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:
@@ -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
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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
|
||||
|
||||
392
scripts/e2e/test-opcuaclient.ps1
Normal file
392
scripts/e2e/test-opcuaclient.ps1
Normal 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 }
|
||||
Reference in New Issue
Block a user