The pre-refactor design minted OPC UA NodeIds directly from the driver's
FullReference (the native-address string). That had three long-term
problems:
1. OPC UA Part 3 §5.2.2 requires NodeIds to be immutable across a node's
lifetime. A rename of the underlying device address — Galaxy attribute,
S7 tag, Modbus register alias — changed the NodeId and broke every
client that had pinned the previous identifier.
2. Two drivers with coincidentally-matching native addresses (e.g. `temp`
in Modbus and `temp` in S7 under different Equipment rows) collided on
the NodeId identifier.
3. TagConfig was being placed verbatim on the wire; for drivers whose
TagConfig is JSON (every driver shipped today, per the
CK_Tag_TagConfig_IsJson check constraint), clients saw the raw JSON
blob as the NodeId string.
Refactor:
* DriverNodeManager.Variable now mints a stable path-based NodeId
`{driverId}/{folder-path}/{browseName}` and records the driver-side
FullReference in a new _fullRefByNodeId map. OnReadValue / OnWriteValue
/ ResolveFullRef look the FullReference up via that map instead of
casting NodeId.Identifier. The old cast path is preserved as a
fallback so any test fixture that still registers variables with
FullRef-shaped NodeIds keeps working.
* EquipmentNodeWalker.AddTagVariable now extracts the cross-driver
`FullName` field from Tag.TagConfig before handing the address to
DriverAttributeInfo. Every shipped driver stores the wire reference in
TagConfig[FullName]; falling back to the raw string covers any future
driver that wants an opaque non-JSON address. ExtractFullName is
exposed internal for unit coverage.
* scripts/e2e/test-galaxy.ps1 defaults updated to the new path-based
NodeIds. Verified live against p7-smoke-galaxy on the dev box:
`ns=2;s=p7-smoke-galaxy/lab-floor/galaxy-line/reactor-1/Source` reads
return Status=0x00000000 with a real Galaxy byte-array value.
Test suite: 195/195 Core.Tests + 283/283 Server.Tests green. Five new
ExtractFullName / FullName-passthrough tests added.
Task #112 GA-3 — golden-path read verified end-to-end; remaining E2E
script stages still blocked on pre-existing issues (ScriptedAlarm
predicate NRE on empty upstream cache, PowerShell $changeLines.Count
guard), tracked separately.
Task #134 — complete.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
281 lines
13 KiB
PowerShell
281 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). 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.
|
||
$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 }
|