#Requires -Version 7.0 <# .SYNOPSIS End-to-end CLI test for the AB Legacy (PCCC) driver. .DESCRIPTION Runs against libplctag's ab_server PCCC Docker fixture (one of the slc500 / micrologix / plc5 compose profiles) or real SLC / MicroLogix / PLC-5 hardware. Five assertions: probe / driver-loopback / forward- bridge / reverse-bridge / subscribe-sees-change. ab_server enforces a non-empty CIP routing path (`/1,0`) before the PCCC dispatcher runs; real hardware accepts an empty path. The default $Gateway uses `/1,0` for the Docker fixture — pass `-Gateway "ab://host:44818/"` when pointing at a real SLC 5/05 / MicroLogix / PLC-5. .PARAMETER Gateway ab://host[:port]/cip-path. Default ab://127.0.0.1/1,0 (Docker fixture). .PARAMETER PlcType Slc500 / MicroLogix / Plc5 / LogixPccc (default Slc500). .PARAMETER Address PCCC address to exercise. Default N7:5. .PARAMETER OpcUaUrl OtOpcUa server endpoint. .PARAMETER BridgeNodeId NodeId at which the server publishes the Address. .PARAMETER DiagnosticsRequestCountNodeId Optional NodeId for the synthetic _Diagnostics//RequestCount variable emitted by AB Legacy discovery (PR ablegacy-10 / #253). When supplied, the script runs the DiagnosticsRequestCount assertion: reads the user-tag BridgeNodeId N times through the OPC UA server, then reads the diagnostic counter and asserts the value is at least N (a probe loop or a parallel client may have bumped it by more, so the comparison is `>=`). NodeId form: ns=;s=AbLegacy//_Diagnostics/RequestCount. Mirrors the -SystemConnectionStatusNodeId knob on test-abcip.ps1. .PARAMETER DiagnosticsDemoteCountNodeId Optional NodeId for the synthetic _Diagnostics//DemoteCount variable emitted by AB Legacy discovery (PR ablegacy-12 / #255). When supplied, the script runs the auto-demote assertion: kills the simulator container so reads start failing, hammers the user-tag BridgeNodeId at least FailureThreshold times to trip the demotion, then reads the diagnostic counter and asserts the value increased by >= 1. NodeId form: ns=;s=AbLegacy//_Diagnostics/DemoteCount. The simulator must support `docker stop otopcua-ab-server-slc500` for the kill stage. .PARAMETER FailureThresholdForDemote Failure threshold the server is configured with (default 3). The demote assertion writes/reads N+1 times against the killed simulator to guarantee the threshold trips even if some reads beat the kill. .PARAMETER DhPlusStation PR ablegacy-13 / #256 — DH+ node address (octal 0..77 == decimal 0..63) of a PLC-5 reachable through a 1756-DHRIO module. **Documentation parameter only — there is no automated assertion**: libplctag's ab_server does not simulate the DHRIO + DH+ + PLC-5 stack, so wire-level coverage requires real hardware. When supplied alongside a `-Gateway` of the form `ab:///1,,2,` and `-PlcType Plc5`, the value here is recorded in the run log so reproducibility is auditable. See docs/drivers/AbLegacy-DH-Bridging.md for the manual smoke procedure. #> param( [string]$Gateway = "ab://127.0.0.1/1,0", [string]$PlcType = "Slc500", [string]$Address = "N7:5", [string]$OpcUaUrl = "opc.tcp://localhost:4840", [Parameter(Mandatory)] [string]$BridgeNodeId, [string]$DiagnosticsRequestCountNodeId, [string]$DiagnosticsDemoteCountNodeId, [int]$FailureThresholdForDemote = 3, # PR ablegacy-13 / #256 — DH+ station via 1756-DHRIO bridging. Doc-only: # no automated assertion (no Docker fixture covers DH+). See script header # comment + docs/drivers/AbLegacy-DH-Bridging.md. [string]$DhPlusStation ) $ErrorActionPreference = "Stop" . "$PSScriptRoot/_common.ps1" # ab_server PCCC works; the earlier "upstream-broken" gate is gone. The only # caveat: libplctag's ab_server rejects empty CIP paths, so $Gateway must # carry a non-empty path segment (default /1,0). Real SLC/PLC-5 hardware # accepts an empty path — use `ab://host:44818/` when pointing at real PLCs. $abLegacyCli = Get-CliInvocation ` -ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli" ` -ExeName "otopcua-ablegacy-cli" $opcUaCli = Get-CliInvocation ` -ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" ` -ExeName "otopcua-cli" $commonAbLegacy = @("-g", $Gateway, "-P", $PlcType) $results = @() $results += Test-Probe ` -Cli $abLegacyCli ` -ProbeArgs (@("probe") + $commonAbLegacy + @("-a", "N7:0")) $writeValue = Get-Random -Minimum 1 -Maximum 9999 $results += Test-DriverLoopback ` -Cli $abLegacyCli ` -WriteArgs (@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", $writeValue)) ` -ReadArgs (@("read") + $commonAbLegacy + @("-a", $Address, "-t", "Int")) ` -ExpectedValue "$writeValue" $bridgeValue = Get-Random -Minimum 10000 -Maximum 19999 $results += Test-ServerBridge ` -DriverCli $abLegacyCli ` -DriverWriteArgs (@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-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 $abLegacyCli ` -DriverReadArgs (@("read") + $commonAbLegacy + @("-a", $Address, "-t", "Int")) ` -ExpectedValue "$reverseValue" $subValue = Get-Random -Minimum 30000 -Maximum 32766 $results += Test-SubscribeSeesChange ` -OpcUaCli $opcUaCli ` -OpcUaUrl $OpcUaUrl ` -OpcUaNodeId $BridgeNodeId ` -DriverCli $abLegacyCli ` -DriverWriteArgs (@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", $subValue)) ` -ExpectedValue "$subValue" # PR 7 — contiguous array read smoke. The default `--tag=N7[120]` in the Docker # fixture's docker-compose.yml has plenty of room for `,10`; against real hardware # the seeded N7 file just needs at least 10 words. Asserts the CLI exits 0 (the # driver issued one PCCC frame for the whole block) — the per-element values are # whatever the device currently holds. Write-Header "Array contiguous read" $arrayResult = Invoke-Cli -Cli $abLegacyCli ` -Args (@("read") + $commonAbLegacy + @("-a", "N7:0,10", "-t", "Int")) if ($arrayResult.ExitCode -eq 0) { Write-Pass "array read N7:0,10 succeeded" $results += @{ Passed = $true } } else { Write-Fail "array read N7:0,10 exit=$($arrayResult.ExitCode)" Write-Host $arrayResult.Output $results += @{ Passed = $false; Reason = "array read exit $($arrayResult.ExitCode)" } } # PR 8 — deadband subscribe assertion. Subscribe with --deadband-absolute 5, # write three small deltas (each within the 5-unit deadband), assert exactly # one notification fires (the first-seen sample). The fourth write breaks # above the threshold and the subscription should fire again. Write-Header "Deadband subscribe (--deadband-absolute 5)" $baseValue = Get-Random -Minimum 100 -Maximum 200 & $abLegacyCli.File @($abLegacyCli.PrefixArgs) ` @("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", $baseValue) | Out-Null $subscribeProc = Start-Process -FilePath $abLegacyCli.File ` -ArgumentList ($abLegacyCli.PrefixArgs + @("subscribe") + $commonAbLegacy ` + @("-a", $Address, "-t", "Int", "-i", "200", "--deadband-absolute", "5")) ` -PassThru -RedirectStandardOutput "$env:TEMP/ablegacy-deadband.out" ` -RedirectStandardError "$env:TEMP/ablegacy-deadband.err" Start-Sleep -Seconds 2 # Three small deltas within deadband. & $abLegacyCli.File @($abLegacyCli.PrefixArgs) ` @("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", ($baseValue + 1)) | Out-Null Start-Sleep -Milliseconds 500 & $abLegacyCli.File @($abLegacyCli.PrefixArgs) ` @("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", ($baseValue + 2)) | Out-Null Start-Sleep -Milliseconds 500 & $abLegacyCli.File @($abLegacyCli.PrefixArgs) ` @("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", ($baseValue + 3)) | Out-Null Start-Sleep -Milliseconds 500 Stop-Process -Id $subscribeProc.Id -Force -ErrorAction SilentlyContinue $subscribeOutput = Get-Content "$env:TEMP/ablegacy-deadband.out" -ErrorAction SilentlyContinue # Count `=` lines (the SubscribeCommand format prints one per OnDataChange). Expect exactly 1 # (the first-seen sample at $baseValue) — none of the +1/+2/+3 deltas crosses the 5 absolute. $notifyLines = @($subscribeOutput | Where-Object { $_ -match " = " }) if ($notifyLines.Count -eq 1) { Write-Pass "deadband subscribe emitted 1 notification (initial only); 3 sub-threshold writes suppressed" $results += @{ Passed = $true } } else { Write-Fail "deadband subscribe expected 1 notification; got $($notifyLines.Count)" Write-Host ($subscribeOutput -join "`n") $results += @{ Passed = $false; Reason = "deadband notify count $($notifyLines.Count)" } } # PR ablegacy-10 / #253 — diagnostic-counter round-trip assertion. After N reads # against the user-tag BridgeNodeId the auto-emitted _Diagnostics//RequestCount # counter must be >= N. The exact equality isn't asserted because a probe loop / # parallel client may have bumped the counter — the spec is "every read counts". if ($DiagnosticsRequestCountNodeId) { Write-Header "DiagnosticsRequestCount (_Diagnostics/RequestCount from $DiagnosticsRequestCountNodeId)" $diagN = 5 # Read the first counter snapshot to baseline; the assertion compares delta against # the N OPC UA reads we issue between snapshots so a noisy probe loop doesn't # invalidate the test. $baselineOut = & $opcUaCli.File @($opcUaCli.PrefixArgs) ` @("read", "-u", $OpcUaUrl, "-n", $DiagnosticsRequestCountNodeId) 2>&1 $baseline = 0 if (($baselineOut -join "`n") -match '(\d+)') { $baseline = [int64]$Matches[1] } for ($i = 0; $i -lt $diagN; $i++) { & $opcUaCli.File @($opcUaCli.PrefixArgs) ` @("read", "-u", $OpcUaUrl, "-n", $BridgeNodeId) | Out-Null } $afterOut = & $opcUaCli.File @($opcUaCli.PrefixArgs) ` @("read", "-u", $OpcUaUrl, "-n", $DiagnosticsRequestCountNodeId) 2>&1 $after = 0 if (($afterOut -join "`n") -match '(\d+)') { $after = [int64]$Matches[1] } $delta = $after - $baseline if ($delta -ge $diagN) { Write-Pass "DiagnosticsRequestCount delta $delta >= $diagN OPC UA reads" $results += @{ Passed = $true } } else { Write-Fail "DiagnosticsRequestCount delta $delta < $diagN OPC UA reads (baseline=$baseline after=$after)" $results += @{ Passed = $false; Reason = "diag delta $delta < $diagN" } } } # ablegacy-11 / #254 — RSLogix CSV import smoke. Builds an in-memory canonical CSV # (one row per N/F/B/L/ST/T/C/R file letter), invokes `import-rslogix --emit # appsettings-fragment` against it, parses the resulting JSON, and asserts the Tags # array carries exactly 8 entries. Doesn't talk to the PLC — purely offline parser # coverage. Write-Header "RSLogix CSV import" $importCsvPath = Join-Path $env:TEMP "ablegacy-rslogix-canonical-$([guid]::NewGuid()).csv" $importJsonPath = Join-Path $env:TEMP "ablegacy-rslogix-fragment-$([guid]::NewGuid()).json" @" Symbol,Address,Description,DataType,Scope MotorSpeed,N7:0,Motor speed setpoint,INT,Global TankLevel,F8:0,Tank level (gallons),REAL,Global RunFlag,B3:0/0,Run command flag,BOOL,Global TotalCount,L9:0,Total piece count,LINT,Global RecipeName,ST10:0,"Recipe name, free-form text",STRING,Global DwellTimer,T4:0.ACC,Dwell timer accumulator,TIMER,Global PieceCounter,C5:0.ACC,Piece counter accumulator,COUNTER,Global StateMachine,R6:0.LEN,State-machine control length,CONTROL,Global "@ | Set-Content -Path $importCsvPath -Encoding UTF8 try { $importResult = Invoke-Cli -Cli $abLegacyCli ` -Args @("import-rslogix", "--file", $importCsvPath, "--device", $Gateway, "--emit", "appsettings-fragment", "--output", $importJsonPath) if ($importResult.ExitCode -ne 0) { Write-Fail "import-rslogix exit=$($importResult.ExitCode): $($importResult.Output)" $results += @{ Passed = $false; Reason = "import-rslogix exit $($importResult.ExitCode)" } } elseif (-not (Test-Path $importJsonPath)) { Write-Fail "import-rslogix produced no output file at $importJsonPath" $results += @{ Passed = $false; Reason = "no output file" } } else { $fragment = Get-Content $importJsonPath -Raw | ConvertFrom-Json $tagCount = @($fragment.Tags).Count if ($tagCount -eq 8) { Write-Pass "import-rslogix emitted $tagCount tag(s) — matches CSV row count" $results += @{ Passed = $true } } else { Write-Fail "import-rslogix emitted $tagCount tag(s); expected 8" $results += @{ Passed = $false; Reason = "tag count $tagCount" } } } } finally { Remove-Item -Path $importCsvPath -ErrorAction SilentlyContinue Remove-Item -Path $importJsonPath -ErrorAction SilentlyContinue } # PR ablegacy-12 / #255 — auto-demote round-trip. Kill the simulator container, # hammer the bridge NodeId past the failure threshold, then assert the # DemoteCount diagnostic incremented. Restart the simulator at the end so the # next run gets a clean baseline. Gated on -DiagnosticsDemoteCountNodeId so # environments without docker-side control of the simulator can opt out. if ($DiagnosticsDemoteCountNodeId) { Write-Header "AutoDemote (kill simulator + observe DemoteCount from $DiagnosticsDemoteCountNodeId)" $baselineDemoteOut = & $opcUaCli.File @($opcUaCli.PrefixArgs) ` @("read", "-u", $OpcUaUrl, "-n", $DiagnosticsDemoteCountNodeId) 2>&1 $baselineDemote = 0 if (($baselineDemoteOut -join "`n") -match '(\d+)') { $baselineDemote = [int64]$Matches[1] } # Best-effort container kill — prefer the slc500 profile name; fall back to # micrologix / plc5 in case the operator pointed the e2e at a different family. $simContainers = @("otopcua-ab-server-slc500", "otopcua-ab-server-micrologix", "otopcua-ab-server-plc5") $killed = $false foreach ($c in $simContainers) { $stop = docker stop $c 2>$null if ($LASTEXITCODE -eq 0 -and $stop) { Write-Host "Stopped $c" $killed = $true break } } if (-not $killed) { Write-Fail "AutoDemote: no ab_server container found via 'docker stop' — skipping demote assertion" $results += @{ Passed = $false; Reason = "no simulator container to kill" } } else { # Hammer past the threshold. Each read against a now-unreachable simulator # surfaces BadCommunicationError; FailureThreshold consecutive ones trip # the demotion. We add 2 extra to absorb timing slack (one read may be # in-flight when the kill lands). $hammerCount = $FailureThresholdForDemote + 2 for ($i = 0; $i -lt $hammerCount; $i++) { & $opcUaCli.File @($opcUaCli.PrefixArgs) ` @("read", "-u", $OpcUaUrl, "-n", $BridgeNodeId) 2>&1 | Out-Null } Start-Sleep -Seconds 1 $afterDemoteOut = & $opcUaCli.File @($opcUaCli.PrefixArgs) ` @("read", "-u", $OpcUaUrl, "-n", $DiagnosticsDemoteCountNodeId) 2>&1 $afterDemote = 0 if (($afterDemoteOut -join "`n") -match '(\d+)') { $afterDemote = [int64]$Matches[1] } $deltaDemote = $afterDemote - $baselineDemote if ($deltaDemote -ge 1) { Write-Pass "AutoDemote DemoteCount delta $deltaDemote >= 1 after $hammerCount failed reads" $results += @{ Passed = $true } } else { Write-Fail "AutoDemote DemoteCount delta $deltaDemote < 1 (baseline=$baselineDemote after=$afterDemote)" $results += @{ Passed = $false; Reason = "demote delta $deltaDemote" } } # Restart the simulator so subsequent test runs have a clean baseline. # Best-effort — if docker-compose isn't on the path the operator can # bring it back manually via the Docker/docker-compose.yml profile. try { docker start (docker ps -aq -f "name=otopcua-ab-server-") | Out-Null } catch { } } } Write-Summary -Title "AB Legacy e2e" -Results $results if ($results | Where-Object { -not $_.Passed }) { exit 1 }