#Requires -Version 7.0 <# .SYNOPSIS End-to-end CLI test for the AB CIP driver (ControlLogix / CompactLogix / Micro800 / GuardLogix) bridged through the OtOpcUa server. .DESCRIPTION Mirrors test-modbus.ps1 but against libplctag's ab_server (or a real Logix controller). Five assertions: probe / driver-loopback / forward-bridge / reverse-bridge / subscribe-sees-change. Prereqs: - ab_server container up (tests/.../AbCip.IntegrationTests/Docker/docker-compose.yml, --profile controllogix) OR a real PLC on the network. - OtOpcUa server running with an AB CIP DriverInstance pointing at the same gateway + a Tag published at the -BridgeNodeId you pass. .PARAMETER Gateway ab://host[:port]/cip-path. Default ab://127.0.0.1/1,0 (ab_server ControlLogix). .PARAMETER Family ControlLogix / CompactLogix / Micro800 / GuardLogix (default ControlLogix). .PARAMETER TagPath Logix symbolic path to exercise. Default 'TestDINT' — matches the ab_server --tag=TestDINT:DINT[1] seed. .PARAMETER OpcUaUrl OtOpcUa server endpoint. .PARAMETER BridgeNodeId NodeId at which the server publishes the TagPath. .PARAMETER FastBridgeNodeId Optional NodeId for a Tag declared with ScanRateMs <= 100. When supplied alongside SlowBridgeNodeId the script runs the per-tag scan-rate assertion (PR abcip-4.1). .PARAMETER SlowBridgeNodeId Optional NodeId for a Tag declared with ScanRateMs >= 1000. Pair with FastBridgeNodeId to enable the scan-rate assertion. .PARAMETER SystemConnectionStatusNodeId Optional NodeId for the synthetic _System/_ConnectionStatus variable emitted by AB CIP discovery (PR abcip-4.3). When supplied, the script runs the SystemTagBrowse assertion — reads the value through the OPC UA server + asserts it surfaces one of the canonical HostState strings. NodeId form: ns=;s=AbCip//_System/_ConnectionStatus. .PARAMETER RefreshTagDbNodeId Optional NodeId for the writeable _System/_RefreshTagDb trigger added in PR abcip-4.4. When supplied, the script runs the RefreshTagDbWrite assertion — writes True through the OPC UA server + reads back, asserting the trigger latches to False (Kepware-style "always idle" semantics) and the write itself surfaces Good. NodeId form: ns=;s=AbCip//_System/_RefreshTagDb. #> param( [string]$Gateway = "ab://127.0.0.1/1,0", [string]$Family = "ControlLogix", [string]$TagPath = "TestDINT", [string]$OpcUaUrl = "opc.tcp://localhost:4840", [Parameter(Mandatory)] [string]$BridgeNodeId, [string]$FastBridgeNodeId, [string]$SlowBridgeNodeId, # PR abcip-4.3 — NodeId for the synthetic _System/_ConnectionStatus variable that # discovery emits under each device. Optional — when wired, runs the # SystemTagBrowse assertion that browses + reads the system folder through the OPC UA # server. NodeId form: ns=;s=AbCip//_System/_ConnectionStatus. [string]$SystemConnectionStatusNodeId, # PR abcip-4.4 — NodeId for the writeable _System/_RefreshTagDb refresh-trigger. # Mirrors the SystemConnectionStatusNodeId knob: optional, only runs the # RefreshTagDbWrite assertion when supplied. NodeId form: # ns=;s=AbCip//_System/_RefreshTagDb. [string]$RefreshTagDbNodeId ) $ErrorActionPreference = "Stop" . "$PSScriptRoot/_common.ps1" $abcipCli = Get-CliInvocation ` -ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli" ` -ExeName "otopcua-abcip-cli" $opcUaCli = Get-CliInvocation ` -ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" ` -ExeName "otopcua-cli" $commonAbCip = @("-g", $Gateway, "-f", $Family) $results = @() # The AbCip driver's TagPath parser rejects CIP attribute syntax like # `@raw_cpu_type` ("malformed TagPath"), so probe uses the real TagPath for # every family. Works against ab_server + real controllers alike. $results += Test-Probe ` -Cli $abcipCli ` -ProbeArgs (@("probe") + $commonAbCip + @("-t", $TagPath, "--type", "DInt")) $writeValue = Get-Random -Minimum 1 -Maximum 9999 $results += Test-DriverLoopback ` -Cli $abcipCli ` -WriteArgs (@("write") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "-v", $writeValue)) ` -ReadArgs (@("read") + $commonAbCip + @("-t", $TagPath, "--type", "DInt")) ` -ExpectedValue "$writeValue" $bridgeValue = Get-Random -Minimum 10000 -Maximum 19999 $results += Test-ServerBridge ` -DriverCli $abcipCli ` -DriverWriteArgs (@("write") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "-v", $bridgeValue)) ` -OpcUaCli $opcUaCli ` -OpcUaUrl $OpcUaUrl ` -OpcUaNodeId $BridgeNodeId ` -ExpectedValue "$bridgeValue" $reverseValue = Get-Random -Minimum 20000 -Maximum 29999 $results += Test-OpcUaWriteBridge ` -OpcUaCli $opcUaCli ` -OpcUaUrl $OpcUaUrl ` -OpcUaNodeId $BridgeNodeId ` -DriverCli $abcipCli ` -DriverReadArgs (@("read") + $commonAbCip + @("-t", $TagPath, "--type", "DInt")) ` -ExpectedValue "$reverseValue" $subValue = Get-Random -Minimum 30000 -Maximum 39999 $results += Test-SubscribeSeesChange ` -OpcUaCli $opcUaCli ` -OpcUaUrl $OpcUaUrl ` -OpcUaNodeId $BridgeNodeId ` -DriverCli $abcipCli ` -DriverWriteArgs (@("write") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "-v", $subValue)) ` -ExpectedValue "$subValue" # PR abcip-3.2 — Symbolic-vs-Logical sanity assertion. Reads the same tag with both # addressing modes through the CLI's --addressing-mode flag. Logical-mode against ab_server # falls back to Symbolic on the wire (libplctag wrapper limitation; see AbCip-Performance.md # §Addressing mode), so the assertion is "both modes complete + return the same value" — not # a perf comparison. Skipped on Micro800 (driver downgrades Logical → Symbolic with warning, # making both reads identical-by-design + uninteresting to compare here). if ($Family -ne "Micro800") { $symValue = Get-Random -Minimum 40000 -Maximum 49999 Write-Host "AB CIP e2e: priming gateway with $symValue then reading via Symbolic + Logical" $writeArgs = @("write") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "-v", $symValue) & $abcipCli.Exe @($abcipCli.Args + $writeArgs) | Out-Null $symRead = & $abcipCli.Exe @($abcipCli.Args + @("read") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "--addressing-mode", "Symbolic")) $logRead = & $abcipCli.Exe @($abcipCli.Args + @("read") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "--addressing-mode", "Logical")) $symMatched = ($symRead -join "`n") -match "$symValue" $logMatched = ($logRead -join "`n") -match "$symValue" $passed = $symMatched -and $logMatched $results += [PSCustomObject]@{ Name = "AddressingModeSanity" Passed = $passed Detail = if ($passed) { "Symbolic + Logical both returned $symValue" } else { "Sym=$symMatched Log=$logMatched" } } } # PR abcip-4.1 — per-tag scan-rate divergence assertion. Runs only when both fast + slow # NodeIds are wired; otherwise this knob is skipped on the existing single-NodeId fixture. # The assertion is "fast bucket sees > 5x as many notifications as slow bucket" — the # unit + integration tests cover the bucketing math, this just proves the multi-rate split # survives end-to-end through the OPC UA server's Subscription / MonitoredItem path. if ($FastBridgeNodeId -and $SlowBridgeNodeId) { Write-Header "Per-tag scan rate (FastBridge=$FastBridgeNodeId, SlowBridge=$SlowBridgeNodeId)" $duration = 8 $fastOut = New-TemporaryFile $slowOut = New-TemporaryFile $fastErr = New-TemporaryFile $slowErr = New-TemporaryFile $fastArgs = @($opcUaCli.PrefixArgs) + @("subscribe", "-u", $OpcUaUrl, "-n", $FastBridgeNodeId, "-i", "100", "--duration", "$duration") $slowArgs = @($opcUaCli.PrefixArgs) + @("subscribe", "-u", $OpcUaUrl, "-n", $SlowBridgeNodeId, "-i", "1000", "--duration", "$duration") $fastProc = Start-Process -FilePath $opcUaCli.File -ArgumentList $fastArgs ` -NoNewWindow -PassThru ` -RedirectStandardOutput $fastOut.FullName ` -RedirectStandardError $fastErr.FullName $slowProc = Start-Process -FilePath $opcUaCli.File -ArgumentList $slowArgs ` -NoNewWindow -PassThru ` -RedirectStandardOutput $slowOut.FullName ` -RedirectStandardError $slowErr.FullName Start-Sleep -Seconds 2 # Drive a single PLC change so even stable tags get *one* notification during the window # (initial-data push + 1 change). The cadence assertion below relies on the fast tag # accumulating sampling-interval-driven events even between explicit changes. $tickValue = Get-Random -Minimum 50000 -Maximum 59999 $writeArgs = @("write") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "-v", $tickValue) & $abcipCli.Exe @($abcipCli.Args + $writeArgs) | Out-Null $fastProc.WaitForExit(($duration + 5) * 1000) | Out-Null $slowProc.WaitForExit(($duration + 5) * 1000) | Out-Null if (-not $fastProc.HasExited) { Stop-Process -Id $fastProc.Id -Force } if (-not $slowProc.HasExited) { Stop-Process -Id $slowProc.Id -Force } $fastText = (Get-Content $fastOut.FullName -Raw) + (Get-Content $fastErr.FullName -Raw) $slowText = (Get-Content $slowOut.FullName -Raw) + (Get-Content $slowErr.FullName -Raw) Remove-Item $fastOut.FullName, $slowOut.FullName, $fastErr.FullName, $slowErr.FullName -ErrorAction SilentlyContinue # Each data-change line matches `=\s*\s*()` per Test-SubscribeSeesChange. $fastMatches = ([regex]::Matches($fastText, "=\s*\S+\s*\(")).Count $slowMatches = ([regex]::Matches($slowText, "=\s*\S+\s*\(")).Count $passed = ($fastMatches -ge 5) -and ($fastMatches -gt ($slowMatches * 5)) $detail = if ($passed) { "fast=$fastMatches notifications vs slow=$slowMatches (>5x ratio achieved)" } else { "fast=$fastMatches slow=$slowMatches — expected fast > slow*5" } $results += [PSCustomObject]@{ Name = "PerTagScanRate"; Passed = $passed; Detail = $detail } } # PR abcip-4.2 — write-coalesce assertion. Writes the same value twice through the OPC UA # server and verifies the PLC-side state reflects only one wire write. The driver-side # diagnostics counter (AbCip.WritesSuppressed) is the authoritative signal, but ab_server # itself doesn't expose a "writes received" counter so this script-level check is intentionally # observational — it primes the tag with a baseline, writes the same value twice, and reads # back to confirm the value matches without surfacing additional state changes. The unit + integration # tests do the strict "exactly N suppressions" math; this is the e2e shape proof. $coalesceValue = Get-Random -Minimum 60000 -Maximum 69999 Write-Header "WriteCoalesce (baseline=$coalesceValue, two redundant writes)" $writeArgs = @("write") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "-v", $coalesceValue) & $abcipCli.Exe @($abcipCli.Args + $writeArgs) | Out-Null & $abcipCli.Exe @($abcipCli.Args + $writeArgs) | Out-Null & $abcipCli.Exe @($abcipCli.Args + $writeArgs) | Out-Null $readArgs = @("read") + $commonAbCip + @("-t", $TagPath, "--type", "DInt") $readOut = & $abcipCli.Exe @($abcipCli.Args + $readArgs) $coalesceMatch = ($readOut -join "`n") -match "$coalesceValue" $results += [PSCustomObject]@{ Name = "WriteCoalesce" Passed = $coalesceMatch Detail = if ($coalesceMatch) { "three identical writes of $coalesceValue produced the expected readback (driver-side WritesSuppressed counter exposed via driver-diagnostics RPC)" } else { "three identical writes did not converge on $coalesceValue — got '$readOut'" } } # PR abcip-4.3 — _System/_ConnectionStatus browse-and-read assertion. Reads the live # diagnostic snapshot via the OPC UA Client CLI; the value comes straight from the # AbCipSystemTagSource (no libplctag round-trip). When the probe loop is healthy + the # gateway is reachable, the value should be "Running"; on a stopped fixture it would be # "Stopped". The assertion accepts any of the four canonical states, plus the "Unknown" # transient that surfaces before the first probe iteration completes. if ($SystemConnectionStatusNodeId) { Write-Header "SystemTagBrowse (_System/_ConnectionStatus from $SystemConnectionStatusNodeId)" $sysReadArgs = @($opcUaCli.PrefixArgs) + @("read", "-u", $OpcUaUrl, "-n", $SystemConnectionStatusNodeId) $sysOut = & $opcUaCli.File @sysReadArgs 2>&1 $sysJoined = ($sysOut -join "`n") $sysMatched = $sysJoined -match "Running|Stopped|Unknown|Faulted" $results += [PSCustomObject]@{ Name = "SystemTagBrowse" Passed = $sysMatched Detail = if ($sysMatched) { "_ConnectionStatus surfaced one of Running / Stopped / Unknown / Faulted via OPC UA" } else { "_ConnectionStatus did not surface a recognised HostState — got '$sysJoined'" } } } # PR abcip-4.4 — _RefreshTagDb write-then-verify assertion. Writes True through the # OPC UA server (the live driver intercepts the write + dispatches to RebrowseAsync # against the cached IAddressSpaceBuilder) + reads back, asserting Kepware-style # latch semantics: the trigger always reads False the moment the dispatch returns. # Pairs with the existing rebrowse step driven by the AbCip CLI (issue #233) — both # surfaces hit the same RebrowseAsync entry point, just from different sides of the # OPC UA wire. if ($RefreshTagDbNodeId) { Write-Header "RefreshTagDbWrite (_System/_RefreshTagDb from $RefreshTagDbNodeId)" $writeArgs = @($opcUaCli.PrefixArgs) + @( "write", "-u", $OpcUaUrl, "-n", $RefreshTagDbNodeId, "-v", "true", "--type", "Boolean") $writeOut = & $opcUaCli.File @writeArgs 2>&1 $writeJoined = ($writeOut -join "`n") # The OPC UA Client CLI surfaces "Good" on success; a non-Good result still # round-trips the literal status code so we can match generously. $writeOk = $writeJoined -match "Good" $readArgs = @($opcUaCli.PrefixArgs) + @("read", "-u", $OpcUaUrl, "-n", $RefreshTagDbNodeId) $readOut = & $opcUaCli.File @readArgs 2>&1 $readJoined = ($readOut -join "`n") # Kepware-style trigger reads always return false — assert the trigger isn't # latched to true after the write. Match case-insensitively because the OPC UA # Client CLI may render the value as "False" or "false". $readFalse = $readJoined -imatch "false" $passed = $writeOk -and $readFalse $results += [PSCustomObject]@{ Name = "RefreshTagDbWrite" Passed = $passed Detail = if ($passed) { "_RefreshTagDb write returned Good and read-back surfaced false — Kepware-style latch held" } else { "RefreshTagDb write/verify failed — write='$writeJoined' read='$readJoined'" } } } Write-Summary -Title "AB CIP e2e" -Results $results if ($results | Where-Object { -not $_.Passed }) { exit 1 }