#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://10.100.0.35: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://10.100.0.35: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://10.100.0.35: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] = (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 }