Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fc596a9a1 |
@@ -133,6 +133,7 @@ section to skip it.
|
|||||||
| Modbus | — | **PASS** (pymodbus fixture) |
|
| Modbus | — | **PASS** (pymodbus fixture) |
|
||||||
| AB CIP | — | **PASS** (ab_server fixture) |
|
| AB CIP | — | **PASS** (ab_server fixture) |
|
||||||
| AB Legacy | — | **PASS** (ab_server SLC500/MicroLogix/PLC-5 profiles; `/1,0` cip-path required for the Docker fixture) |
|
| AB Legacy | — | **PASS** (ab_server SLC500/MicroLogix/PLC-5 profiles; `/1,0` cip-path required for the Docker fixture) |
|
||||||
|
| Galaxy | — | **PASS** (requires OtOpcUaGalaxyHost + a live Galaxy; 7 stages including alarms + history) |
|
||||||
| S7 | — | **PASS** (python-snap7 fixture) |
|
| S7 | — | **PASS** (python-snap7 fixture) |
|
||||||
| FOCAS | `FOCAS_TRUST_WIRE=1` | **SKIP** (no public simulator — task #222 lab rig) |
|
| 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** (needs XAR or standalone Router — task #221) |
|
||||||
|
|||||||
@@ -310,6 +310,109 @@ function Test-SubscribeSeesChange {
|
|||||||
return @{ Passed = $false; Reason = "change not observed on subscription" }
|
return @{ Passed = $false; Reason = "change not observed on subscription" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Test — alarm fires on threshold. Start `otopcua-cli alarms --refresh` on the
|
||||||
|
# alarm Condition NodeId in the background; drive the underlying data change via
|
||||||
|
# `otopcua-cli write` on the input NodeId; wait for the subscription window to
|
||||||
|
# close; assert the captured stdout contains a matching ALARM line (`SourceName`
|
||||||
|
# of the Condition + an Active state). Covers Part 9 alarm propagation through
|
||||||
|
# the server → driver → Condition node path.
|
||||||
|
function Test-AlarmFiresOnThreshold {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)] $OpcUaCli,
|
||||||
|
[Parameter(Mandatory)] [string]$OpcUaUrl,
|
||||||
|
[Parameter(Mandatory)] [string]$AlarmNodeId,
|
||||||
|
[Parameter(Mandatory)] [string]$InputNodeId,
|
||||||
|
[Parameter(Mandatory)] [string]$TriggerValue,
|
||||||
|
[int]$DurationSec = 10,
|
||||||
|
[int]$SettleSec = 2
|
||||||
|
)
|
||||||
|
Write-Header "Alarm fires on threshold"
|
||||||
|
|
||||||
|
$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 ${SettleSec}s to settle"
|
||||||
|
Start-Sleep -Seconds $SettleSec
|
||||||
|
|
||||||
|
$w = Invoke-Cli -Cli $OpcUaCli -Args @(
|
||||||
|
"write", "-u", $OpcUaUrl, "-n", $InputNodeId, "-v", $TriggerValue)
|
||||||
|
if ($w.ExitCode -ne 0) {
|
||||||
|
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item $stdout.FullName, $stderr.FullName -ErrorAction SilentlyContinue
|
||||||
|
Write-Fail "input write failed (exit=$($w.ExitCode))"
|
||||||
|
Write-Host $w.Output
|
||||||
|
return @{ Passed = $false; Reason = "input write failed" }
|
||||||
|
}
|
||||||
|
Write-Info "input write ok, waiting up to ${DurationSec}s for the alarm to surface"
|
||||||
|
|
||||||
|
# otopcua-cli alarms runs until Ctrl+C; terminate it ourselves after the
|
||||||
|
# duration window (no built-in --duration flag on the alarms command).
|
||||||
|
Start-Sleep -Seconds $DurationSec
|
||||||
|
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
|
||||||
|
|
||||||
|
# AlarmsCommand emits `[ts] ALARM <SourceName>` per event + lines for
|
||||||
|
# State: Active,Unacknowledged | Severity | Message. Match on `ALARM` +
|
||||||
|
# `Active` — both need to appear for the alarm to count as fired.
|
||||||
|
if ($out -match "ALARM\b" -and $out -match "Active\b") {
|
||||||
|
Write-Pass "alarm condition fired with Active state"
|
||||||
|
return @{ Passed = $true }
|
||||||
|
}
|
||||||
|
Write-Fail "no Active alarm event observed in ${DurationSec}s"
|
||||||
|
Write-Host $out
|
||||||
|
return @{ Passed = $false; Reason = "no alarm event" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test — history-read returns samples. Calls `otopcua-cli historyread` on the
|
||||||
|
# target NodeId for a time window (default 1h back) and asserts the CLI reports
|
||||||
|
# at least one value returned. Works against any historized tag — driver-sourced,
|
||||||
|
# virtual, or scripted-alarm historizing to the Aveva / SQLite sink.
|
||||||
|
function Test-HistoryHasSamples {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)] $OpcUaCli,
|
||||||
|
[Parameter(Mandatory)] [string]$OpcUaUrl,
|
||||||
|
[Parameter(Mandatory)] [string]$NodeId,
|
||||||
|
[int]$LookbackSec = 3600,
|
||||||
|
[int]$MinSamples = 1
|
||||||
|
)
|
||||||
|
Write-Header "History read"
|
||||||
|
|
||||||
|
$end = (Get-Date).ToUniversalTime().ToString("o")
|
||||||
|
$start = (Get-Date).ToUniversalTime().AddSeconds(-$LookbackSec).ToString("o")
|
||||||
|
|
||||||
|
$r = Invoke-Cli -Cli $OpcUaCli -Args @(
|
||||||
|
"historyread", "-u", $OpcUaUrl, "-n", $NodeId,
|
||||||
|
"--start", $start, "--end", $end, "--max", "1000")
|
||||||
|
if ($r.ExitCode -ne 0) {
|
||||||
|
Write-Fail "historyread exit=$($r.ExitCode)"
|
||||||
|
Write-Host $r.Output
|
||||||
|
return @{ Passed = $false; Reason = "historyread failed" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# HistoryReadCommand ends with `N values returned.` — parse and check >= MinSamples.
|
||||||
|
if ($r.Output -match '(\d+)\s+values?\s+returned') {
|
||||||
|
$count = [int]$Matches[1]
|
||||||
|
if ($count -ge $MinSamples) {
|
||||||
|
Write-Pass "$count samples returned (>= $MinSamples)"
|
||||||
|
return @{ Passed = $true }
|
||||||
|
}
|
||||||
|
Write-Fail "only $count samples returned, expected >= $MinSamples — tag may not be historized, or lookback window misses samples"
|
||||||
|
Write-Host $r.Output
|
||||||
|
return @{ Passed = $false; Reason = "insufficient samples" }
|
||||||
|
}
|
||||||
|
Write-Fail "could not parse 'N values returned.' marker from historyread output"
|
||||||
|
Write-Host $r.Output
|
||||||
|
return @{ Passed = $false; Reason = "parse failure" }
|
||||||
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Summary helper — caller passes an array of test results.
|
# Summary helper — caller passes an array of test results.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -49,6 +49,17 @@
|
|||||||
"bridgeNodeId": "ns=2;s=TwinCAT/MAIN_iCounter"
|
"bridgeNodeId": "ns=2;s=TwinCAT/MAIN_iCounter"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"galaxy": {
|
||||||
|
"$comment": "Galaxy (MXAccess) driver. Has no per-driver CLI — all stages go through otopcua-cli against the published NodeIds. Seven stages: probe / source read / virtual-tag bridge / subscribe-sees-change / reverse write / alarm fires / history read. Requires OtOpcUaGalaxyHost running + seed-phase-7-smoke.sql applied with a real Galaxy attribute substituted into dbo.Tag.TagConfig.",
|
||||||
|
"sourceNodeId": "ns=2;s=p7-smoke-tag-source",
|
||||||
|
"virtualNodeId": "ns=2;s=p7-smoke-vt-derived",
|
||||||
|
"alarmNodeId": "ns=2;s=p7-smoke-al-overtemp",
|
||||||
|
"alarmTriggerValue": "75",
|
||||||
|
"changeWaitSec": 10,
|
||||||
|
"alarmWaitSec": 10,
|
||||||
|
"historyLookbackSec": 3600
|
||||||
|
},
|
||||||
|
|
||||||
"phase7": {
|
"phase7": {
|
||||||
"$comment": "Virtual tags + scripted alarms. The VirtualNodeId must resolve to a server-side virtual tag whose script reads the modbus InputNodeId and writes VT = input * 2. The AlarmNodeId is the ConditionId of a scripted alarm that fires when VT > 100.",
|
"$comment": "Virtual tags + scripted alarms. The VirtualNodeId must resolve to a server-side virtual tag whose script reads the modbus InputNodeId and writes VT = input * 2. The AlarmNodeId is the ConditionId of a scripted alarm that fires when VT > 100.",
|
||||||
"modbusEndpoint": "127.0.0.1:5502",
|
"modbusEndpoint": "127.0.0.1:5502",
|
||||||
|
|||||||
@@ -172,6 +172,23 @@ else { $summary["twincat"] = "SKIP (no config entry)" }
|
|||||||
# Phase 7 virtual tags + scripted alarms
|
# Phase 7 virtual tags + scripted alarms
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
$galaxy = Get-Or $config "galaxy"
|
||||||
|
if ($galaxy) {
|
||||||
|
Write-Header "== GALAXY =="
|
||||||
|
Run-Suite "galaxy" {
|
||||||
|
& "$PSScriptRoot/test-galaxy.ps1" `
|
||||||
|
-OpcUaUrl (Get-Or $galaxy "opcUaUrl" $OpcUaUrl) `
|
||||||
|
-SourceNodeId $galaxy["sourceNodeId"] `
|
||||||
|
-VirtualNodeId (Get-Or $galaxy "virtualNodeId" "") `
|
||||||
|
-AlarmNodeId (Get-Or $galaxy "alarmNodeId" "") `
|
||||||
|
-AlarmTriggerValue (Get-Or $galaxy "alarmTriggerValue" "75") `
|
||||||
|
-ChangeWaitSec (Get-Or $galaxy "changeWaitSec" 10) `
|
||||||
|
-AlarmWaitSec (Get-Or $galaxy "alarmWaitSec" 10) `
|
||||||
|
-HistoryLookbackSec (Get-Or $galaxy "historyLookbackSec" 3600)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else { $summary["galaxy"] = "SKIP (no config entry)" }
|
||||||
|
|
||||||
$phase7 = Get-Or $config "phase7"
|
$phase7 = Get-Or $config "phase7"
|
||||||
if ($phase7) {
|
if ($phase7) {
|
||||||
Write-Header "== PHASE 7 virtual tags + scripted alarms =="
|
Write-Header "== PHASE 7 virtual tags + scripted alarms =="
|
||||||
|
|||||||
278
scripts/e2e/test-galaxy.ps1
Normal file
278
scripts/e2e/test-galaxy.ps1
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
#Requires -Version 7.0
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
End-to-end CLI test for the Galaxy (MXAccess) driver — read, write, subscribe,
|
||||||
|
alarms, and history through a running OtOpcUa server.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Unlike the other e2e scripts there is no `otopcua-galaxy-cli` — the Galaxy
|
||||||
|
driver proxy lives in-process with the server + talks to `OtOpcUaGalaxyHost`
|
||||||
|
over a named pipe (MXAccess is 32-bit COM, can't ship in the .NET 10 process).
|
||||||
|
Every stage therefore goes through `otopcua-cli` against the published OPC UA
|
||||||
|
address space.
|
||||||
|
|
||||||
|
Seven stages:
|
||||||
|
|
||||||
|
1. Probe — otopcua-cli connect + read the source NodeId; confirms
|
||||||
|
the whole Galaxy.Host → Proxy → server → client chain is
|
||||||
|
up
|
||||||
|
2. Source read — otopcua-cli read returns a Good value for the source
|
||||||
|
attribute; proves IReadable.ReadAsync is dispatching
|
||||||
|
through the IPC bridge
|
||||||
|
3. Virtual-tag bridge — `otopcua-cli read` on the VirtualTag NodeId; confirms
|
||||||
|
the Phase 7 CachedTagUpstreamSource is bridging the
|
||||||
|
driver-sourced input into the scripting engine
|
||||||
|
4. Subscribe-sees-change — subscribe to the source NodeId in the background;
|
||||||
|
Galaxy pushes a data-change event within N seconds
|
||||||
|
(Galaxy's underlying attribute must be actively
|
||||||
|
changing — production Galaxies typically have
|
||||||
|
scan-driven updates; for idle galaxies, widen
|
||||||
|
-ChangeWaitSec or drive the write stage below first)
|
||||||
|
5. Reverse bridge — `otopcua-cli write` to a writable Galaxy attribute;
|
||||||
|
read it back. Gracefully becomes INFO-only if the
|
||||||
|
attribute's Galaxy-side AccessLevel forbids writes
|
||||||
|
(BadUserAccessDenied / BadNotWritable)
|
||||||
|
6. Alarm fires — subscribe to the scripted-alarm Condition NodeId,
|
||||||
|
drive the source tag above its threshold, confirm an
|
||||||
|
Active alarm event surfaces. Exercises the Part 9
|
||||||
|
alarm-condition propagation path
|
||||||
|
7. History read — historyread on the source tag over the last hour;
|
||||||
|
confirms Aveva Historian → IHistoryProvider dispatch
|
||||||
|
returns samples
|
||||||
|
|
||||||
|
The Phase 7 seed (`scripts/smoke/seed-phase-7-smoke.sql`) already plants the
|
||||||
|
right shape — one Galaxy DriverInstance, one source Tag, one VirtualTag
|
||||||
|
(source × 2), one ScriptedAlarm (source > 50). Substitute the real Galaxy
|
||||||
|
attribute FullName into `dbo.Tag.TagConfig` before running.
|
||||||
|
|
||||||
|
.PARAMETER OpcUaUrl
|
||||||
|
OtOpcUa server endpoint. Default opc.tcp://localhost:4840.
|
||||||
|
|
||||||
|
.PARAMETER SourceNodeId
|
||||||
|
NodeId of the driver-sourced Galaxy tag (numeric, writable preferred).
|
||||||
|
Default matches the Phase 7 seed — `ns=2;s=p7-smoke-tag-source`.
|
||||||
|
|
||||||
|
.PARAMETER VirtualNodeId
|
||||||
|
NodeId of the VirtualTag computed as Source × 2 (Phase 7 scripting).
|
||||||
|
Default matches the Phase 7 seed — `ns=2;s=p7-smoke-vt-derived`.
|
||||||
|
|
||||||
|
.PARAMETER AlarmNodeId
|
||||||
|
NodeId of the scripted-alarm Condition (fires when Source > 50).
|
||||||
|
Default matches the Phase 7 seed — `ns=2;s=p7-smoke-al-overtemp`.
|
||||||
|
|
||||||
|
.PARAMETER AlarmTriggerValue
|
||||||
|
Value written to -SourceNodeId to push it over the alarm threshold.
|
||||||
|
Default 75 (well above the seeded 50-threshold).
|
||||||
|
|
||||||
|
.PARAMETER ChangeWaitSec
|
||||||
|
Seconds the subscribe-sees-change stage waits for a natural data change.
|
||||||
|
Default 10. Idle galaxies may need this extended or the stage will fail
|
||||||
|
with "subscribe did not observe...".
|
||||||
|
|
||||||
|
.PARAMETER AlarmWaitSec
|
||||||
|
Seconds the alarm-fires stage waits after triggering the write. Default 10.
|
||||||
|
|
||||||
|
.PARAMETER HistoryLookbackSec
|
||||||
|
Seconds back from now to query history. Default 3600 (1 h).
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
# Against the default Phase-7 smoke seed + live Galaxy + OtOpcUa server
|
||||||
|
./scripts/e2e/test-galaxy.ps1
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
# Custom NodeIds from a non-smoke cluster
|
||||||
|
./scripts/e2e/test-galaxy.ps1 `
|
||||||
|
-SourceNodeId "ns=2;s=Reactor1.Temperature" `
|
||||||
|
-VirtualNodeId "ns=2;s=Reactor1.TempDoubled" `
|
||||||
|
-AlarmNodeId "ns=2;s=Reactor1.OverTemp" `
|
||||||
|
-AlarmTriggerValue 120
|
||||||
|
#>
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
|
||||||
|
[string]$SourceNodeId = "ns=2;s=p7-smoke-tag-source",
|
||||||
|
[string]$VirtualNodeId = "ns=2;s=p7-smoke-vt-derived",
|
||||||
|
[string]$AlarmNodeId = "ns=2;s=p7-smoke-al-overtemp",
|
||||||
|
[string]$AlarmTriggerValue = "75",
|
||||||
|
[int]$ChangeWaitSec = 10,
|
||||||
|
[int]$AlarmWaitSec = 10,
|
||||||
|
[int]$HistoryLookbackSec = 3600
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
. "$PSScriptRoot/_common.ps1"
|
||||||
|
|
||||||
|
$opcUaCli = Get-CliInvocation `
|
||||||
|
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
|
||||||
|
-ExeName "otopcua-cli"
|
||||||
|
|
||||||
|
$results = @()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stage 1 — Probe. The probe is an otopcua-cli read against the source NodeId;
|
||||||
|
# success implies Galaxy.Host is up + the pipe ACL lets the server connect +
|
||||||
|
# the Proxy is tracking the tag + the server published it.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Write-Header "Probe"
|
||||||
|
$probe = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $SourceNodeId)
|
||||||
|
if ($probe.ExitCode -eq 0 -and $probe.Output -match "Status:\s+0x00000000") {
|
||||||
|
Write-Pass "source NodeId readable (Galaxy pipe → proxy → server → client chain up)"
|
||||||
|
$results += @{ Passed = $true }
|
||||||
|
} else {
|
||||||
|
Write-Fail "probe read failed (exit=$($probe.ExitCode))"
|
||||||
|
Write-Host $probe.Output
|
||||||
|
$results += @{ Passed = $false; Reason = "probe failed" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stage 2 — Source read. Captures the current value for the later virtual-tag
|
||||||
|
# comparison + confirms read dispatch works end-to-end. Failure here without a
|
||||||
|
# stage-1 failure would be unusual — probe already reads.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Write-Header "Source read"
|
||||||
|
$sourceRead = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $SourceNodeId)
|
||||||
|
$sourceValue = $null
|
||||||
|
if ($sourceRead.ExitCode -eq 0 -and $sourceRead.Output -match "Value:\s+([^\r\n]+)") {
|
||||||
|
$sourceValue = $Matches[1].Trim()
|
||||||
|
Write-Pass "source value = $sourceValue"
|
||||||
|
$results += @{ Passed = $true }
|
||||||
|
} else {
|
||||||
|
Write-Fail "source read failed"
|
||||||
|
Write-Host $sourceRead.Output
|
||||||
|
$results += @{ Passed = $false; Reason = "source read failed" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stage 3 — Virtual-tag bridge. Reads the Phase 7 VirtualTag (source × 2). Not
|
||||||
|
# strictly driver-specific, but exercises the CachedTagUpstreamSource bridge
|
||||||
|
# (the seam most likely to silently stop working after a Galaxy-side change).
|
||||||
|
# Skip if the VirtualNodeId param is empty (non-Phase-7 clusters).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if ([string]::IsNullOrEmpty($VirtualNodeId)) {
|
||||||
|
Write-Header "Virtual-tag bridge"
|
||||||
|
Write-Skip "VirtualNodeId not supplied — skipping Phase 7 bridge check"
|
||||||
|
} else {
|
||||||
|
Write-Header "Virtual-tag bridge"
|
||||||
|
$vtRead = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $VirtualNodeId)
|
||||||
|
if ($vtRead.ExitCode -eq 0 -and $vtRead.Output -match "Value:\s+([^\r\n]+)") {
|
||||||
|
$vtValue = $Matches[1].Trim()
|
||||||
|
Write-Pass "virtual-tag value = $vtValue (source was $sourceValue)"
|
||||||
|
$results += @{ Passed = $true }
|
||||||
|
} else {
|
||||||
|
Write-Fail "virtual-tag read failed"
|
||||||
|
Write-Host $vtRead.Output
|
||||||
|
$results += @{ Passed = $false; Reason = "virtual-tag read failed" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stage 4 — Subscribe-sees-change. otopcua-cli subscribe in the background;
|
||||||
|
# wait N seconds for Galaxy to push any data-change event on the source node.
|
||||||
|
# This is optimistic — if the Galaxy attribute is idle, widen -ChangeWaitSec.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Write-Header "Subscribe sees change"
|
||||||
|
$stdout = New-TemporaryFile
|
||||||
|
$stderr = New-TemporaryFile
|
||||||
|
$subArgs = @($opcUaCli.PrefixArgs) + @(
|
||||||
|
"subscribe", "-u", $OpcUaUrl, "-n", $SourceNodeId,
|
||||||
|
"-i", "500", "--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
|
||||||
|
|
||||||
|
# Any `=` followed by `(Good)` line after the initial subscribe-confirmation
|
||||||
|
# indicates at least one data-change tick arrived.
|
||||||
|
$changeLines = ($subOut -split "`n") | Where-Object { $_ -match "=\s+.*\(Good\)" }
|
||||||
|
if ($changeLines.Count -gt 0) {
|
||||||
|
Write-Pass "$($changeLines.Count) data-change events observed"
|
||||||
|
$results += @{ Passed = $true }
|
||||||
|
} else {
|
||||||
|
Write-Fail "no data-change events in ${ChangeWaitSec}s — Galaxy attribute may be idle; rerun with -ChangeWaitSec larger, or trigger a change first"
|
||||||
|
Write-Host $subOut
|
||||||
|
$results += @{ Passed = $false; Reason = "no data-change" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stage 5 — Reverse bridge (OPC UA write → Galaxy). Galaxy attributes with
|
||||||
|
# AccessLevel > FreeAccess often reject anonymous writes; record as INFO when
|
||||||
|
# that's the case rather than failing the whole script.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Write-Header "Reverse bridge (OPC UA write)"
|
||||||
|
$writeValue = [int]$AlarmTriggerValue # reuse the alarm trigger value — two stages for one write
|
||||||
|
$w = Invoke-Cli -Cli $opcUaCli -Args @(
|
||||||
|
"write", "-u", $OpcUaUrl, "-n", $SourceNodeId, "-v", "$writeValue")
|
||||||
|
if ($w.ExitCode -ne 0) {
|
||||||
|
# Connection/protocol failure — still a test failure.
|
||||||
|
Write-Fail "write CLI exit=$($w.ExitCode)"
|
||||||
|
Write-Host $w.Output
|
||||||
|
$results += @{ Passed = $false; Reason = "write failed" }
|
||||||
|
} elseif ($w.Output -match "Write failed:\s*0x801F0000") {
|
||||||
|
Write-Info "BadUserAccessDenied — attribute's Galaxy-side ACL blocks writes for this session. Not a bug; grant WriteOperate or run against a writable attribute."
|
||||||
|
$results += @{ Passed = $true; Reason = "acl-expected" }
|
||||||
|
} elseif ($w.Output -match "Write failed:\s*0x80390000|BadNotWritable") {
|
||||||
|
Write-Info "BadNotWritable — attribute is read-only at the Galaxy layer (status attributes, @-prefixed meta, etc)."
|
||||||
|
$results += @{ Passed = $true; Reason = "readonly-expected" }
|
||||||
|
} elseif ($w.Output -match "Write successful") {
|
||||||
|
# Read back — Galaxy poll interval + MXAccess advise may need a second or two to settle.
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
$r = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $SourceNodeId)
|
||||||
|
if ($r.Output -match "Value:\s+$([Regex]::Escape("$writeValue"))\b") {
|
||||||
|
Write-Pass "write propagated — source reads back $writeValue"
|
||||||
|
$results += @{ Passed = $true }
|
||||||
|
} else {
|
||||||
|
Write-Fail "write reported success but read-back did not reflect $writeValue"
|
||||||
|
Write-Host $r.Output
|
||||||
|
$results += @{ Passed = $false; Reason = "write-readback mismatch" }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Fail "unexpected write response"
|
||||||
|
Write-Host $w.Output
|
||||||
|
$results += @{ Passed = $false; Reason = "unexpected write response" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stage 6 — Alarm fires. Uses the helper from _common.ps1. If stage 5 already
|
||||||
|
# wrote the trigger value the alarm may already be active; that's fine — the
|
||||||
|
# Part 9 ConditionRefresh in the alarms CLI replays the current state so the
|
||||||
|
# subscribe window still captures the Active event.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if ([string]::IsNullOrEmpty($AlarmNodeId)) {
|
||||||
|
Write-Header "Alarm fires on threshold"
|
||||||
|
Write-Skip "AlarmNodeId not supplied — skipping alarm check"
|
||||||
|
} else {
|
||||||
|
$results += Test-AlarmFiresOnThreshold `
|
||||||
|
-OpcUaCli $opcUaCli `
|
||||||
|
-OpcUaUrl $OpcUaUrl `
|
||||||
|
-AlarmNodeId $AlarmNodeId `
|
||||||
|
-InputNodeId $SourceNodeId `
|
||||||
|
-TriggerValue $AlarmTriggerValue `
|
||||||
|
-DurationSec $AlarmWaitSec
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stage 7 — History read. historyread against the source tag over the last N
|
||||||
|
# seconds. Failure modes the skip pattern catches: tag not historized in the
|
||||||
|
# Galaxy attribute's historization profile, or the lookback window misses the
|
||||||
|
# sample cadence.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
$results += Test-HistoryHasSamples `
|
||||||
|
-OpcUaCli $opcUaCli `
|
||||||
|
-OpcUaUrl $OpcUaUrl `
|
||||||
|
-NodeId $SourceNodeId `
|
||||||
|
-LookbackSec $HistoryLookbackSec
|
||||||
|
|
||||||
|
Write-Summary -Title "Galaxy e2e" -Results $results
|
||||||
|
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
||||||
Reference in New Issue
Block a user