Seven-stage e2e script covering every Galaxy-specific capability surface:
IReadable + IWritable + ISubscribable + IAlarmSource + IHistoryProvider.
Unlike the other drivers there is no per-protocol CLI — Galaxy's proxy
lives in-process with the server + talks to OtOpcUaGalaxyHost over a
named pipe (MXAccess COM is 32-bit-only), so every stage runs through
`otopcua-cli` against the published OPC UA address space.
## Stages
1. Probe — otopcua-cli read on the source NodeId
2. Source read — capture value for downstream comparison
3. Virtual-tag bridge — Phase 7 VirtualTag (source × 2) through
CachedTagUpstreamSource
4. Subscribe-sees-change — data-change events propagate
5. Reverse bridge — opc-ua write → Galaxy; soft-passes if the
attribute's Galaxy-side ACL forbids writes
(`BadUserAccessDenied` / `BadNotWritable`)
6. Alarm fires — scripted-alarm Condition fires with Active
state when source crosses threshold
7. History read — historyread returns samples from the Aveva
Historian → IHistoryProvider path
## Two new helpers in _common.ps1
- `Test-AlarmFiresOnThreshold` — start `otopcua-cli alarms --refresh`
in the background on a Condition NodeId, drive the source change,
assert captured stdout contains `ALARM` + `Active`. Uses the same
Start-Process + temp-file pattern as `Test-SubscribeSeesChange` since
the alarms command runs until Ctrl+C (no built-in --duration).
- `Test-HistoryHasSamples` — call `otopcua-cli historyread` over a
configurable lookback window, parse `N values returned.` marker, fail
if below MinSamples. Works for driver-sourced, virtual, or scripted-
alarm historized nodes.
## Wiring
- `test-all.ps1` picks up the optional `galaxy` sidecar section and
runs the script with the configured NodeIds + wait windows.
- `e2e-config.sample.json` adds a `galaxy` section seeded with the
Phase 7 defaults (`p7-smoke-tag-source` / `-vt-derived` /
`-al-overtemp`) — matches `scripts/smoke/seed-phase-7-smoke.sql`.
- `scripts/e2e/README.md` expected-matrix gains a Galaxy row.
## Prereqs
- OtOpcUaGalaxyHost running (NSSM-wrapped) with the Galaxy + MXAccess
runtime available
- `seed-phase-7-smoke.sql` applied with a live Galaxy attribute
substituted into `dbo.Tag.TagConfig`
- OtOpcUa server running against the `p7-smoke` cluster
- Non-elevated shell (Galaxy.Host pipe ACL denies Admins)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
279 lines
13 KiB
PowerShell
279 lines
13 KiB
PowerShell
#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 }
|