diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md index 028aa7f..e225894 100644 --- a/scripts/e2e/README.md +++ b/scripts/e2e/README.md @@ -133,6 +133,7 @@ section to skip it. | Modbus | — | **PASS** (pymodbus fixture) | | AB CIP | — | **PASS** (ab_server fixture) | | AB Legacy | — | **PASS** (ab_server SLC500/MicroLogix/PLC-5 profiles; `/1,0` cip-path required for the Docker fixture) | +| Galaxy | — | **PASS** (requires OtOpcUaGalaxyHost + a live Galaxy; 7 stages including alarms + history) | | S7 | — | **PASS** (python-snap7 fixture) | | FOCAS | `FOCAS_TRUST_WIRE=1` | **SKIP** (no public simulator — task #222 lab rig) | | TwinCAT | `TWINCAT_TRUST_WIRE=1` | **SKIP** (needs XAR or standalone Router — task #221) | diff --git a/scripts/e2e/_common.ps1 b/scripts/e2e/_common.ps1 index 595880c..27cd47f 100644 --- a/scripts/e2e/_common.ps1 +++ b/scripts/e2e/_common.ps1 @@ -310,6 +310,109 @@ function Test-SubscribeSeesChange { return @{ Passed = $false; Reason = "change not observed on subscription" } } +# Test — alarm fires on threshold. Start `otopcua-cli alarms --refresh` on the +# alarm Condition NodeId in the background; drive the underlying data change via +# `otopcua-cli write` on the input NodeId; wait for the subscription window to +# close; assert the captured stdout contains a matching ALARM line (`SourceName` +# of the Condition + an Active state). Covers Part 9 alarm propagation through +# the server → driver → Condition node path. +function Test-AlarmFiresOnThreshold { + param( + [Parameter(Mandatory)] $OpcUaCli, + [Parameter(Mandatory)] [string]$OpcUaUrl, + [Parameter(Mandatory)] [string]$AlarmNodeId, + [Parameter(Mandatory)] [string]$InputNodeId, + [Parameter(Mandatory)] [string]$TriggerValue, + [int]$DurationSec = 10, + [int]$SettleSec = 2 + ) + Write-Header "Alarm fires on threshold" + + $stdout = New-TemporaryFile + $stderr = New-TemporaryFile + $allArgs = @($OpcUaCli.PrefixArgs) + @( + "alarms", "-u", $OpcUaUrl, "-n", $AlarmNodeId, "-i", "500", "--refresh") + $proc = Start-Process -FilePath $OpcUaCli.File ` + -ArgumentList $allArgs ` + -NoNewWindow -PassThru ` + -RedirectStandardOutput $stdout.FullName ` + -RedirectStandardError $stderr.FullName + Write-Info "alarm subscription started (pid $($proc.Id)), waiting ${SettleSec}s to settle" + Start-Sleep -Seconds $SettleSec + + $w = Invoke-Cli -Cli $OpcUaCli -Args @( + "write", "-u", $OpcUaUrl, "-n", $InputNodeId, "-v", $TriggerValue) + if ($w.ExitCode -ne 0) { + Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue + Remove-Item $stdout.FullName, $stderr.FullName -ErrorAction SilentlyContinue + Write-Fail "input write failed (exit=$($w.ExitCode))" + Write-Host $w.Output + return @{ Passed = $false; Reason = "input write failed" } + } + Write-Info "input write ok, waiting up to ${DurationSec}s for the alarm to surface" + + # otopcua-cli alarms runs until Ctrl+C; terminate it ourselves after the + # duration window (no built-in --duration flag on the alarms command). + Start-Sleep -Seconds $DurationSec + if (-not $proc.HasExited) { Stop-Process -Id $proc.Id -Force } + + $out = (Get-Content $stdout.FullName -Raw) + (Get-Content $stderr.FullName -Raw) + Remove-Item $stdout.FullName, $stderr.FullName -ErrorAction SilentlyContinue + + # AlarmsCommand emits `[ts] ALARM ` per event + lines for + # State: Active,Unacknowledged | Severity | Message. Match on `ALARM` + + # `Active` — both need to appear for the alarm to count as fired. + if ($out -match "ALARM\b" -and $out -match "Active\b") { + Write-Pass "alarm condition fired with Active state" + return @{ Passed = $true } + } + Write-Fail "no Active alarm event observed in ${DurationSec}s" + Write-Host $out + return @{ Passed = $false; Reason = "no alarm event" } +} + +# Test — history-read returns samples. Calls `otopcua-cli historyread` on the +# target NodeId for a time window (default 1h back) and asserts the CLI reports +# at least one value returned. Works against any historized tag — driver-sourced, +# virtual, or scripted-alarm historizing to the Aveva / SQLite sink. +function Test-HistoryHasSamples { + param( + [Parameter(Mandatory)] $OpcUaCli, + [Parameter(Mandatory)] [string]$OpcUaUrl, + [Parameter(Mandatory)] [string]$NodeId, + [int]$LookbackSec = 3600, + [int]$MinSamples = 1 + ) + Write-Header "History read" + + $end = (Get-Date).ToUniversalTime().ToString("o") + $start = (Get-Date).ToUniversalTime().AddSeconds(-$LookbackSec).ToString("o") + + $r = Invoke-Cli -Cli $OpcUaCli -Args @( + "historyread", "-u", $OpcUaUrl, "-n", $NodeId, + "--start", $start, "--end", $end, "--max", "1000") + if ($r.ExitCode -ne 0) { + Write-Fail "historyread exit=$($r.ExitCode)" + Write-Host $r.Output + return @{ Passed = $false; Reason = "historyread failed" } + } + + # HistoryReadCommand ends with `N values returned.` — parse and check >= MinSamples. + if ($r.Output -match '(\d+)\s+values?\s+returned') { + $count = [int]$Matches[1] + if ($count -ge $MinSamples) { + Write-Pass "$count samples returned (>= $MinSamples)" + return @{ Passed = $true } + } + Write-Fail "only $count samples returned, expected >= $MinSamples — tag may not be historized, or lookback window misses samples" + Write-Host $r.Output + return @{ Passed = $false; Reason = "insufficient samples" } + } + Write-Fail "could not parse 'N values returned.' marker from historyread output" + Write-Host $r.Output + return @{ Passed = $false; Reason = "parse failure" } +} + # --------------------------------------------------------------------------- # Summary helper — caller passes an array of test results. # --------------------------------------------------------------------------- diff --git a/scripts/e2e/e2e-config.sample.json b/scripts/e2e/e2e-config.sample.json index f40362c..a3ce156 100644 --- a/scripts/e2e/e2e-config.sample.json +++ b/scripts/e2e/e2e-config.sample.json @@ -49,6 +49,17 @@ "bridgeNodeId": "ns=2;s=TwinCAT/MAIN_iCounter" }, + "galaxy": { + "$comment": "Galaxy (MXAccess) driver. Has no per-driver CLI — all stages go through otopcua-cli against the published NodeIds. Seven stages: probe / source read / virtual-tag bridge / subscribe-sees-change / reverse write / alarm fires / history read. Requires OtOpcUaGalaxyHost running + seed-phase-7-smoke.sql applied with a real Galaxy attribute substituted into dbo.Tag.TagConfig.", + "sourceNodeId": "ns=2;s=p7-smoke-tag-source", + "virtualNodeId": "ns=2;s=p7-smoke-vt-derived", + "alarmNodeId": "ns=2;s=p7-smoke-al-overtemp", + "alarmTriggerValue": "75", + "changeWaitSec": 10, + "alarmWaitSec": 10, + "historyLookbackSec": 3600 + }, + "phase7": { "$comment": "Virtual tags + scripted alarms. The VirtualNodeId must resolve to a server-side virtual tag whose script reads the modbus InputNodeId and writes VT = input * 2. The AlarmNodeId is the ConditionId of a scripted alarm that fires when VT > 100.", "modbusEndpoint": "127.0.0.1:5502", diff --git a/scripts/e2e/test-all.ps1 b/scripts/e2e/test-all.ps1 index 979d332..6066e8e 100644 --- a/scripts/e2e/test-all.ps1 +++ b/scripts/e2e/test-all.ps1 @@ -172,6 +172,23 @@ else { $summary["twincat"] = "SKIP (no config entry)" } # Phase 7 virtual tags + scripted alarms # --------------------------------------------------------------------------- +$galaxy = Get-Or $config "galaxy" +if ($galaxy) { + Write-Header "== GALAXY ==" + Run-Suite "galaxy" { + & "$PSScriptRoot/test-galaxy.ps1" ` + -OpcUaUrl (Get-Or $galaxy "opcUaUrl" $OpcUaUrl) ` + -SourceNodeId $galaxy["sourceNodeId"] ` + -VirtualNodeId (Get-Or $galaxy "virtualNodeId" "") ` + -AlarmNodeId (Get-Or $galaxy "alarmNodeId" "") ` + -AlarmTriggerValue (Get-Or $galaxy "alarmTriggerValue" "75") ` + -ChangeWaitSec (Get-Or $galaxy "changeWaitSec" 10) ` + -AlarmWaitSec (Get-Or $galaxy "alarmWaitSec" 10) ` + -HistoryLookbackSec (Get-Or $galaxy "historyLookbackSec" 3600) + } +} +else { $summary["galaxy"] = "SKIP (no config entry)" } + $phase7 = Get-Or $config "phase7" if ($phase7) { Write-Header "== PHASE 7 virtual tags + scripted alarms ==" diff --git a/scripts/e2e/test-galaxy.ps1 b/scripts/e2e/test-galaxy.ps1 new file mode 100644 index 0000000..3a93eb8 --- /dev/null +++ b/scripts/e2e/test-galaxy.ps1 @@ -0,0 +1,278 @@ +#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 }