#Requires -Version 7.0 <# .SYNOPSIS End-to-end CLI test for AB CIP HSBY failover routing (PR abcip-5.2). Subscribes to a tag through the OtOpcUa OPC UA server, flips the active chassis mid-stream via the paired-fixture's hsby-mux sidecar HTTP endpoint, and asserts the subscribe stream survives the failover (no permanent loss of notifications + the post-flip data carries the partner-side update). .DESCRIPTION Paired-fixture variant of test-abcip.ps1. Where test-abcip.ps1 runs against a single ab_server instance, this script assumes a paired fixture with two ab_server instances (primary + partner) and an hsby-mux sidecar exposing /flip {"active": "primary" | "partner"} over HTTP. Five assertions: - HsbyInitialActive — primary is Active at start (hsby-mux primes it) - HsbyResolveActive — driver-diagnostics surfaces AbCip.HsbyActive == 1 - HsbyFailoverFlip — POST {"active": "partner"} → AbCip.HsbyActive == 2 - HsbySubscribeSurvives — subscribe stream stays open across the flip + sees an updated value from the partner side - HsbyFailoverCount — AbCip.HsbyFailoverCount increments by ≥ 1 .PARAMETER PrimaryGateway ab://host[:port]/cip-path of the primary chassis. Default ab://127.0.0.1/1,0. .PARAMETER PartnerGateway ab://host[:port]/cip-path of the partner chassis. Default ab://127.0.0.2/1,0. .PARAMETER HsbyMuxUrl Base URL of the paired-fixture's hsby-mux sidecar. Default http://localhost:7080. Endpoints used: GET /role → returns {"primary":"Active","partner":"Standby"} POST /flip {"active":"primary"|"partner"} → flips role tag values on each chassis .PARAMETER OpcUaUrl OtOpcUa server endpoint. Default opc.tcp://localhost:4840. .PARAMETER BridgeNodeId NodeId at which the server publishes the tag exercised by the subscribe assertion. Required. .PARAMETER TagPath Logix symbolic path the bridge tag points at. Default 'TestDINT'. .PARAMETER DriverInstanceId DriverInstance ID for the AB CIP driver under test. Used to scope the driver-diagnostics RPC. Default 'abcip-hsby'. .EXAMPLE ./test-abcip-hsby.ps1 -BridgeNodeId 'ns=2;s=AbCip/Bridge/TestDINT' #> param( [string]$PrimaryGateway = "ab://127.0.0.1/1,0", [string]$PartnerGateway = "ab://127.0.0.2/1,0", [string]$HsbyMuxUrl = "http://localhost:7080", [string]$OpcUaUrl = "opc.tcp://localhost:4840", [Parameter(Mandatory)] [string]$BridgeNodeId, [string]$TagPath = "TestDINT", [string]$DriverInstanceId = "abcip-hsby" ) $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" $results = @() function Invoke-HsbyFlip { param([string]$Active) $body = @{ active = $Active } | ConvertTo-Json -Compress try { Invoke-RestMethod -Uri "$HsbyMuxUrl/flip" -Method Post -Body $body -ContentType 'application/json' } catch { throw "hsby-mux at $HsbyMuxUrl/flip rejected the request: $($_.Exception.Message)" } } function Get-HsbyDiagnosticValue { param([string]$Counter) # Pull driver-diagnostics through the OPC UA Admin RPC surface. The CLI returns # a raw JSON blob; we grep out the named counter so the assertion is robust to # other counters the driver surfaces. $diagArgs = @($opcUaCli.PrefixArgs) + @( "driver-diagnostics", "-u", $OpcUaUrl, "-d", $DriverInstanceId) $diagOut = & $opcUaCli.File @diagArgs 2>&1 $joined = ($diagOut -join "`n") if ($joined -match "${Counter}.*?:\s*([\d\.]+)") { return [double]$matches[1] } return $null } # ---- HsbyInitialActive — hsby-mux primes primary as Active ---- Write-Header "HsbyInitialActive (POST $HsbyMuxUrl/flip {active=primary})" try { Invoke-HsbyFlip -Active "primary" | Out-Null Start-Sleep -Seconds 3 # role-probe loop default tick is 2s $active = Get-HsbyDiagnosticValue -Counter "AbCip.HsbyActive" $passed = ($active -eq 1.0) $results += [PSCustomObject]@{ Name = "HsbyInitialActive" Passed = $passed Detail = if ($passed) { "AbCip.HsbyActive=1 after priming primary" } else { "AbCip.HsbyActive=$active (expected 1)" } } } catch { $results += [PSCustomObject]@{ Name = "HsbyInitialActive"; Passed = $false; Detail = $_.Exception.Message } } # ---- HsbyResolveActive — driver routing reads through the primary ---- Write-Header "HsbyResolveActive (read $TagPath via primary)" $readArgs = @("read") + @("-g", $PrimaryGateway, "-f", "ControlLogix") + @("-t", $TagPath, "--type", "DInt") $readOut = & $abcipCli.Exe @($abcipCli.Args + $readArgs) 2>&1 $readOk = ($readOut -join "`n") -notmatch "(error|fail)" $results += [PSCustomObject]@{ Name = "HsbyResolveActive" Passed = $readOk Detail = if ($readOk) { "primary read completed without error" } else { "read failed: $($readOut -join ' ')" } } # ---- HsbySubscribeSurvives + HsbyFailoverFlip + HsbyFailoverCount ---- Write-Header "HsbyFailoverFlip + HsbySubscribeSurvives (subscribe across flip)" $failoverBaseline = Get-HsbyDiagnosticValue -Counter "AbCip.HsbyFailoverCount" if ($null -eq $failoverBaseline) { $failoverBaseline = 0 } $duration = 12 $subOut = New-TemporaryFile $subErr = New-TemporaryFile $subArgs = @($opcUaCli.PrefixArgs) + @( "subscribe", "-u", $OpcUaUrl, "-n", $BridgeNodeId, "-i", "200", "--duration", "$duration") $subProc = Start-Process -FilePath $opcUaCli.File -ArgumentList $subArgs ` -NoNewWindow -PassThru ` -RedirectStandardOutput $subOut.FullName ` -RedirectStandardError $subErr.FullName # Let the subscribe settle + accumulate primary-side notifications. Start-Sleep -Seconds 3 # Mid-stream flip — primary→Standby, partner→Active. try { Invoke-HsbyFlip -Active "partner" | Out-Null } catch { Stop-Process -Id $subProc.Id -Force -ErrorAction SilentlyContinue $results += [PSCustomObject]@{ Name = "HsbyFailoverFlip"; Passed = $false; Detail = "hsby-mux flip rejected: $($_.Exception.Message)" } } # Wait for the role-probe loop to catch up (default tick 2s + ProbeIntervalMs slack). Start-Sleep -Seconds 4 # Drive a write through the partner so the subscribe sees a fresh value. $flipValue = Get-Random -Minimum 70000 -Maximum 79999 $writeArgs = @("write") + @("-g", $PartnerGateway, "-f", "ControlLogix") + @("-t", $TagPath, "--type", "DInt", "-v", $flipValue) & $abcipCli.Exe @($abcipCli.Args + $writeArgs) | Out-Null $activeAfter = Get-HsbyDiagnosticValue -Counter "AbCip.HsbyActive" $flipPassed = ($activeAfter -eq 2.0) $results += [PSCustomObject]@{ Name = "HsbyFailoverFlip" Passed = $flipPassed Detail = if ($flipPassed) { "AbCip.HsbyActive=2 after flip" } else { "AbCip.HsbyActive=$activeAfter (expected 2)" } } # Stop the subscribe + harvest the stream. $subProc.WaitForExit(($duration + 5) * 1000) | Out-Null if (-not $subProc.HasExited) { Stop-Process -Id $subProc.Id -Force } $subText = (Get-Content $subOut.FullName -Raw) + (Get-Content $subErr.FullName -Raw) Remove-Item $subOut.FullName, $subErr.FullName -ErrorAction SilentlyContinue # Stream survival = at least one notification *after* the flip carries the new # partner-side value. The post-flip write of $flipValue is the canary. $saw = $subText -match "$flipValue" $results += [PSCustomObject]@{ Name = "HsbySubscribeSurvives" Passed = $saw Detail = if ($saw) { "subscribe stream surfaced post-flip value $flipValue from partner chassis" } else { "subscribe stream did not see the post-flip canary $flipValue — output: $subText" } } # ---- HsbyFailoverCount — counter incremented by ≥ 1 ---- Write-Header "HsbyFailoverCount" $failoverAfter = Get-HsbyDiagnosticValue -Counter "AbCip.HsbyFailoverCount" if ($null -eq $failoverAfter) { $failoverAfter = 0 } $counterOk = ($failoverAfter - $failoverBaseline) -ge 1 $results += [PSCustomObject]@{ Name = "HsbyFailoverCount" Passed = $counterOk Detail = if ($counterOk) { "AbCip.HsbyFailoverCount went from $failoverBaseline → $failoverAfter" } else { "AbCip.HsbyFailoverCount unchanged ($failoverBaseline → $failoverAfter); expected at least 1 increment" } } Write-Summary -Title "AB CIP HSBY failover e2e" -Results $results if ($results | Where-Object { -not $_.Passed }) { exit 1 }