#Requires -Version 7.0 <# .SYNOPSIS End-to-end CLI test for the Galaxy (MXAccess) driver — read, write, subscribe, alarms, and history through a running OtOpcUa server. .DESCRIPTION Unlike the other e2e scripts there is no `otopcua-galaxy-cli` — the Galaxy driver proxy lives in-process with the server + talks to `OtOpcUaGalaxyHost` over a named pipe (MXAccess is 32-bit COM, can't ship in the .NET 10 process). Every stage therefore goes through `otopcua-cli` against the published OPC UA address space. Seven stages: 1. Probe — otopcua-cli connect + read the source NodeId; confirms the whole Galaxy.Host → Proxy → server → client chain is up 2. Source read — otopcua-cli read returns a Good value for the source attribute; proves IReadable.ReadAsync is dispatching through the IPC bridge 3. Virtual-tag bridge — `otopcua-cli read` on the VirtualTag NodeId; confirms the Phase 7 CachedTagUpstreamSource is bridging the driver-sourced input into the scripting engine 4. Subscribe-sees-change — subscribe to the source NodeId in the background; Galaxy pushes a data-change event within N seconds (Galaxy's underlying attribute must be actively changing — production Galaxies typically have scan-driven updates; for idle galaxies, widen -ChangeWaitSec or drive the write stage below first) 5. Reverse bridge — `otopcua-cli write` to a writable Galaxy attribute; read it back. Gracefully becomes INFO-only if the attribute's Galaxy-side AccessLevel forbids writes (BadUserAccessDenied / BadNotWritable) 6. Alarm fires — subscribe to the scripted-alarm Condition NodeId, drive the source tag above its threshold, confirm an Active alarm event surfaces. Exercises the Part 9 alarm-condition propagation path 7. History read — historyread on the source tag over the last hour; confirms Aveva Historian → IHistoryProvider dispatch returns samples The Phase 7 seed (`scripts/smoke/seed-phase-7-smoke.sql`) already plants the right shape — one Galaxy DriverInstance, one source Tag, one VirtualTag (source × 2), one ScriptedAlarm (source > 50). Substitute the real Galaxy attribute FullName into `dbo.Tag.TagConfig` before running. .PARAMETER OpcUaUrl OtOpcUa server endpoint. Default opc.tcp://localhost:4840. .PARAMETER SourceNodeId NodeId of the driver-sourced Galaxy tag (numeric, writable preferred). Default matches the Phase 7 seed — `ns=2;s=p7-smoke-tag-source`. .PARAMETER VirtualNodeId NodeId of the VirtualTag computed as Source × 2 (Phase 7 scripting). Default matches the Phase 7 seed — `ns=2;s=p7-smoke-vt-derived`. .PARAMETER AlarmNodeId NodeId of the scripted-alarm Condition (fires when Source > 50). Default matches the Phase 7 seed — `ns=2;s=p7-smoke-al-overtemp`. .PARAMETER AlarmTriggerValue Value written to -SourceNodeId to push it over the alarm threshold. Default 75 (well above the seeded 50-threshold). .PARAMETER ChangeWaitSec Seconds the subscribe-sees-change stage waits for a natural data change. Default 10. Idle galaxies may need this extended or the stage will fail with "subscribe did not observe...". .PARAMETER AlarmWaitSec Seconds the alarm-fires stage waits after triggering the write. Default 10. .PARAMETER HistoryLookbackSec Seconds back from now to query history. Default 3600 (1 h). .EXAMPLE # Against the default Phase-7 smoke seed + live Galaxy + OtOpcUa server ./scripts/e2e/test-galaxy.ps1 .EXAMPLE # Custom NodeIds from a non-smoke cluster ./scripts/e2e/test-galaxy.ps1 ` -SourceNodeId "ns=2;s=Reactor1.Temperature" ` -VirtualNodeId "ns=2;s=Reactor1.TempDoubled" ` -AlarmNodeId "ns=2;s=Reactor1.OverTemp" ` -AlarmTriggerValue 120 #> param( [string]$OpcUaUrl = "opc.tcp://localhost:4840", [string]$SourceNodeId = "ns=2;s=p7-smoke-tag-source", [string]$VirtualNodeId = "ns=2;s=p7-smoke-vt-derived", [string]$AlarmNodeId = "ns=2;s=p7-smoke-al-overtemp", [string]$AlarmTriggerValue = "75", [int]$ChangeWaitSec = 10, [int]$AlarmWaitSec = 10, [int]$HistoryLookbackSec = 3600 ) $ErrorActionPreference = "Stop" . "$PSScriptRoot/_common.ps1" $opcUaCli = Get-CliInvocation ` -ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" ` -ExeName "otopcua-cli" $results = @() # --------------------------------------------------------------------------- # Stage 1 — Probe. The probe is an otopcua-cli read against the source NodeId; # success implies Galaxy.Host is up + the pipe ACL lets the server connect + # the Proxy is tracking the tag + the server published it. # --------------------------------------------------------------------------- Write-Header "Probe" $probe = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $SourceNodeId) if ($probe.ExitCode -eq 0 -and $probe.Output -match "Status:\s+0x00000000") { Write-Pass "source NodeId readable (Galaxy pipe → proxy → server → client chain up)" $results += @{ Passed = $true } } else { Write-Fail "probe read failed (exit=$($probe.ExitCode))" Write-Host $probe.Output $results += @{ Passed = $false; Reason = "probe failed" } } # --------------------------------------------------------------------------- # Stage 2 — Source read. Captures the current value for the later virtual-tag # comparison + confirms read dispatch works end-to-end. Failure here without a # stage-1 failure would be unusual — probe already reads. # --------------------------------------------------------------------------- Write-Header "Source read" $sourceRead = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $SourceNodeId) $sourceValue = $null if ($sourceRead.ExitCode -eq 0 -and $sourceRead.Output -match "Value:\s+([^\r\n]+)") { $sourceValue = $Matches[1].Trim() Write-Pass "source value = $sourceValue" $results += @{ Passed = $true } } else { Write-Fail "source read failed" Write-Host $sourceRead.Output $results += @{ Passed = $false; Reason = "source read failed" } } # --------------------------------------------------------------------------- # Stage 3 — Virtual-tag bridge. Reads the Phase 7 VirtualTag (source × 2). Not # strictly driver-specific, but exercises the CachedTagUpstreamSource bridge # (the seam most likely to silently stop working after a Galaxy-side change). # Skip if the VirtualNodeId param is empty (non-Phase-7 clusters). # --------------------------------------------------------------------------- if ([string]::IsNullOrEmpty($VirtualNodeId)) { Write-Header "Virtual-tag bridge" Write-Skip "VirtualNodeId not supplied — skipping Phase 7 bridge check" } else { Write-Header "Virtual-tag bridge" $vtRead = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $VirtualNodeId) if ($vtRead.ExitCode -eq 0 -and $vtRead.Output -match "Value:\s+([^\r\n]+)") { $vtValue = $Matches[1].Trim() Write-Pass "virtual-tag value = $vtValue (source was $sourceValue)" $results += @{ Passed = $true } } else { Write-Fail "virtual-tag read failed" Write-Host $vtRead.Output $results += @{ Passed = $false; Reason = "virtual-tag read failed" } } } # --------------------------------------------------------------------------- # Stage 4 — Subscribe-sees-change. otopcua-cli subscribe in the background; # wait N seconds for Galaxy to push any data-change event on the source node. # This is optimistic — if the Galaxy attribute is idle, widen -ChangeWaitSec. # --------------------------------------------------------------------------- Write-Header "Subscribe sees change" $stdout = New-TemporaryFile $stderr = New-TemporaryFile $subArgs = @($opcUaCli.PrefixArgs) + @( "subscribe", "-u", $OpcUaUrl, "-n", $SourceNodeId, "-i", "500", "--duration", "$ChangeWaitSec") $subProc = Start-Process -FilePath $opcUaCli.File ` -ArgumentList $subArgs -NoNewWindow -PassThru ` -RedirectStandardOutput $stdout.FullName ` -RedirectStandardError $stderr.FullName Write-Info "subscription started (pid $($subProc.Id)) for ${ChangeWaitSec}s" $subProc.WaitForExit(($ChangeWaitSec + 5) * 1000) | Out-Null if (-not $subProc.HasExited) { Stop-Process -Id $subProc.Id -Force } $subOut = (Get-Content $stdout.FullName -Raw) + (Get-Content $stderr.FullName -Raw) Remove-Item $stdout.FullName, $stderr.FullName -ErrorAction SilentlyContinue # Any `=` followed by `(Good)` line after the initial subscribe-confirmation # indicates at least one data-change tick arrived. $changeLines = ($subOut -split "`n") | Where-Object { $_ -match "=\s+.*\(Good\)" } if ($changeLines.Count -gt 0) { Write-Pass "$($changeLines.Count) data-change events observed" $results += @{ Passed = $true } } else { Write-Fail "no data-change events in ${ChangeWaitSec}s — Galaxy attribute may be idle; rerun with -ChangeWaitSec larger, or trigger a change first" Write-Host $subOut $results += @{ Passed = $false; Reason = "no data-change" } } # --------------------------------------------------------------------------- # Stage 5 — Reverse bridge (OPC UA write → Galaxy). Galaxy attributes with # AccessLevel > FreeAccess often reject anonymous writes; record as INFO when # that's the case rather than failing the whole script. # --------------------------------------------------------------------------- Write-Header "Reverse bridge (OPC UA write)" $writeValue = [int]$AlarmTriggerValue # reuse the alarm trigger value — two stages for one write $w = Invoke-Cli -Cli $opcUaCli -Args @( "write", "-u", $OpcUaUrl, "-n", $SourceNodeId, "-v", "$writeValue") if ($w.ExitCode -ne 0) { # Connection/protocol failure — still a test failure. Write-Fail "write CLI exit=$($w.ExitCode)" Write-Host $w.Output $results += @{ Passed = $false; Reason = "write failed" } } elseif ($w.Output -match "Write failed:\s*0x801F0000") { Write-Info "BadUserAccessDenied — attribute's Galaxy-side ACL blocks writes for this session. Not a bug; grant WriteOperate or run against a writable attribute." $results += @{ Passed = $true; Reason = "acl-expected" } } elseif ($w.Output -match "Write failed:\s*0x80390000|BadNotWritable") { Write-Info "BadNotWritable — attribute is read-only at the Galaxy layer (status attributes, @-prefixed meta, etc)." $results += @{ Passed = $true; Reason = "readonly-expected" } } elseif ($w.Output -match "Write successful") { # Read back — Galaxy poll interval + MXAccess advise may need a second or two to settle. Start-Sleep -Seconds 2 $r = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $SourceNodeId) if ($r.Output -match "Value:\s+$([Regex]::Escape("$writeValue"))\b") { Write-Pass "write propagated — source reads back $writeValue" $results += @{ Passed = $true } } else { Write-Fail "write reported success but read-back did not reflect $writeValue" Write-Host $r.Output $results += @{ Passed = $false; Reason = "write-readback mismatch" } } } else { Write-Fail "unexpected write response" Write-Host $w.Output $results += @{ Passed = $false; Reason = "unexpected write response" } } # --------------------------------------------------------------------------- # Stage 6 — Alarm fires. Uses the helper from _common.ps1. If stage 5 already # wrote the trigger value the alarm may already be active; that's fine — the # Part 9 ConditionRefresh in the alarms CLI replays the current state so the # subscribe window still captures the Active event. # --------------------------------------------------------------------------- if ([string]::IsNullOrEmpty($AlarmNodeId)) { Write-Header "Alarm fires on threshold" Write-Skip "AlarmNodeId not supplied — skipping alarm check" } else { $results += Test-AlarmFiresOnThreshold ` -OpcUaCli $opcUaCli ` -OpcUaUrl $OpcUaUrl ` -AlarmNodeId $AlarmNodeId ` -InputNodeId $SourceNodeId ` -TriggerValue $AlarmTriggerValue ` -DurationSec $AlarmWaitSec } # --------------------------------------------------------------------------- # Stage 7 — History read. historyread against the source tag over the last N # seconds. Failure modes the skip pattern catches: tag not historized in the # Galaxy attribute's historization profile, or the lookback window misses the # sample cadence. # --------------------------------------------------------------------------- $results += Test-HistoryHasSamples ` -OpcUaCli $opcUaCli ` -OpcUaUrl $OpcUaUrl ` -NodeId $SourceNodeId ` -LookbackSec $HistoryLookbackSec Write-Summary -Title "Galaxy e2e" -Results $results if ($results | Where-Object { -not $_.Passed }) { exit 1 }