task-galaxy-e2e branch — non-FOCAS work-in-progress snapshot

Catch-all commit for pending work on the task-galaxy-e2e branch that
wasn't part of the FOCAS migration. Grouping by topic so future per-topic
commits can be cherry-picked if needed.

TwinCAT
- src/.../Driver.TwinCAT/AdsTwinCATClient.cs + TwinCATDriverFactoryExtensions.cs:
  factory-registration extensions + ADS client refinements.
- src/.../Driver.TwinCAT.Cli/Commands/BrowseCommand.cs: new browse command
  for the TwinCAT test-client CLI.
- tests/.../Driver.TwinCAT.IntegrationTests/TwinCAT3SmokeTests.cs + TwinCatProject/:
  fixture scaffold with a minimal POU + README pointing at the TCBSD/ESXi
  VM for e2e.
- docs/Driver.TwinCAT.Cli.md + docs/drivers/TwinCAT-Test-Fixture.md:
  documentation for the above.
- docs/v3/twincat-backlog.md: forward-looking backlog seed.

Admin UI + fleet status
- src/.../Admin/Components/Pages/Clusters/DriversTab.razor + Hosts.razor:
  UI refresh for fleet-status rendering.
- src/.../Admin/Hubs/FleetStatusHub.cs + FleetStatusPoller.cs +
  Admin/Program.cs: SignalR hub + poller plumbing for live fleet data.
- tests/.../Admin.Tests/FleetStatusPollerTests.cs: poller coverage.

Server + redundancy runtime (Phase 6.3 follow-ups)
- src/.../Server/Hosting/RedundancyPublisherHostedService.cs: HostedService
  that owns the RedundancyStatePublisher lifecycle + wires peer reachability.
- src/.../Server/Redundancy/ServerRedundancyNodeWriter.cs: OPC UA
  variable-node writer binding ServiceLevel + ServerUriArray to the
  publisher's events.
- src/.../Server/Program.cs + Server.csproj: hosted-service registration.
- tests/.../Server.Tests/ServerRedundancyNodeWriterTests.cs +
  Server.Tests.csproj: coverage for the above.

Configuration
- src/.../Configuration/Validation/DraftValidator.cs +
  tests/.../Configuration.Tests/DraftValidatorTests.cs: draft-validation
  refinements.

E2E scripts (shared infrastructure)
- scripts/e2e/README.md + _common.ps1 + test-all.ps1: shared helpers + the
  all-drivers test-all runner.
- scripts/e2e/test-opcuaclient.ps1: OPC UA Client e2e runner.

Docs
- docs/v2/implementation/phase-6-{1,2,3,4}*.md + exit-gate-phase-{3,7}.md:
  phase-gate + implementation doc updates.
- docs/v2/plan.md: top-level plan refresh.
- docs/v2/redundancy-interop-playbook.md: client interop playbook for the
  Phase 6.3 redundancy-runtime work.

Two orphan FOCAS docs remain on disk but deliberately unstaged —
docs/v2/focas-deployment.md and docs/v2/implementation/focas-simulator-plan.md
describe the now-retired Tier-C topology and should either be rewritten
or deleted in a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-24 14:12:19 -04:00
parent 4b0664bd55
commit 69e0d02c72
58 changed files with 3070 additions and 247 deletions

View File

@@ -53,27 +53,47 @@ read-only tag.
## Status
Stages 1 + 2 (driver-side probe + loopback) are verified end-to-end
against the pymodbus / ab_server / python-snap7 fixtures. Stages 3-5
(anything crossing the OtOpcUa server) are **blocked** on server-side
driver factory wiring:
All seven driver factories are registered in
`src/ZB.MOM.WW.OtOpcUa.Server/Program.cs` — Galaxy, FOCAS, Modbus,
AB CIP, AB Legacy, S7, TwinCAT. `DriverInstanceBootstrapper` can
materialise any `DriverType` row from the central Config DB into a
live driver. The factory-wiring block that originally gated stages
3-5 is closed.
- `src/ZB.MOM.WW.OtOpcUa.Server/Program.cs` only registers Galaxy +
FOCAS factories. `DriverInstanceBootstrapper` skips any `DriverType`
without a registered factory — so Modbus / AB CIP / AB Legacy / S7 /
TwinCAT rows in the Config DB are silently no-op'd even when the seed
is perfect.
- No Config DB seed script exists for non-Galaxy drivers; Admin UI is
currently the only path to author one.
Live-boot verification:
Tracking: **#209** (umbrella) → #210 (Modbus), #211 (AB CIP), #212 (S7),
#213 (AB Legacy, also hardware-gated — #222). Each child issue lists
the factory class to write + the seed SQL shape + the verification
command.
- **Galaxy** — 7/7 stages (read / write / subscribe / alarms / history)
against a real Galaxy + `OtOpcUaGalaxyHost` on this dev box.
- **AB CIP, S7** — 5/5 stages each under task #220 against the
`ab_server` + `python-snap7` fixtures.
- **AB Legacy** — 5/5 stages under task #222 against `ab_server` SLC500
/ MicroLogix / PLC-5 profiles (requires the `cip-path /1,0` workaround
for the Docker fixture).
- **Modbus** — 5/5 stages against the `pymodbus` + dl205 profile,
including HR[200] scratch register + per-protocol bidirectional +
subscribe-sees-change stages.
- **TwinCAT** — factory registered; driver features validated against the
TCBSD VM virtual-PLC fixture (FreeBSD + TwinCAT/BSD runtime on ESXi —
bypasses the Hyper-V/RTIME conflict that blocks XAR on the dev box).
`TWINCAT_TRUST_WIRE=1` is still required to run the script —
false-pass-prevention belt, not an "unverified" flag.
- **FOCAS** — factory registered; gated by `FOCAS_TRUST_WIRE=1` pending
the lab-rig CNC (task #222 follow-up).
- **OpcUaClient (gateway)** — eight-stage script (`test-opcuaclient.ps1`)
covers probe / remote read / forward bridge / subscribe / reverse
bridge / browse mirror / alarm / history against the opc-plc Docker
fixture at `opc.tcp://localhost:50000`. Reverse-bridge / alarm /
history stages are opt-in per the parameter docs (opc-plc's default
image has no writable nodes and does not historize).
Until those ship, stages 3-5 will fail with "read failed" (nothing
published at that NodeId) and `[FAIL]` the suite even on a running
server.
Remaining work is **per-protocol seed authoring**: each dev fills in
the NodeIds their server publishes under `e2e-config.json` (sidecar
is `.gitignore`-d; see `e2e-config.sample.json` for the shape). Admin
UI remains the supported path for authoring the matching driver
instance rows in the Config DB.
Tracking: umbrella #209 is closed; remaining TwinCAT / FOCAS work
tracks under their hardware-fixture tasks (#221 / #222).
## Prereqs
@@ -85,7 +105,9 @@ server.
for the simulator matrix — pymodbus / ab_server / python-snap7 /
opc-plc cover Modbus / AB / S7 / OPC UA Client. FOCAS and TwinCAT
have no public simulator; they are gated with env-var skip flags
below.
below. For OpcUaClient, `docker compose -f
tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/
docker-compose.yml up -d` brings up `opc-plc` on port 50000.
3. **PowerShell 7+**. The runner uses null-coalescing + `Set-StrictMode`;
the Windows-PowerShell-5.1 shell will not parse `test-all.ps1`.
4. **.NET 10 SDK**. Each script either runs `dotnet run --project
@@ -136,7 +158,8 @@ section to skip it.
| 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) |
| TwinCAT | `TWINCAT_TRUST_WIRE=1` | **SKIP** by default; features **validated** against the TCBSD VM fixture — set the env var to run |
| OpcUaClient | — | **PASS** stages 1-4 + browse (opc-plc Docker fixture); stages 5/7/8 are opt-in (require writable / alarm / historizing upstream) |
| Phase 7 | — | **PASS** if the Modbus instance seeds a `VT_DoubledHR100` virtual tag + `AlarmHigh` scripted alarm |
Set the `*_TRUST_WIRE` env vars to `1` when you've pointed the script at

View File

@@ -422,8 +422,11 @@ function Write-Summary {
[Parameter(Mandatory)] [string]$Title,
[Parameter(Mandatory)] [array]$Results
)
$passed = ($Results | Where-Object { $_.Passed }).Count
$failed = ($Results | Where-Object { -not $_.Passed }).Count
# @(...) forces an array even when Where-Object matches 0 or 1 items,
# otherwise .Count trips Set-StrictMode -Version 3.0 ("property 'Count'
# cannot be found on this object") on $null or on a single hashtable.
$passed = @($Results | Where-Object { $_.Passed }).Count
$failed = @($Results | Where-Object { -not $_.Passed }).Count
Write-Host ""
Write-Host "=== $Title summary: $passed/$($Results.Count) passed ===" `
-ForegroundColor $(if ($failed -eq 0) { "Green" } else { "Red" })

View File

@@ -189,6 +189,33 @@ if ($galaxy) {
}
else { $summary["galaxy"] = "SKIP (no config entry)" }
# ---------------------------------------------------------------------------
# OPC UA Client (gateway driver)
# ---------------------------------------------------------------------------
$opcuaclient = Get-Or $config "opcuaclient"
if ($opcuaclient) {
Write-Header "== OPC UA CLIENT =="
Run-Suite "opcuaclient" {
& "$PSScriptRoot/test-opcuaclient.ps1" `
-RemoteUrl (Get-Or $opcuaclient "remoteUrl" "opc.tcp://localhost:50000") `
-OpcUaUrl (Get-Or $opcuaclient "opcUaUrl" $OpcUaUrl) `
-RemoteNodeId (Get-Or $opcuaclient "remoteNodeId" "ns=3;s=FastUInt1") `
-BridgeNodeId $opcuaclient["bridgeNodeId"] `
-WritableRemoteNodeId (Get-Or $opcuaclient "writableRemoteNodeId" "") `
-WritableBridgeNodeId (Get-Or $opcuaclient "writableBridgeNodeId" "") `
-BridgeRootNodeId (Get-Or $opcuaclient "bridgeRootNodeId" "i=85") `
-BrowseDepth (Get-Or $opcuaclient "browseDepth" 3) `
-BrowseMinNodes (Get-Or $opcuaclient "browseMinNodes" 5) `
-AlarmNodeId (Get-Or $opcuaclient "alarmNodeId" "") `
-AlarmWaitSec (Get-Or $opcuaclient "alarmWaitSec" 15) `
-HistoryNodeId (Get-Or $opcuaclient "historyNodeId" "") `
-HistoryLookbackSec (Get-Or $opcuaclient "historyLookbackSec" 3600) `
-ChangeWaitSec (Get-Or $opcuaclient "changeWaitSec" 8)
}
}
else { $summary["opcuaclient"] = "SKIP (no config entry)" }
$phase7 = Get-Or $config "phase7"
if ($phase7) {
Write-Header "== PHASE 7 virtual tags + scripted alarms =="
@@ -220,7 +247,9 @@ $summary.GetEnumerator() | ForEach-Object {
Write-Host (" {0,-10} {1}" -f $_.Key, $_.Value) -ForegroundColor $color
}
$failed = ($summary.Values | Where-Object { $_ -eq "FAIL" }).Count
# @() wrap — Where-Object returns $null / a single scalar for 0-or-1 matches,
# and .Count on either trips Set-StrictMode -Version 3.0.
$failed = @($summary.Values | Where-Object { $_ -eq "FAIL" }).Count
if ($failed -gt 0) {
Write-Host "$failed suite(s) failed." -ForegroundColor Red
exit 1

View File

@@ -0,0 +1,392 @@
#Requires -Version 7.0
<#
.SYNOPSIS
End-to-end CLI test for the OPC UA Client (gateway) driver bridged through
the OtOpcUa server.
.DESCRIPTION
The OpcUaClient driver is unique in the fleet — it's a gateway that connects
to ANOTHER OPC UA server and re-exposes its address space through the local
OtOpcUa server. So there's no protocol-specific driver CLI; both directions
of this test use `otopcua-cli` against two different endpoints:
remote = the upstream OPC UA server the driver connects to (opc-plc fixture
by default, opc.tcp://localhost:50000)
local = the OtOpcUa server itself, which mirrors remote nodes through the
OpcUaClient driver instance (opc.tcp://localhost:4840)
Eight stages cover the driver's full capability surface:
1. Remote probe — otopcua-cli connect to the upstream. Confirms the
simulator / target server is reachable and
speaking UA Secure Channel.
2. Remote read — otopcua-cli read of -RemoteNodeId on the upstream.
Captures the current value + confirms the node
exists. Baseline for the forward-bridge stage.
3. Forward bridge — otopcua-cli read of -BridgeNodeId on the LOCAL
server. Proves the driver discovered + mirrored
the remote node into the local address space and
the read path is live (IReadable via session).
4. Subscribe-sees-change — subscribe on local -BridgeNodeId in the
background. opc-plc's tickers (FastUInt1, StepUp)
mutate autonomously, so no driver poke is needed
— a data-change event should arrive within the
subscription window. Covers ISubscribable +
upstream subscription transfer.
5. Reverse bridge — otopcua-cli write to local -WritableBridgeNodeId,
then otopcua-cli read of -WritableRemoteNodeId
directly on the upstream. Confirms writes flow
through the driver to the remote (IWritable). Opt-
in — opc-plc default image has no writable nodes
without `--sn`; pass -WritableBridgeNodeId AND
-WritableRemoteNodeId to enable.
6. Browse mirror — otopcua-cli browse of the local -BridgeRootNodeId
at depth -BrowseDepth. Asserts at least
-BrowseMinNodes descendants appear. Covers
ITagDiscovery → local-namespace projection.
7. Alarm fires — otopcua-cli alarms subscription on local
-AlarmNodeId. opc-plc with `--alm` cycles a
TripAlarm autonomously; assert an Active alarm
event surfaces. Covers IAlarmSource → OPC UA A&E
projection. Opt-in via -AlarmNodeId.
8. History read — historyread on local -HistoryNodeId over a
lookback window. Covers IHistoryProvider →
upstream HistoryRead dispatch. Opt-in via
-HistoryNodeId. Note: opc-plc's default image
does not historize — a historizing upstream
(Prosys, UaExpert sample server) is required.
Prereqs:
1. Upstream OPC UA server reachable at -RemoteUrl. Default expects the
opc-plc Docker fixture (`tests/.../Driver.OpcUaClient.IntegrationTests/
Docker/docker-compose.yml`): `docker compose up -d` before running.
2. OtOpcUa server running at -OpcUaUrl with an OpcUaClient DriverInstance
in its Config DB whose EndpointUrl = -RemoteUrl. The server's
DiscoverAsync populates the mirrored namespace at startup; the
-BridgeNodeId / -BridgeRootNodeId you pass must correspond to whatever
NodeIds that discovery produced on your local server.
3. To exercise stages 5 / 7 / 8, the upstream must expose writable nodes /
alarm conditions / history. opc-plc alone doesn't cover all three — see
parameter docs below for the combinations that work with opc-plc.
.PARAMETER RemoteUrl
Upstream OPC UA server endpoint (the server the driver connects to).
Default matches the opc-plc Docker fixture — opc.tcp://localhost:50000.
.PARAMETER OpcUaUrl
Local OtOpcUa server endpoint. Default opc.tcp://localhost:4840.
.PARAMETER RemoteNodeId
NodeId on the upstream used for stages 1-2. Default ns=3;s=FastUInt1 — opc-plc
ticker that increments every 100 ms.
.PARAMETER BridgeNodeId
NodeId on the LOCAL server that mirrors -RemoteNodeId after the OpcUaClient
driver discovers it. Dev-specific — whatever the local DiscoverAsync produced
for the upstream node. No default; mandatory for stages 3-4.
.PARAMETER WritableRemoteNodeId
Writable NodeId on the upstream for the reverse-bridge stage. opc-plc's
default image has no writable nodes; add `--sn=1` to the compose command to
expose `ns=3;s=SlowUInt1` as writable (or similar per opc-plc docs). Omit to
skip stage 5.
.PARAMETER WritableBridgeNodeId
Matching local mirror of -WritableRemoteNodeId. Omit to skip stage 5.
.PARAMETER BridgeRootNodeId
Root NodeId on the local server under which the mirrored upstream sits. The
browse stage walks from this node down to -BrowseDepth. Default i=85
(ObjectsFolder) — works but produces a lot of output; pass a narrower root
for faster / more targeted coverage.
.PARAMETER BrowseDepth
Max depth for the browse stage. Default 3.
.PARAMETER BrowseMinNodes
Minimum number of descendants expected under -BridgeRootNodeId. Default 5.
.PARAMETER AlarmNodeId
NodeId of the ConditionType on the local server for the alarm-fires stage.
opc-plc with `--alm` exposes e.g. TripAlarm conditions; the local mirror path
of that condition goes here. Omit to skip stage 7.
.PARAMETER AlarmWaitSec
Seconds to wait for the alarm to cycle. opc-plc's TripAlarm fires on its own
cadence; 15 s usually covers one cycle. Default 15.
.PARAMETER HistoryNodeId
NodeId on the local server whose history to query. Omit to skip stage 8.
.PARAMETER HistoryLookbackSec
Seconds back from now to query history. Default 3600.
.PARAMETER ChangeWaitSec
Seconds the subscribe-sees-change stage waits for a natural ticker update.
opc-plc's FastUInt1 ticks every 100 ms so a short window suffices. Default 8.
.EXAMPLE
# Bare-minimum: stages 1-4 + browse, against the opc-plc compose fixture.
# Requires the local OtOpcUa server to have discovered opc-plc and placed
# FastUInt1 under (for example) ns=2;s=OpcUaClient/FastUInt1.
./scripts/e2e/test-opcuaclient.ps1 -BridgeNodeId "ns=2;s=OpcUaClient/FastUInt1"
.EXAMPLE
# Full matrix — all eight stages. Requires an opc-plc image with --sn (for
# writable) + --alm (for alarms; default compose has this) + a historizing
# upstream (opc-plc does not; Prosys does).
./scripts/e2e/test-opcuaclient.ps1 `
-BridgeNodeId "ns=2;s=OpcUaClient/FastUInt1" `
-WritableRemoteNodeId "ns=3;s=SlowUInt1" `
-WritableBridgeNodeId "ns=2;s=OpcUaClient/SlowUInt1" `
-BridgeRootNodeId "ns=2;s=OpcUaClient" `
-AlarmNodeId "ns=2;s=OpcUaClient/TripAlarm" `
-HistoryNodeId "ns=2;s=OpcUaClient/StepUp"
#>
param(
[string]$RemoteUrl = "opc.tcp://localhost:50000",
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
[string]$RemoteNodeId = "ns=3;s=FastUInt1",
[Parameter(Mandatory)] [string]$BridgeNodeId,
[string]$WritableRemoteNodeId = "",
[string]$WritableBridgeNodeId = "",
[string]$BridgeRootNodeId = "i=85",
[int]$BrowseDepth = 3,
[int]$BrowseMinNodes = 5,
[string]$AlarmNodeId = "",
[int]$AlarmWaitSec = 15,
[string]$HistoryNodeId = "",
[int]$HistoryLookbackSec = 3600,
[int]$ChangeWaitSec = 8
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot/_common.ps1"
$opcUaCli = Get-CliInvocation `
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
-ExeName "otopcua-cli"
$results = @()
# ---------------------------------------------------------------------------
# Stage 1 — Remote probe. `otopcua-cli connect` exits 0 when the Secure Channel
# + Session handshake to the upstream complete cleanly. A failure here means
# opc-plc isn't running or the endpoint is unreachable — nothing downstream is
# worth trying.
# ---------------------------------------------------------------------------
Write-Header "Remote probe"
$probe = Invoke-Cli -Cli $opcUaCli -Args @("connect", "-u", $RemoteUrl)
if ($probe.ExitCode -eq 0 -and $probe.Output -match "Connection successful") {
Write-Pass "upstream $RemoteUrl reachable + speaks UA"
$results += @{ Passed = $true }
} else {
Write-Fail "upstream connect failed (exit=$($probe.ExitCode))"
Write-Host $probe.Output
$results += @{ Passed = $false; Reason = "remote probe failed" }
# Fail fast: if the upstream is down every other stage will cascade.
Write-Summary -Title "OpcUaClient e2e" -Results $results
exit 1
}
# ---------------------------------------------------------------------------
# Stage 2 — Remote read. Pulls the current value of -RemoteNodeId directly from
# the upstream. Recorded for later stages to compare against, and confirms the
# chosen NodeId actually exists on this upstream.
# ---------------------------------------------------------------------------
Write-Header "Remote read"
$remoteRead = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $RemoteUrl, "-n", $RemoteNodeId)
$remoteValue = $null
if ($remoteRead.ExitCode -eq 0 -and $remoteRead.Output -match "Value:\s+([^\r\n]+)") {
$remoteValue = $Matches[1].Trim()
Write-Pass "remote $RemoteNodeId = $remoteValue"
$results += @{ Passed = $true }
} else {
Write-Fail "remote read of $RemoteNodeId failed"
Write-Host $remoteRead.Output
$results += @{ Passed = $false; Reason = "remote read failed" }
}
# ---------------------------------------------------------------------------
# Stage 3 — Forward bridge. Read -BridgeNodeId on the LOCAL server. If the
# OpcUaClient driver is live + its discovery mapped -RemoteNodeId into the
# local namespace, this should return a Good value. For ticker nodes like
# FastUInt1 we don't require exact equality with stage 2 (the ticker has
# likely advanced between reads); a Good-status read is the real signal.
# ---------------------------------------------------------------------------
Write-Header "Forward bridge (remote → local)"
$localRead = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $BridgeNodeId)
if ($localRead.ExitCode -eq 0 -and $localRead.Output -match "Status:\s+0x00000000" -and $localRead.Output -match "Value:\s+([^\r\n]+)") {
$localValue = $Matches[1].Trim()
Write-Pass "local bridge $BridgeNodeId = $localValue (remote was $remoteValue)"
$results += @{ Passed = $true }
} else {
Write-Fail "local bridge read failed — driver instance may not be configured or discovery hasn't run"
Write-Host $localRead.Output
$results += @{ Passed = $false; Reason = "forward bridge failed" }
}
# ---------------------------------------------------------------------------
# Stage 4 — Subscribe sees change. opc-plc's FastUInt1 ticks autonomously so we
# don't need to drive a write. A properly wired OpcUaClient driver forwards
# remote MonitoredItem data-change callbacks to the local server, which then
# publishes them to our subscribe client. If nothing arrives within the
# window, either the remote node isn't a ticker OR the upstream subscription
# chain is broken (probe state, keep-alive, SDK publish queue).
# ---------------------------------------------------------------------------
Write-Header "Subscribe sees change"
$stdout = New-TemporaryFile
$stderr = New-TemporaryFile
$subArgs = @($opcUaCli.PrefixArgs) + @(
"subscribe", "-u", $OpcUaUrl, "-n", $BridgeNodeId,
"-i", "200", "--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
# SubscribeCommand prints `[timestamp] <NodeId> = <value> (0xNNNNNNNN)` per
# data-change event. 0x00000000 == Good; anything else is a non-Good status
# we intentionally don't count (a quality drop isn't a "saw the change").
$changeLines = @(($subOut -split "`n") | Where-Object { $_ -match "=\s+\S.*\(0x00000000\)" })
if ($changeLines.Count -gt 0) {
Write-Pass "$($changeLines.Count) data-change events observed on bridge"
$results += @{ Passed = $true }
} else {
Write-Fail "no data-change events in ${ChangeWaitSec}s — upstream node may be static, or subscription chain broken"
Write-Host $subOut
$results += @{ Passed = $false; Reason = "no data-change" }
}
# ---------------------------------------------------------------------------
# Stage 5 — Reverse bridge. Only runs when both writable NodeIds are supplied.
# Writes on the local bridge side, reads directly on the upstream to verify
# the write crossed the driver. 2s settle accounts for the driver's next poll
# (non-idempotent writes on upstream side may take a tick to propagate).
# ---------------------------------------------------------------------------
if ([string]::IsNullOrEmpty($WritableBridgeNodeId) -or [string]::IsNullOrEmpty($WritableRemoteNodeId)) {
Write-Header "Reverse bridge (local → remote)"
Write-Skip "WritableBridgeNodeId / WritableRemoteNodeId not supplied — opc-plc default has no writable nodes. Add --sn=N to the compose and re-run with both params set."
} else {
Write-Header "Reverse bridge (local → remote)"
$writeValue = Get-Random -Minimum 1 -Maximum 9999
$w = Invoke-Cli -Cli $opcUaCli -Args @(
"write", "-u", $OpcUaUrl, "-n", $WritableBridgeNodeId, "-v", "$writeValue")
if ($w.ExitCode -ne 0 -or $w.Output -notmatch "Write successful") {
Write-Fail "local-side write failed"
Write-Host $w.Output
$results += @{ Passed = $false; Reason = "reverse-bridge write failed" }
} else {
Write-Info "local write ok, waiting 2s for driver propagate"
Start-Sleep -Seconds 2
$r = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $RemoteUrl, "-n", $WritableRemoteNodeId)
if ($r.ExitCode -eq 0 -and $r.Output -match "Value:\s+$([Regex]::Escape("$writeValue"))\b") {
Write-Pass "remote reads back $writeValue"
$results += @{ Passed = $true }
} else {
Write-Fail "remote value did not reflect $writeValue"
Write-Host $r.Output
$results += @{ Passed = $false; Reason = "reverse-bridge readback mismatch" }
}
}
}
# ---------------------------------------------------------------------------
# Stage 6 — Browse mirror. Walks -BridgeRootNodeId to -BrowseDepth levels. The
# BrowseCommand emits one line per encountered node; we count non-empty lines
# minus the root-summary line and compare against -BrowseMinNodes. A naked
# i=85 root always has something; a narrower dev-specific root is stricter.
# ---------------------------------------------------------------------------
Write-Header "Browse mirror"
$br = Invoke-Cli -Cli $opcUaCli -Args @(
"browse", "-u", $OpcUaUrl, "-n", $BridgeRootNodeId,
"-r", "-d", "$BrowseDepth")
if ($br.ExitCode -ne 0) {
Write-Fail "browse failed (exit=$($br.ExitCode))"
Write-Host $br.Output
$results += @{ Passed = $false; Reason = "browse failed" }
} else {
# BrowseCommand prints one line per node: `[Type] Name (NodeId: xxx)` with
# indentation for depth. Count every line carrying a NodeId marker.
$nodeLines = @(($br.Output -split "`n") | Where-Object { $_ -match "\(NodeId:" })
$count = $nodeLines.Count
if ($count -ge $BrowseMinNodes) {
Write-Pass "$count descendants under $BridgeRootNodeId (>= $BrowseMinNodes)"
$results += @{ Passed = $true }
} else {
Write-Fail "only $count descendants — expected >= $BrowseMinNodes"
Write-Host $br.Output
$results += @{ Passed = $false; Reason = "browse under-populated" }
}
}
# ---------------------------------------------------------------------------
# Stage 7 — Alarm fires. opc-plc with --alm (set in the compose) cycles a
# TripAlarm Condition autonomously. The local alarm subscription should
# surface at least one Active transition within the wait window. Opt-in:
# requires the user to know the local mirror of the upstream alarm Condition.
# ---------------------------------------------------------------------------
if ([string]::IsNullOrEmpty($AlarmNodeId)) {
Write-Header "Alarm fires"
Write-Skip "AlarmNodeId not supplied — skipping alarm stage"
} else {
Write-Header "Alarm fires"
$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 ${AlarmWaitSec}s for opc-plc alarm cycle"
Start-Sleep -Seconds $AlarmWaitSec
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
if ($out -match "ALARM\b" -and $out -match "Active\b") {
Write-Pass "alarm condition fired with Active state"
$results += @{ Passed = $true }
} else {
Write-Fail "no Active alarm event observed in ${AlarmWaitSec}s — check opc-plc compose has --alm + the AlarmNodeId is the local mirror of the upstream Condition"
Write-Host $out
$results += @{ Passed = $false; Reason = "no alarm event" }
}
}
# ---------------------------------------------------------------------------
# Stage 8 — History read. IHistoryProvider dispatch to the upstream's
# HistoryRead service. opc-plc does NOT historize by default, so this stage
# SKIPs when -HistoryNodeId is empty. Against a historizing upstream (Prosys,
# UA Expert sample server, AVEVA Historian) point -HistoryNodeId at the local
# mirror of a historized node.
# ---------------------------------------------------------------------------
if ([string]::IsNullOrEmpty($HistoryNodeId)) {
Write-Header "History read"
Write-Skip "HistoryNodeId not supplied — opc-plc default does not historize; supply a historized-upstream mirror NodeId to enable."
} else {
$results += Test-HistoryHasSamples `
-OpcUaCli $opcUaCli `
-OpcUaUrl $OpcUaUrl `
-NodeId $HistoryNodeId `
-LookbackSec $HistoryLookbackSec
}
Write-Summary -Title "OpcUaClient e2e" -Results $results
if ($results | Where-Object { -not $_.Passed }) { exit 1 }