Files
lmxopcua/scripts/e2e/test-opcuaclient.ps1
Joseph Doherty c36903d6a0 Auto: opcuaclient-12 — IHistoryProvider.ReadEventsAsync EventFilter spec + impl
Adds a filter-aware overload of IHistoryProvider.ReadEventsAsync that carries
EventFilter SelectClauses + WhereClause, and implements it on the OPC UA
Client driver via Session.HistoryReadAsync + ReadEventDetails.

The change is additive (default-impl returns NotSupportedException) so the
existing Galaxy.Proxy.GalaxyProxyDriver implementation keeps compiling
against the fixed-field overload — no cross-driver refactor required.

* Core.Abstractions: new EventHistoryRequest / SimpleAttributeSpec /
  ContentFilterSpec records mirror the OPC UA wire shape transport-neutrally.
  HistoricalEventBatch / HistoricalEventRow carry an open-ended Fields bag
  keyed by SimpleAttributeSpec.FieldName so server-side dispatch can re-align
  with the client's wire-side SelectClause order.
* OpcUaClient driver: new ReadEventsAsync(fullReference, EventHistoryRequest, ct)
  builds an EventFilter, calls Session.HistoryReadAsync, and unwraps
  HistoryEvent.Events into HistoricalEventBatch rows. Default SelectClause
  set matches BuildHistoryEvent on the server side. ContentFilter bytes are
  decoded through the live session's MessageContext (passthrough — the
  driver does not evaluate filters).
* Unit tests: 7 new tests cover SelectClause translation, default-clause
  fallback, malformed where-clause swallowing, uninitialized-driver guard,
  null-request guard, and IHistoryProvider default fallback.
* Integration scaffold: build-only [Fact] gated on opc-plc --alm; flips to
  green when the fixture image is upgraded.
* Docs: HistoryRead Events section in docs/drivers/OpcUaClient.md plus a
  cross-link from Client.CLI.md historyread page.
* E2E: -HistoryEvents switch on scripts/e2e/test-opcuaclient.ps1 confirms
  the gateway round-trips HistoryReadEvents without
  BadHistoryOperationUnsupported (gated; defaults to skip).

Closes #284
2026-04-26 09:29:40 -04:00

211 lines
9.7 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"
)
$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.
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