Both VirtualTagEngine and ScriptedAlarmEngine share a pattern: the BuildReadCache helper iterates the script's declared input set, reading from _valueCache with a fallback to _upstream.ReadTag. When an upstream tag hasn't yet delivered its first subscription push, ReadTag returns a DataValueSnapshot with a null Value and BadNotConnected quality. User scripts then cast `(double)ctx.GetTag(path).Value` unconditionally and throw NullReferenceException — once per evaluation tick until the cache fills, spamming the log with identical stack traces. The existing catch block recovered (kept the prior state) but didn't silence the churn. Add AreInputsReady(cache) to both engines: return true only when every entry has a non-null Value and a non-Bad StatusCode (Good + Uncertain are both considered ready). Skip script evaluation when the check returns false — the engine holds the prior state (alarm) or the prior snapshot (virtual tag) until upstream delivers. Eliminates the cold- start NRE spam at root without changing the script-engine contract. Also: fix $changeLines.Count in test-galaxy.ps1 — PowerShell's Set-StrictMode -Version 3.0 errors on .Count when Where-Object returns 0 or 1 items. Wrap in `@(...)` to force an array; same pattern the sibling _common.ps1 already uses in Write-Summary for the same reason. Task #112 — the Galaxy live E2E now passes 3/7 stages (probe + source read + reverse-bridge-ACL). The remaining 4 stages (virtual-tag, subscribe-sees-change, alarm-fires, history-read) are deployment- specific: MoveInBatchID is idle in this Galaxy + its AccessLevel blocks writes + it's not historized. Cold-start behaviour is now correct, so once the seed points at a live attribute those stages should light up. Tests: 36/36 VirtualTags.Tests + 47/47 ScriptedAlarms.Tests green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
283 lines
14 KiB
PowerShell
283 lines
14 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). NodeIds
|
||
are path-based per OPC UA Part 3 §5.2.2 — the default matches the Phase 7 seed
|
||
walking `p7-smoke-galaxy` (DriverInstanceId) → `lab-floor` → `galaxy-line` →
|
||
`reactor-1` → `Source` (Tag.Name).
|
||
|
||
.PARAMETER VirtualNodeId
|
||
NodeId of the VirtualTag computed as Source × 2 (Phase 7 scripting). Same
|
||
path-based scheme, ending in the VirtualTag.Name (`Doubled`).
|
||
|
||
.PARAMETER AlarmNodeId
|
||
NodeId of the scripted-alarm Condition (fires when Source > 50). Same
|
||
path-based scheme, ending in ScriptedAlarm.Name (`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-galaxy/lab-floor/galaxy-line/reactor-1/Source",
|
||
[string]$VirtualNodeId = "ns=2;s=p7-smoke-galaxy/lab-floor/galaxy-line/reactor-1/Doubled",
|
||
[string]$AlarmNodeId = "ns=2;s=p7-smoke-galaxy/lab-floor/galaxy-line/reactor-1/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. The `@(...)` forces an array
|
||
# so `.Count` works on the 0-match + single-match cases that Set-StrictMode
|
||
# -Version 3.0 otherwise flags as `property 'Count' cannot be found`.
|
||
$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 }
|