Files
lmxopcua/scripts/e2e/test-opcuaclient.ps1
2026-04-26 10:05:05 -04:00

241 lines
11 KiB
PowerShell

#Requires -Version 7.0
<#
.SYNOPSIS
End-to-end CLI test for the OPC UA Client (gateway) driver bridged through
the OtOpcUa server. Stages: probe, read, subscribe, topology-change.
.DESCRIPTION
The OPC UA Client driver reads from an upstream OPC UA server (default:
Microsoft's opc-plc simulator on opc.tcp://localhost:50000) and re-exposes
its address space through the local OtOpcUa server. This script drives
the bridged path end-to-end via `otopcua-cli`.
Four stages:
1. Probe — otopcua-cli connect succeeds against the OtOpcUa
server; confirms the gateway is up.
2. Bridged read — otopcua-cli read on the bridged NodeId returns a
Good value with a non-null payload; proves the
IReadable.ReadAsync path round-trips through the
driver to the upstream simulator.
3. Subscribe — otopcua-cli subscribe observes a data change within
N seconds (opc-plc's StepUp ticks once per second by
default, so this should always see a change).
4. Topology change — assert the auto-reimport-on-ModelChangeEvent path
is wired up. We can't easily fire a real upstream
model change without elevated opc-plc access, so
this stage prints the option settings + asserts the
driver's diagnostic surface reflects WatchModelChanges
is enabled (or skips with INFO when the upstream
doesn't expose ModelChangeEventType).
Requires:
- a running OtOpcUa server whose config DB has an OpcUaClient
DriverInstance bound to opc-plc (or another upstream server)
- the upstream OPC UA simulator reachable at $UpstreamUrl
- a Tag bridged from upstream NodeId $UpstreamNodeId to local
$BridgedNodeId
.PARAMETER OpcUaUrl
Endpoint URL of the OtOpcUa server. Default opc.tcp://localhost:4840.
.PARAMETER UpstreamUrl
Endpoint URL of the upstream OPC UA server (for documentation; the bridge
itself is wired in the OtOpcUa server config). Default opc.tcp://localhost:50000.
.PARAMETER BridgedNodeId
Local NodeId the OtOpcUa server exposes for the upstream tag. Required —
set per your server config (e.g. 'ns=2;s=/warsaw/opc-plc/StepUp').
.PARAMETER UpstreamNodeId
The upstream NodeId being bridged (informational only; default
'ns=3;s=StepUp' which is opc-plc's monotonically-increasing UInt32).
.PARAMETER ChangeWaitSec
How long the subscribe stage waits for a data-change. Default 10s.
.PARAMETER ReverseConnect
When set, the script asserts the gateway is configured for reverse-connect
(server-initiated) mode. The OtOpcUa server's DriverConfig for the OpcUaClient
instance must already have ReverseConnect.Enabled=true + ListenerUrl set; this
script doesn't reconfigure the driver, only verifies the bridged path still
reads end-to-end with the listener up. The reverse-connect topology is opaque
to the downstream OPC UA client (us), so the read assertion is identical to
the dial-mode path — the value of running the script in this mode is to catch
regressions where reverse-connect breaks the post-init capability surface.
.PARAMETER ReverseListenerUrl
Documentation-only. The listener URL the gateway is expected to be bound to
when -ReverseConnect is set; printed in the run banner so operators can
cross-check their server config. Default opc.tcp://0.0.0.0:4844.
.EXAMPLE
.\test-opcuaclient.ps1 -BridgedNodeId "ns=2;s=/warsaw/opc-plc/StepUp"
.EXAMPLE
# OT-DMZ deployment: the upstream dials the gateway. The script flow is the
# same — we still drive the bridged read through the OtOpcUa server — but the
# banner reflects the reverse-connect topology.
.\test-opcuaclient.ps1 -BridgedNodeId "ns=2;s=/warsaw/opc-plc/StepUp" -ReverseConnect
#>
param(
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
[string]$UpstreamUrl = "opc.tcp://localhost:50000",
[Parameter(Mandatory)] [string]$BridgedNodeId,
[string]$UpstreamNodeId = "ns=3;s=StepUp",
[int]$ChangeWaitSec = 10,
[switch]$ReverseConnect,
[string]$ReverseListenerUrl = "opc.tcp://0.0.0.0:4844",
# PR-12: HistoryReadEvents passthrough check. Requires the upstream to be running
# in alarm-history mode (opc-plc --alm) AND the OtOpcUa server to expose a notifier
# node bridged to the upstream's events source. The CLI doesn't have a dedicated
# event-history command yet; this stage runs a regular historyread against the
# bridged notifier and confirms the gateway round-trips the request without
# surfacing BadHistoryOperationUnsupported, which would indicate the filter-aware
# ReadEventsAsync path lost wiring.
[switch]$HistoryEvents,
[string]$EventsNotifierNodeId = "i=2253",
# PR-14: upstream-redundancy probe. Passes the primary + secondary URLs
# straight through to the gateway driver via DriverConfig (operator must have
# already wired Redundancy.Enabled=true on the OpcUaClient instance — this
# script doesn't reconfigure the driver, only verifies the bridged read still
# works while both upstreams are reachable, and that the driver's redundancy
# diagnostics are non-null). Stage is no-op when neither URL is provided.
[string]$PrimaryUrl,
[string]$SecondaryUrl
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot/_common.ps1"
$opcUaCli = Get-CliInvocation `
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
-ExeName "otopcua-cli"
if ($ReverseConnect) {
Write-Host "[INFO] -ReverseConnect set: gateway is expected to be bound to listener $ReverseListenerUrl"
Write-Host "[INFO] Upstream OPC UA server should be configured with --rc=$ReverseListenerUrl (or equivalent on a real server)"
}
$results = @()
# Stage 1: probe
$results += Test-Probe `
-Name "OpcUaClient probe" `
-Cmd $opcUaCli `
-Args @("connect", "-u", $OpcUaUrl)
# Stage 2: bridged read
$results += Test-Probe `
-Name "OpcUaClient bridged read" `
-Cmd $opcUaCli `
-Args @("read", "-u", $OpcUaUrl, "-n", $BridgedNodeId)
# Stage 3: subscribe-sees-change
Write-Host "[INFO] Subscribing to $BridgedNodeId for ${ChangeWaitSec}s..."
$subResults = & $opcUaCli.Cmd @($opcUaCli.Args + @(
"subscribe", "-u", $OpcUaUrl, "-n", $BridgedNodeId,
"-i", "500", "--duration", "$ChangeWaitSec"))
if ($LASTEXITCODE -eq 0 -and $subResults -match "DataChange|StepUp|value=") {
$results += [pscustomobject]@{ Stage = "Subscribe-sees-change"; Status = "PASS" }
} else {
$results += [pscustomobject]@{ Stage = "Subscribe-sees-change"; Status = "FAIL" }
}
# Stage 4: topology change (auto-reimport on ModelChangeEvent)
#
# The OPC UA Client driver subscribes to BaseModelChangeEventType on the
# upstream Server node (i=2253) at the end of InitializeAsync, then debounces
# events over OpcUaClientDriverOptions.ModelChangeDebounce (default 5s) and
# triggers ReinitializeAsync.
#
# Driving a real upstream ModelChangeEvent from outside the simulator is
# upstream-specific:
# - opc-plc: invoke OpcPlc.AddSlowNode via OPC UA Call (requires a session
# directly to opc-plc, not via the gateway, since the gateway exposes
# mirrored read/write paths only for variables — methods are mirrored
# under PR-9 but call permissions on the simulator's namespace may
# not allow downstream invocation).
# - production server: deploy a topology-change to the upstream server +
# observe the local re-import.
#
# This stage is therefore documentation-only by default. Set
# $env:OPCUACLIENT_TOPOLOGY_TRIGGER_CMD to a command that drives a real
# topology change on the upstream and we'll execute it + wait for the
# debounced re-import.
$triggerCmd = $env:OPCUACLIENT_TOPOLOGY_TRIGGER_CMD
if ($triggerCmd) {
Write-Host "[INFO] Driving topology change via: $triggerCmd"
& cmd.exe /c $triggerCmd
Start-Sleep -Seconds 8 # debounce window + re-import duration
# After re-import the bridged node should still be readable (or, if
# the upstream removed the node, the read should return BadNodeIdUnknown).
# Either way the gateway must remain healthy.
$results += Test-Probe `
-Name "Topology-change re-read" `
-Cmd $opcUaCli `
-Args @("read", "-u", $OpcUaUrl, "-n", $BridgedNodeId)
} else {
Write-Host "[INFO] Topology-change stage skipped (set OPCUACLIENT_TOPOLOGY_TRIGGER_CMD to drive a real upstream model change)."
$results += [pscustomobject]@{ Stage = "Topology-change"; Status = "SKIP" }
}
# Stage 5 (gated): HistoryReadEvents passthrough
#
# PR-12 lands the filter-aware IHistoryProvider.ReadEventsAsync overload on the
# OPC UA Client driver. End-to-end coverage requires:
# (a) the upstream in alarm-history mode (opc-plc --alm or a real server);
# (b) the OtOpcUa server forwarding HistoryReadEvents to the gateway driver.
# Gated behind -HistoryEvents because the default opc-plc fixture image isn't
# launched with --alm. When set, the stage issues a historyread against the
# bridged notifier ($EventsNotifierNodeId) and confirms the gateway returns
# the request without BadHistoryOperationUnsupported.
# Stage 6 (gated): upstream-redundancy probe (PR-14)
#
# When -PrimaryUrl + -SecondaryUrl are both supplied, the script runs an extra
# read against the bridged NodeId and reports whether the gateway is still
# answering. The actual ServiceLevel-driven failover is observable only on the
# server side (driver-diagnostics RPC reports RedundancyFailoverCount); this
# stage is a smoke check that the bridged path keeps round-tripping while
# both upstreams are reachable. Drive a real failover by writing to the
# primary's ServiceLevel node from outside this script.
if ($PrimaryUrl -and $SecondaryUrl) {
Write-Host "[INFO] Upstream redundancy probe: primary=$PrimaryUrl secondary=$SecondaryUrl"
$results += Test-Probe `
-Name "OpcUaClient redundancy bridged-read" `
-Cmd $opcUaCli `
-Args @("read", "-u", $OpcUaUrl, "-n", $BridgedNodeId)
} else {
if (-not $PrimaryUrl -and -not $SecondaryUrl) {
Write-Host "[INFO] Upstream redundancy stage skipped (set -PrimaryUrl and -SecondaryUrl to enable)."
$results += [pscustomobject]@{ Stage = "Upstream-redundancy"; Status = "SKIP" }
}
}
if ($HistoryEvents) {
Write-Host "[INFO] HistoryEvents stage: issuing historyread against $EventsNotifierNodeId"
$start = (Get-Date).ToUniversalTime().AddMinutes(-30).ToString("o")
$end = (Get-Date).ToUniversalTime().AddMinutes(1).ToString("o")
$eventOut = & $opcUaCli.Cmd @($opcUaCli.Args + @(
"historyread", "-u", $OpcUaUrl, "-n", $EventsNotifierNodeId,
"--start", $start, "--end", $end))
if ($LASTEXITCODE -eq 0 -and $eventOut -notmatch "BadHistoryOperationUnsupported") {
$results += [pscustomobject]@{ Stage = "HistoryReadEvents"; Status = "PASS" }
} elseif ($eventOut -match "BadHistoryOperationUnsupported") {
Write-Host "[INFO] Upstream returned BadHistoryOperationUnsupported — re-run with --alm + a notifier that has event history."
$results += [pscustomobject]@{ Stage = "HistoryReadEvents"; Status = "SKIP" }
} else {
$results += [pscustomobject]@{ Stage = "HistoryReadEvents"; Status = "FAIL" }
}
}
Write-Host ""
Write-Host "=== test-opcuaclient.ps1 results ==="
$results | Format-Table -AutoSize
$failed = $results | Where-Object { $_.Status -eq "FAIL" }
if ($failed) {
exit 1
}
exit 0