diff --git a/.gitignore b/.gitignore index 196445f..9dee879 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ packages/ # LiteDB local config cache (Phase 6.1 Stream D — runtime artifact, not source) src/ZB.MOM.WW.OtOpcUa.Server/config_cache.db + +# E2E sidecar config — NodeIds are specific to each dev's local seed (see scripts/e2e/README.md) +scripts/e2e/e2e-config.json diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 854e97a..61939a2 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -30,6 +30,7 @@ + diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md new file mode 100644 index 0000000..17bd72c --- /dev/null +++ b/scripts/e2e/README.md @@ -0,0 +1,135 @@ +# E2E CLI test scripts + +End-to-end black-box tests that drive each protocol through its driver CLI +and verify the resulting OPC UA address-space state through +`otopcua-cli`. They answer one question per driver: + +> **If I poke the real PLC through the driver, does the running OtOpcUa +> server see the change?** + +This is the acceptance gate v1 was missing — the driver-level integration +tests (`tests/.../IntegrationTests/`) confirm the driver sees the PLC, and +the OPC UA `Client.CLI.Tests` confirm the client sees the server — but +nothing glued them end-to-end. These scripts close that loop. + +## Three-stage test per driver + +Every per-driver script runs the same three tests: + +1. **`probe`** — driver CLI opens a session + reads a sentinel. Confirms + the simulator / PLC is reachable and speaking the protocol. +2. **Driver loopback** — write a random value via the driver CLI, read it + back via the same CLI. Confirms the driver round-trips without + involving the OPC UA server. A failure here is a driver bug, not a + server-bridge bug. +3. **Server bridge** — write a different random value via the driver + CLI, wait `--ServerPollDelaySec` (default 3s), read the OPC UA NodeId + the server publishes that tag at via `otopcua-cli read`. Confirms the + full path: driver CLI → PLC → OtOpcUa server → OPC UA client. + +The OtOpcUa server must already be running with a config that +(a) binds a driver instance to the same PLC the script points at, and +(b) publishes the address the script writes under a NodeId the script +knows. Those NodeIds live in `e2e-config.json` (see below). + +## Prereqs + +1. **OtOpcUa server** running on `opc.tcp://localhost:4840` (or pass + `-OpcUaUrl` to override). The server's Config DB must define a + driver instance per protocol you want to test, bound to the matching + simulator endpoint. +2. **Per-driver simulators** running. See `docs/v2/test-data-sources.md` + 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. +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 + src/ZB.MOM.WW.OtOpcUa.Driver..Cli` directly, or if + `$env:OTOPCUA_CLI_BIN` points at a publish folder, runs the pre-built + `otopcua-*.exe` from there (faster for repeat loops). + +## Running + +### One protocol at a time + +```powershell +./scripts/e2e/test-modbus.ps1 ` + -ModbusHost 127.0.0.1:5502 ` + -BridgeNodeId "ns=2;s=Modbus/HR100" +``` + +Every per-protocol script takes the driver endpoint, the address to +write, and the OPC UA NodeId the server exposes it at. + +### Full matrix + +```powershell +./scripts/e2e/test-all.ps1 ` + -ConfigFile ./scripts/e2e/e2e-config.json +``` + +The runner reads the sidecar JSON, invokes each driver's script with the +parameters from that section, and prints a `FINAL MATRIX` showing +PASS / FAIL / SKIP per driver. Any driver absent from the sidecar is +SKIP-ed rather than failing hard — useful on dev boxes that only have +one simulator up. + +### Sidecar format + +Copy `e2e-config.sample.json` → `e2e-config.json` and fill in the +NodeIds from **your** server's Config DB. The file is `.gitignore`-d +(each dev's NodeIds are specific to their local seed). Omit a driver +section to skip it. + +## Expected pass/fail matrix (default config) + +| Driver | Gate | Default state on a clean dev box | +|---|---|---| +| Modbus | — | **PASS** (pymodbus fixture) | +| AB CIP | — | **PASS** (ab_server fixture) | +| AB Legacy | `AB_LEGACY_TRUST_WIRE=1` | **SKIP** (ab_server PCCC path upstream-broken — task #222) | +| 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) | +| 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 +real hardware or a properly-configured simulator. + +## Output + +Each step prints one of: + +- `[PASS] ...` — step succeeded +- `[FAIL] ...` — step failed, stdout of the failing CLI is echoed below + for diagnosis +- `[SKIP] ...` — step short-circuited (env-var gate) +- `[INFO] ...` — progress note (e.g., "waiting 3s for server-side poll") + +The runner ends with a coloured summary per driver: + +``` +==================== FINAL MATRIX ==================== + modbus PASS + abcip PASS + ablegacy SKIP (no config entry) + s7 PASS + focas SKIP (no config entry) + twincat SKIP (no config entry) + phase7 PASS +All present suites passed. +``` + +Non-zero exit if any present suite failed. SKIPs do not fail the run. + +## Why this is separate from `dotnet test` + +`dotnet test` covers driver-layer + server-layer correctness in +isolation — mocks + in-process test hosts. These e2e scripts cover the +integration seam that unit tests *can't* cover by design: a live OPC UA +server process, a live simulator, and the wire between them. Run them +before a v2 release-readiness sign-off, after a driver-layer change +that could plausibly affect the NodeManager contract, and before any +"it works on my box" handoff to QA. diff --git a/scripts/e2e/_common.ps1 b/scripts/e2e/_common.ps1 new file mode 100644 index 0000000..1a6b17f --- /dev/null +++ b/scripts/e2e/_common.ps1 @@ -0,0 +1,219 @@ +# Shared PowerShell helpers for the OtOpcUa end-to-end CLI test scripts. +# +# Every per-protocol script dot-sources this file and calls the Test-* functions +# below. Keeps the per-script code down to ~50 lines of parameterisation + +# bridging-tag identifiers. +# +# Conventions: +# - All test helpers return a hashtable: @{ Passed=; Reason= } +# - Helpers never throw unless the test setup is itself broken (a crashed +# CLI is a test failure, not an exception). +# - Output is plain text with [PASS] / [FAIL] / [SKIP] / [INFO] prefixes so +# grep/log-scraping works. + +Set-StrictMode -Version 3.0 + +# --------------------------------------------------------------------------- +# Colouring + prefixes. +# --------------------------------------------------------------------------- + +function Write-Header { + param([string]$Title) + Write-Host "" + Write-Host "=== $Title ===" -ForegroundColor Cyan +} + +function Write-Pass { + param([string]$Message) + Write-Host "[PASS] $Message" -ForegroundColor Green +} + +function Write-Fail { + param([string]$Message) + Write-Host "[FAIL] $Message" -ForegroundColor Red +} + +function Write-Skip { + param([string]$Message) + Write-Host "[SKIP] $Message" -ForegroundColor Yellow +} + +function Write-Info { + param([string]$Message) + Write-Host "[INFO] $Message" -ForegroundColor Gray +} + +# --------------------------------------------------------------------------- +# CLI invocation helpers. +# --------------------------------------------------------------------------- + +# Resolve a CLI path from either a published binary OR a `dotnet run` fallback. +# Preferred order: +# 1. $env:OTOPCUA_CLI_BIN points at a publish/ folder → use there +# 2. Fall back to `dotnet run --project src/ --` +# +# $ProjectFolder = relative path from repo root +# $ExeName = expected AssemblyName (no .exe) +function Get-CliInvocation { + param( + [Parameter(Mandatory)] [string]$ProjectFolder, + [Parameter(Mandatory)] [string]$ExeName + ) + + if ($env:OTOPCUA_CLI_BIN) { + $binPath = Join-Path $env:OTOPCUA_CLI_BIN "$ExeName.exe" + if (Test-Path $binPath) { + return @{ File = $binPath; PrefixArgs = @() } + } + } + + # Dotnet-run fallback. --no-build would be faster but not every CI step + # has rebuilt; default to a full run so the script is forgiving. + return @{ + File = "dotnet" + PrefixArgs = @("run", "--project", $ProjectFolder, "--") + } +} + +# Run a CLI and capture stdout+stderr+exitcode. Never throws. +function Invoke-Cli { + param( + [Parameter(Mandatory)] $Cli, # output of Get-CliInvocation + [Parameter(Mandatory)] [string[]]$Args, # CLI arguments (after `-- `) + [int]$TimeoutSec = 30 + ) + + $allArgs = @($Cli.PrefixArgs) + $Args + $output = $null + $exitCode = -1 + + try { + $output = & $Cli.File @allArgs 2>&1 | Out-String + $exitCode = $LASTEXITCODE + } + catch { + return @{ + Output = $_.Exception.Message + ExitCode = -1 + } + } + + return @{ + Output = $output + ExitCode = $exitCode + } +} + +# --------------------------------------------------------------------------- +# Test helpers — reusable building blocks every per-protocol script calls. +# --------------------------------------------------------------------------- + +# Test 1 — the driver CLI's probe command exits 0. Confirms the PLC / simulator +# is reachable and speaks the protocol. Prerequisite for everything else. +function Test-Probe { + param( + [Parameter(Mandatory)] $Cli, + [Parameter(Mandatory)] [string[]]$ProbeArgs + ) + Write-Header "Probe" + $r = Invoke-Cli -Cli $Cli -Args $ProbeArgs + if ($r.ExitCode -eq 0) { + Write-Pass "driver CLI probe succeeded" + return @{ Passed = $true } + } + Write-Fail "driver CLI probe exit=$($r.ExitCode)" + Write-Host $r.Output + return @{ Passed = $false; Reason = "probe exit $($r.ExitCode)" } +} + +# Test 2 — driver-loopback. Write a value via the driver CLI, read it back via +# the same CLI, assert round-trip equality. Confirms the driver itself is +# functional without pulling the OtOpcUa server into the loop. +function Test-DriverLoopback { + param( + [Parameter(Mandatory)] $Cli, + [Parameter(Mandatory)] [string[]]$WriteArgs, + [Parameter(Mandatory)] [string[]]$ReadArgs, + [Parameter(Mandatory)] [string]$ExpectedValue + ) + Write-Header "Driver loopback" + + $w = Invoke-Cli -Cli $Cli -Args $WriteArgs + if ($w.ExitCode -ne 0) { + Write-Fail "write failed (exit=$($w.ExitCode))" + Write-Host $w.Output + return @{ Passed = $false; Reason = "write failed" } + } + Write-Info "write ok" + + $r = Invoke-Cli -Cli $Cli -Args $ReadArgs + if ($r.ExitCode -ne 0) { + Write-Fail "read failed (exit=$($r.ExitCode))" + Write-Host $r.Output + return @{ Passed = $false; Reason = "read failed" } + } + + if ($r.Output -match "Value:\s+$([Regex]::Escape($ExpectedValue))\b") { + Write-Pass "round-trip equals $ExpectedValue" + return @{ Passed = $true } + } + Write-Fail "round-trip value mismatch — expected $ExpectedValue" + Write-Host $r.Output + return @{ Passed = $false; Reason = "value mismatch" } +} + +# Test 3 — server bridge. Write via the driver CLI, read the corresponding +# OPC UA NodeId via the OPC UA client CLI. Confirms the full path: +# driver CLI → PLC → OtOpcUa server (polling/subscription) → OPC UA client. +function Test-ServerBridge { + param( + [Parameter(Mandatory)] $DriverCli, + [Parameter(Mandatory)] [string[]]$DriverWriteArgs, + [Parameter(Mandatory)] $OpcUaCli, + [Parameter(Mandatory)] [string]$OpcUaUrl, + [Parameter(Mandatory)] [string]$OpcUaNodeId, + [Parameter(Mandatory)] [string]$ExpectedValue, + [int]$ServerPollDelaySec = 3 + ) + Write-Header "Server bridge" + + $w = Invoke-Cli -Cli $DriverCli -Args $DriverWriteArgs + if ($w.ExitCode -ne 0) { + Write-Fail "driver-side write failed (exit=$($w.ExitCode))" + Write-Host $w.Output + return @{ Passed = $false; Reason = "driver write failed" } + } + Write-Info "driver write ok, waiting ${ServerPollDelaySec}s for server-side poll" + Start-Sleep -Seconds $ServerPollDelaySec + + $r = Invoke-Cli -Cli $OpcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $OpcUaNodeId) + if ($r.ExitCode -ne 0) { + Write-Fail "OPC UA client read failed (exit=$($r.ExitCode))" + Write-Host $r.Output + return @{ Passed = $false; Reason = "opc-ua read failed" } + } + + if ($r.Output -match "Value:\s+$([Regex]::Escape($ExpectedValue))\b") { + Write-Pass "server-side read equals $ExpectedValue" + return @{ Passed = $true } + } + Write-Fail "server-side value mismatch — expected $ExpectedValue" + Write-Host $r.Output + return @{ Passed = $false; Reason = "bridge value mismatch" } +} + +# --------------------------------------------------------------------------- +# Summary helper — caller passes an array of test results. +# --------------------------------------------------------------------------- + +function Write-Summary { + param( + [Parameter(Mandatory)] [string]$Title, + [Parameter(Mandatory)] [array]$Results + ) + $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" }) +} diff --git a/scripts/e2e/e2e-config.sample.json b/scripts/e2e/e2e-config.sample.json new file mode 100644 index 0000000..e7f5ae1 --- /dev/null +++ b/scripts/e2e/e2e-config.sample.json @@ -0,0 +1,56 @@ +{ + "$comment": "Copy this file to e2e-config.json and replace the NodeIds with the ones your Config DB publishes. Fields named `opcUaUrl` override the -OpcUaUrl parameter on test-all.ps1 per-driver. Omit a top-level key to skip that driver.", + + "modbus": { + "endpoint": "127.0.0.1:5502", + "bridgeNodeId": "ns=2;s=Modbus/HR100", + "opcUaUrl": "opc.tcp://localhost:4840" + }, + + "abcip": { + "gateway": "ab://127.0.0.1/1,0", + "family": "ControlLogix", + "tagPath": "TestDINT", + "bridgeNodeId": "ns=2;s=AbCip/TestDINT" + }, + + "ablegacy": { + "$comment": "Gated behind AB_LEGACY_TRUST_WIRE=1 — ab_server PCCC path upstream-broken, needs real SLC / MicroLogix / PLC-5 or RSEmulate 500.", + "gateway": "ab://192.168.1.10/1,0", + "plcType": "Slc500", + "address": "N7:5", + "bridgeNodeId": "ns=2;s=AbLegacy/N7_5" + }, + + "s7": { + "endpoint": "127.0.0.1:102", + "cpu": "S71500", + "slot": 0, + "address": "DB1.DBW0", + "bridgeNodeId": "ns=2;s=S7/DB1_DBW0" + }, + + "focas": { + "$comment": "Gated behind FOCAS_TRUST_WIRE=1 — no public simulator. Point at a real CNC + ensure Fwlib32.dll is on PATH.", + "host": "192.168.1.20", + "port": 8193, + "address": "R100", + "bridgeNodeId": "ns=2;s=Focas/R100" + }, + + "twincat": { + "$comment": "Gated behind TWINCAT_TRUST_WIRE=1 — needs XAR or standalone TwinCAT Router NuGet reachable at -AmsNetId.", + "amsNetId": "127.0.0.1.1.1", + "amsPort": 851, + "symbolPath": "MAIN.iCounter", + "bridgeNodeId": "ns=2;s=TwinCAT/MAIN_iCounter" + }, + + "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.", + "modbusEndpoint": "127.0.0.1:5502", + "inputNodeId": "ns=2;s=Modbus/HR100", + "virtualNodeId": "ns=2;s=Virtual/VT_DoubledHR100", + "alarmNodeId": "ns=2;s=Alarm/HR100_High" + } +} diff --git a/scripts/e2e/test-abcip.ps1 b/scripts/e2e/test-abcip.ps1 new file mode 100644 index 0000000..83a5c35 --- /dev/null +++ b/scripts/e2e/test-abcip.ps1 @@ -0,0 +1,85 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + End-to-end CLI test for the AB CIP driver (ControlLogix / CompactLogix / + Micro800 / GuardLogix) bridged through the OtOpcUa server. + +.DESCRIPTION + Mirrors test-modbus.ps1 but against libplctag's ab_server (or a real Logix + controller). Three assertions: probe / driver-loopback / server-bridge. + + Prereqs: + - ab_server container up (tests/.../AbCip.IntegrationTests/Docker/docker-compose.yml, + --profile controllogix) OR a real PLC on the network. + - OtOpcUa server running with an AB CIP DriverInstance pointing at the + same gateway + a Tag published at the -BridgeNodeId you pass. + +.PARAMETER Gateway + ab://host[:port]/cip-path. Default ab://127.0.0.1/1,0 (ab_server ControlLogix). + +.PARAMETER Family + ControlLogix / CompactLogix / Micro800 / GuardLogix (default ControlLogix). + +.PARAMETER TagPath + Logix symbolic path to exercise. Default 'TestDINT' — matches the ab_server + --tag=TestDINT:DINT[1] seed. + +.PARAMETER OpcUaUrl + OtOpcUa server endpoint. + +.PARAMETER BridgeNodeId + NodeId at which the server publishes the TagPath. +#> + +param( + [string]$Gateway = "ab://127.0.0.1/1,0", + [string]$Family = "ControlLogix", + [string]$TagPath = "TestDINT", + [string]$OpcUaUrl = "opc.tcp://localhost:4840", + [Parameter(Mandatory)] [string]$BridgeNodeId +) + +$ErrorActionPreference = "Stop" +. "$PSScriptRoot/_common.ps1" + +$abcipCli = Get-CliInvocation ` + -ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli" ` + -ExeName "otopcua-abcip-cli" +$opcUaCli = Get-CliInvocation ` + -ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" ` + -ExeName "otopcua-cli" + +$commonAbCip = @("-g", $Gateway, "-f", $Family) +$results = @() + +# Probe via @raw_cpu_type for ControlLogix; against ab_server this surfaces the +# string 'Controllogix'. Other families use the main TestDINT. +$probeTag = if ($Family -eq "ControlLogix" -or $Family -eq "CompactLogix" -or $Family -eq "GuardLogix") { + "@raw_cpu_type" +} else { + $TagPath +} +$probeType = if ($probeTag -eq "@raw_cpu_type") { "String" } else { "DInt" } + +$results += Test-Probe ` + -Cli $abcipCli ` + -ProbeArgs (@("probe") + $commonAbCip + @("-t", $probeTag, "--type", $probeType)) + +$writeValue = Get-Random -Minimum 1 -Maximum 9999 +$results += Test-DriverLoopback ` + -Cli $abcipCli ` + -WriteArgs (@("write") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "-v", $writeValue)) ` + -ReadArgs (@("read") + $commonAbCip + @("-t", $TagPath, "--type", "DInt")) ` + -ExpectedValue "$writeValue" + +$bridgeValue = Get-Random -Minimum 10000 -Maximum 19999 +$results += Test-ServerBridge ` + -DriverCli $abcipCli ` + -DriverWriteArgs (@("write") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "-v", $bridgeValue)) ` + -OpcUaCli $opcUaCli ` + -OpcUaUrl $OpcUaUrl ` + -OpcUaNodeId $BridgeNodeId ` + -ExpectedValue "$bridgeValue" + +Write-Summary -Title "AB CIP e2e" -Results $results +if ($results | Where-Object { -not $_.Passed }) { exit 1 } diff --git a/scripts/e2e/test-ablegacy.ps1 b/scripts/e2e/test-ablegacy.ps1 new file mode 100644 index 0000000..2a8aa68 --- /dev/null +++ b/scripts/e2e/test-ablegacy.ps1 @@ -0,0 +1,80 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + End-to-end CLI test for the AB Legacy (PCCC) driver. + +.DESCRIPTION + **KNOWN-BROKEN upstream (ab_server PCCC dispatcher gap, verified 2026-04-21).** + Works against real SLC / MicroLogix / PLC-5 hardware or a RSEmulate 500 + golden-box. Against the Docker ab_server the tests deliberately skip — + same gate as tests/.../AbLegacy.IntegrationTests (AB_LEGACY_TRUST_WIRE=1). + + Three assertions: probe / driver-loopback / server-bridge. + +.PARAMETER Gateway + ab://host[:port]/cip-path. Default ab://127.0.0.1/1,0. + +.PARAMETER PlcType + Slc500 / MicroLogix / Plc5 / LogixPccc (default Slc500). + +.PARAMETER Address + PCCC address to exercise. Default N7:5. + +.PARAMETER OpcUaUrl + OtOpcUa server endpoint. + +.PARAMETER BridgeNodeId + NodeId at which the server publishes the Address. +#> + +param( + [string]$Gateway = "ab://127.0.0.1/1,0", + [string]$PlcType = "Slc500", + [string]$Address = "N7:5", + [string]$OpcUaUrl = "opc.tcp://localhost:4840", + [Parameter(Mandatory)] [string]$BridgeNodeId +) + +$ErrorActionPreference = "Stop" +. "$PSScriptRoot/_common.ps1" + +# Skip-gate: the driver CLI's underlying AbLegacyServerFixture-equivalent +# check — operators point at real hardware by setting AB_LEGACY_TRUST_WIRE=1. +# Without the opt-in we skip (don't run against the known-broken ab_server). +if (-not ($env:AB_LEGACY_TRUST_WIRE -eq "1" -or $env:AB_LEGACY_TRUST_WIRE -eq "true")) { + Write-Skip "AB_LEGACY_TRUST_WIRE not set — skipping (ab_server PCCC is upstream-broken; set =1 against real hardware / RSEmulate)." + exit 0 +} + +$abLegacyCli = Get-CliInvocation ` + -ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli" ` + -ExeName "otopcua-ablegacy-cli" +$opcUaCli = Get-CliInvocation ` + -ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" ` + -ExeName "otopcua-cli" + +$commonAbLegacy = @("-g", $Gateway, "-P", $PlcType) +$results = @() + +$results += Test-Probe ` + -Cli $abLegacyCli ` + -ProbeArgs (@("probe") + $commonAbLegacy + @("-a", "N7:0")) + +$writeValue = Get-Random -Minimum 1 -Maximum 9999 +$results += Test-DriverLoopback ` + -Cli $abLegacyCli ` + -WriteArgs (@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", $writeValue)) ` + -ReadArgs (@("read") + $commonAbLegacy + @("-a", $Address, "-t", "Int")) ` + -ExpectedValue "$writeValue" + +$bridgeValue = Get-Random -Minimum 10000 -Maximum 19999 +$results += Test-ServerBridge ` + -DriverCli $abLegacyCli ` + -DriverWriteArgs (@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", $bridgeValue)) ` + -OpcUaCli $opcUaCli ` + -OpcUaUrl $OpcUaUrl ` + -OpcUaNodeId $BridgeNodeId ` + -ExpectedValue "$bridgeValue" + +Write-Summary -Title "AB Legacy e2e" -Results $results +if ($results | Where-Object { -not $_.Passed }) { exit 1 } diff --git a/scripts/e2e/test-all.ps1 b/scripts/e2e/test-all.ps1 new file mode 100644 index 0000000..a57c2f9 --- /dev/null +++ b/scripts/e2e/test-all.ps1 @@ -0,0 +1,192 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Runs every scripts/e2e/test-*.ps1 and tallies PASS / FAIL / SKIP. + +.DESCRIPTION + The per-protocol scripts require protocol-specific NodeIds that depend on + your server's config DB seed. This runner expects a JSON sidecar at + scripts/e2e/e2e-config.json (not checked in — see README) with one entry + per driver giving the NodeIds + endpoints to pass through. Any driver + missing from the sidecar is skipped with a clear message rather than + failing hard. + +.PARAMETER ConfigFile + Path to the sidecar JSON. Default: scripts/e2e/e2e-config.json. + +.PARAMETER OpcUaUrl + Default OPC UA endpoint passed to each per-driver script. Default + opc.tcp://localhost:4840. Individual entries in the config file can override. +#> + +param( + [string]$ConfigFile = "$PSScriptRoot/e2e-config.json", + [string]$OpcUaUrl = "opc.tcp://localhost:4840" +) + +$ErrorActionPreference = "Stop" +. "$PSScriptRoot/_common.ps1" + +if (-not (Test-Path $ConfigFile)) { + Write-Fail "no config at $ConfigFile — copy e2e-config.sample.json + fill in your NodeIds first (see README)" + exit 2 +} + +$config = Get-Content $ConfigFile -Raw | ConvertFrom-Json +$summary = [ordered]@{} + +function Run-Suite { + param( + [string]$Name, + [scriptblock]$Action + ) + try { + & $Action + $summary[$Name] = if ($LASTEXITCODE -eq 0) { "PASS" } else { "FAIL" } + } + catch { + Write-Fail "$Name runner crashed: $_" + $summary[$Name] = "FAIL" + } +} + +# --------------------------------------------------------------------------- +# Modbus +# --------------------------------------------------------------------------- + +if ($config.modbus) { + Write-Header "== MODBUS ==" + Run-Suite "modbus" { + & "$PSScriptRoot/test-modbus.ps1" ` + -ModbusHost $config.modbus.endpoint ` + -OpcUaUrl ($config.modbus.opcUaUrl ?? $OpcUaUrl) ` + -BridgeNodeId $config.modbus.bridgeNodeId + } +} +else { $summary["modbus"] = "SKIP (no config entry)" } + +# --------------------------------------------------------------------------- +# AB CIP +# --------------------------------------------------------------------------- + +if ($config.abcip) { + Write-Header "== AB CIP ==" + Run-Suite "abcip" { + & "$PSScriptRoot/test-abcip.ps1" ` + -Gateway $config.abcip.gateway ` + -Family ($config.abcip.family ?? "ControlLogix") ` + -TagPath ($config.abcip.tagPath ?? "TestDINT") ` + -OpcUaUrl ($config.abcip.opcUaUrl ?? $OpcUaUrl) ` + -BridgeNodeId $config.abcip.bridgeNodeId + } +} +else { $summary["abcip"] = "SKIP (no config entry)" } + +# --------------------------------------------------------------------------- +# AB Legacy +# --------------------------------------------------------------------------- + +if ($config.ablegacy) { + Write-Header "== AB LEGACY ==" + Run-Suite "ablegacy" { + & "$PSScriptRoot/test-ablegacy.ps1" ` + -Gateway $config.ablegacy.gateway ` + -PlcType ($config.ablegacy.plcType ?? "Slc500") ` + -Address ($config.ablegacy.address ?? "N7:5") ` + -OpcUaUrl ($config.ablegacy.opcUaUrl ?? $OpcUaUrl) ` + -BridgeNodeId $config.ablegacy.bridgeNodeId + } +} +else { $summary["ablegacy"] = "SKIP (no config entry)" } + +# --------------------------------------------------------------------------- +# S7 +# --------------------------------------------------------------------------- + +if ($config.s7) { + Write-Header "== S7 ==" + Run-Suite "s7" { + & "$PSScriptRoot/test-s7.ps1" ` + -S7Host $config.s7.endpoint ` + -Cpu ($config.s7.cpu ?? "S71500") ` + -Slot ($config.s7.slot ?? 0) ` + -Address ($config.s7.address ?? "DB1.DBW0") ` + -OpcUaUrl ($config.s7.opcUaUrl ?? $OpcUaUrl) ` + -BridgeNodeId $config.s7.bridgeNodeId + } +} +else { $summary["s7"] = "SKIP (no config entry)" } + +# --------------------------------------------------------------------------- +# FOCAS +# --------------------------------------------------------------------------- + +if ($config.focas) { + Write-Header "== FOCAS ==" + Run-Suite "focas" { + & "$PSScriptRoot/test-focas.ps1" ` + -CncHost $config.focas.host ` + -CncPort ($config.focas.port ?? 8193) ` + -Address ($config.focas.address ?? "R100") ` + -OpcUaUrl ($config.focas.opcUaUrl ?? $OpcUaUrl) ` + -BridgeNodeId $config.focas.bridgeNodeId + } +} +else { $summary["focas"] = "SKIP (no config entry)" } + +# --------------------------------------------------------------------------- +# TwinCAT +# --------------------------------------------------------------------------- + +if ($config.twincat) { + Write-Header "== TWINCAT ==" + Run-Suite "twincat" { + & "$PSScriptRoot/test-twincat.ps1" ` + -AmsNetId $config.twincat.amsNetId ` + -AmsPort ($config.twincat.amsPort ?? 851) ` + -SymbolPath ($config.twincat.symbolPath ?? "MAIN.iCounter") ` + -OpcUaUrl ($config.twincat.opcUaUrl ?? $OpcUaUrl) ` + -BridgeNodeId $config.twincat.bridgeNodeId + } +} +else { $summary["twincat"] = "SKIP (no config entry)" } + +# --------------------------------------------------------------------------- +# Phase 7 virtual tags + scripted alarms +# --------------------------------------------------------------------------- + +if ($config.phase7) { + Write-Header "== PHASE 7 virtual tags + scripted alarms ==" + Run-Suite "phase7" { + & "$PSScriptRoot/test-phase7-virtualtags.ps1" ` + -ModbusHost ($config.phase7.modbusEndpoint ?? $config.modbus.endpoint) ` + -OpcUaUrl ($config.phase7.opcUaUrl ?? $OpcUaUrl) ` + -InputNodeId $config.phase7.inputNodeId ` + -VirtualNodeId $config.phase7.virtualNodeId ` + -AlarmNodeId ($config.phase7.alarmNodeId ?? $null) + } +} +else { $summary["phase7"] = "SKIP (no config entry)" } + +# --------------------------------------------------------------------------- +# Final matrix +# --------------------------------------------------------------------------- + +Write-Host "" +Write-Host "==================== FINAL MATRIX ====================" -ForegroundColor Cyan +$summary.GetEnumerator() | ForEach-Object { + $color = switch -Wildcard ($_.Value) { + "PASS" { "Green" } + "FAIL" { "Red" } + "SKIP*" { "Yellow" } + default { "Gray" } + } + Write-Host (" {0,-10} {1}" -f $_.Key, $_.Value) -ForegroundColor $color +} + +$failed = ($summary.Values | Where-Object { $_ -eq "FAIL" }).Count +if ($failed -gt 0) { + Write-Host "$failed suite(s) failed." -ForegroundColor Red + exit 1 +} +Write-Host "All present suites passed." -ForegroundColor Green diff --git a/scripts/e2e/test-focas.ps1 b/scripts/e2e/test-focas.ps1 new file mode 100644 index 0000000..b5e61ae --- /dev/null +++ b/scripts/e2e/test-focas.ps1 @@ -0,0 +1,78 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + End-to-end CLI test for the FOCAS (Fanuc CNC) driver. + +.DESCRIPTION + **Hardware-gated.** There is no public FOCAS simulator; the driver's + FwlibFocasClient P/Invokes Fanuc's licensed Fwlib32.dll. Against a dev + box without the DLL on PATH the test will skip with a clear message. + Against a real CNC with the DLL present it runs probe / driver-loopback / + server-bridge the same way the other scripts do. + + Set FOCAS_TRUST_WIRE=1 when -CncHost points at a real CNC to un-gate. + +.PARAMETER CncHost + IP or hostname of the CNC. Default 127.0.0.1 — override for real runs. + +.PARAMETER CncPort + FOCAS TCP port. Default 8193. + +.PARAMETER Address + FOCAS address to exercise. Default R100 (PMC R-file register). + +.PARAMETER OpcUaUrl + OtOpcUa server endpoint. + +.PARAMETER BridgeNodeId + NodeId at which the server publishes the Address. +#> + +param( + [string]$CncHost = "127.0.0.1", + [int]$CncPort = 8193, + [string]$Address = "R100", + [string]$OpcUaUrl = "opc.tcp://localhost:4840", + [Parameter(Mandatory)] [string]$BridgeNodeId +) + +$ErrorActionPreference = "Stop" +. "$PSScriptRoot/_common.ps1" + +if (-not ($env:FOCAS_TRUST_WIRE -eq "1" -or $env:FOCAS_TRUST_WIRE -eq "true")) { + Write-Skip "FOCAS_TRUST_WIRE not set — no public simulator exists (task #222 tracks the lab rig). Set =1 when -CncHost points at a real CNC with Fwlib32.dll on PATH." + exit 0 +} + +$focasCli = Get-CliInvocation ` + -ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli" ` + -ExeName "otopcua-focas-cli" +$opcUaCli = Get-CliInvocation ` + -ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" ` + -ExeName "otopcua-cli" + +$commonFocas = @("-h", $CncHost, "-p", $CncPort) +$results = @() + +$results += Test-Probe ` + -Cli $focasCli ` + -ProbeArgs (@("probe") + $commonFocas + @("-a", $Address, "--type", "Int16")) + +$writeValue = Get-Random -Minimum 1 -Maximum 9999 +$results += Test-DriverLoopback ` + -Cli $focasCli ` + -WriteArgs (@("write") + $commonFocas + @("-a", $Address, "-t", "Int16", "-v", $writeValue)) ` + -ReadArgs (@("read") + $commonFocas + @("-a", $Address, "-t", "Int16")) ` + -ExpectedValue "$writeValue" + +$bridgeValue = Get-Random -Minimum 10000 -Maximum 19999 +$results += Test-ServerBridge ` + -DriverCli $focasCli ` + -DriverWriteArgs (@("write") + $commonFocas + @("-a", $Address, "-t", "Int16", "-v", $bridgeValue)) ` + -OpcUaCli $opcUaCli ` + -OpcUaUrl $OpcUaUrl ` + -OpcUaNodeId $BridgeNodeId ` + -ExpectedValue "$bridgeValue" + +Write-Summary -Title "FOCAS e2e" -Results $results +if ($results | Where-Object { -not $_.Passed }) { exit 1 } diff --git a/scripts/e2e/test-modbus.ps1 b/scripts/e2e/test-modbus.ps1 new file mode 100644 index 0000000..9d57b95 --- /dev/null +++ b/scripts/e2e/test-modbus.ps1 @@ -0,0 +1,74 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + End-to-end CLI test for the Modbus-TCP driver bridged through the OtOpcUa server. + +.DESCRIPTION + Three assertions: + 1. `otopcua-modbus-cli probe` hits the simulator + 2. Driver-loopback write + read-back via modbus-cli + 3. Bridge: modbus-cli writes HR[100], OPC UA client reads the bridged NodeId + + Requires a running Modbus simulator on localhost:5502 (the pymodbus fixture + default per docs/drivers/Modbus-Test-Fixture.md) and a running OtOpcUa server + whose config DB has a Modbus DriverInstance bound to that simulator + a Tag + at HR[100] Int16 published under the NodeId passed via -BridgeNodeId. + +.PARAMETER ModbusHost + Host:port of the Modbus simulator. Default 127.0.0.1:5502. + +.PARAMETER OpcUaUrl + Endpoint URL of the OtOpcUa server. Default opc.tcp://localhost:4840. + +.PARAMETER BridgeNodeId + OPC UA NodeId the OtOpcUa server publishes the HR[100] tag at. Set per your + server config — e.g. 'ns=2;s=/warsaw/modbus-sim/HR_100'. Required. + +.EXAMPLE + .\test-modbus.ps1 -BridgeNodeId "ns=2;s=/warsaw/modbus-sim/HR_100" +#> + +param( + [string]$ModbusHost = "127.0.0.1:5502", + [string]$OpcUaUrl = "opc.tcp://localhost:4840", + [Parameter(Mandatory)] [string]$BridgeNodeId +) + +$ErrorActionPreference = "Stop" +. "$PSScriptRoot/_common.ps1" + +$hostPart, $portPart = $ModbusHost.Split(":") +$port = [int]$portPart + +$modbusCli = Get-CliInvocation ` + -ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli" ` + -ExeName "otopcua-modbus-cli" +$opcUaCli = Get-CliInvocation ` + -ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" ` + -ExeName "otopcua-cli" + +$commonModbus = @("-h", $hostPart, "-p", $port) +$results = @() + +$results += Test-Probe ` + -Cli $modbusCli ` + -ProbeArgs (@("probe") + $commonModbus) + +$writeValue = Get-Random -Minimum 1 -Maximum 9999 +$results += Test-DriverLoopback ` + -Cli $modbusCli ` + -WriteArgs (@("write") + $commonModbus + @("-r", "HoldingRegisters", "-a", "100", "-t", "UInt16", "-v", $writeValue)) ` + -ReadArgs (@("read") + $commonModbus + @("-r", "HoldingRegisters", "-a", "100", "-t", "UInt16")) ` + -ExpectedValue "$writeValue" + +$bridgeValue = Get-Random -Minimum 10000 -Maximum 19999 +$results += Test-ServerBridge ` + -DriverCli $modbusCli ` + -DriverWriteArgs (@("write") + $commonModbus + @("-r", "HoldingRegisters", "-a", "100", "-t", "UInt16", "-v", $bridgeValue)) ` + -OpcUaCli $opcUaCli ` + -OpcUaUrl $OpcUaUrl ` + -OpcUaNodeId $BridgeNodeId ` + -ExpectedValue "$bridgeValue" + +Write-Summary -Title "Modbus e2e" -Results $results +if ($results | Where-Object { -not $_.Passed }) { exit 1 } diff --git a/scripts/e2e/test-phase7-virtualtags.ps1 b/scripts/e2e/test-phase7-virtualtags.ps1 new file mode 100644 index 0000000..e0b490d --- /dev/null +++ b/scripts/e2e/test-phase7-virtualtags.ps1 @@ -0,0 +1,156 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + End-to-end test for Phase 7 virtual tags + scripted alarms, driven via the + Modbus CLI. + +.DESCRIPTION + Assumes the OtOpcUa server's config DB has this Phase 7 scaffolding: + + 1. A Modbus DriverInstance bound to -ModbusHost, with a Tag at HR[100] + as UInt16 published under -InputNodeId. + 2. A VirtualTag `VT_DoubledHR100` = `double(input)` where input is + HR[100], published under -VirtualNodeId. + 3. A ScriptedAlarm `Alarm_HighHR100` that fires when VT_DoubledHR100 > 100, + published so the client can subscribe to AlarmConditionType events. + + Three assertions: + 1. Virtual-tag bridge — modbus-cli writes HR[100]=21, OPC UA client reads + VirtualNodeId + expects 42. + 2. Alarm fire — modbus-cli writes HR[100]=60 (VT=120, above threshold), + OPC UA client alarms subscribe sees the condition go Active. + 3. Alarm clear — modbus-cli writes HR[100]=10 (VT=20, below threshold), + OPC UA client sees the condition go back to Inactive. + + See scripts/smoke/seed-phase-7-smoke.sql for the seed shape. This script + doesn't seed; it verifies the running state. + +.PARAMETER ModbusHost + Modbus simulator endpoint. Default 127.0.0.1:5502. + +.PARAMETER OpcUaUrl + OtOpcUa server endpoint. + +.PARAMETER InputNodeId + NodeId at which the server publishes HR[100] (the input tag). + +.PARAMETER VirtualNodeId + NodeId at which the server publishes VT_DoubledHR100. + +.PARAMETER AlarmNodeId + NodeId of the AlarmConditionType (or its source) the server publishes for + Alarm_HighHR100. Alarms subscribe filters by SourceNode = this NodeId. +#> + +param( + [string]$ModbusHost = "127.0.0.1:5502", + [string]$OpcUaUrl = "opc.tcp://localhost:4840", + [Parameter(Mandatory)] [string]$InputNodeId, + [Parameter(Mandatory)] [string]$VirtualNodeId, + [string]$AlarmNodeId +) + +$ErrorActionPreference = "Stop" +. "$PSScriptRoot/_common.ps1" + +$hostPart, $portPart = $ModbusHost.Split(":") +$port = [int]$portPart + +$modbusCli = Get-CliInvocation ` + -ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli" ` + -ExeName "otopcua-modbus-cli" +$opcUaCli = Get-CliInvocation ` + -ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" ` + -ExeName "otopcua-cli" + +$commonModbus = @("-h", $hostPart, "-p", $port) +$results = @() + +# --- Assertion 1: virtual-tag bridge ------------------------------------------ +Write-Header "Virtual tag — VT_DoubledHR100 = HR[100] * 2" +$inputValue = 21 +$expectedVirtual = $inputValue * 2 + +$w = Invoke-Cli -Cli $modbusCli -Args (@("write") + $commonModbus + ` + @("-r", "HoldingRegisters", "-a", "100", "-t", "UInt16", "-v", $inputValue)) +if ($w.ExitCode -ne 0) { + Write-Fail "modbus write failed (exit=$($w.ExitCode))" + $results += @{ Passed = $false; Reason = "seed write failed" } +} +else { + Write-Info "wrote HR[100]=$inputValue, waiting 3s for virtual-tag engine to re-evaluate" + Start-Sleep -Seconds 3 + $r = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $VirtualNodeId) + if ($r.ExitCode -eq 0 -and $r.Output -match "Value:\s+$expectedVirtual\b") { + Write-Pass "virtual tag = $expectedVirtual (input * 2)" + $results += @{ Passed = $true } + } + else { + Write-Fail "expected VT = $expectedVirtual; got:" + Write-Host $r.Output + $results += @{ Passed = $false; Reason = "virtual tag mismatch" } + } +} + +# --- Assertion 2: scripted alarm fires --------------------------------------- +if ([string]::IsNullOrWhiteSpace($AlarmNodeId)) { + Write-Skip "AlarmNodeId not provided — skipping alarm fire/clear assertions" +} +else { + Write-Header "Scripted alarm — fires when VT > 100" + $fireValue = 60 # VT = 120, above threshold + $w = Invoke-Cli -Cli $modbusCli -Args (@("write") + $commonModbus + ` + @("-r", "HoldingRegisters", "-a", "100", "-t", "UInt16", "-v", $fireValue)) + if ($w.ExitCode -ne 0) { + Write-Fail "modbus write failed" + $results += @{ Passed = $false } + } + else { + Write-Info "wrote HR[100]=$fireValue (VT=$($fireValue*2)); subscribing alarms for 5s" + # otopcua-cli's `alarms` command subscribes + prints events until an + # interrupt or timeout. We capture ~5s worth then parse for ActiveState. + $job = Start-Job -ScriptBlock { + param($file, $prefix, $url, $source) + $cmdArgs = $prefix + @("alarms", "-u", $url, "-n", $source, "--duration-seconds", "5") + & $file @cmdArgs 2>&1 + } -ArgumentList $opcUaCli.File, $opcUaCli.PrefixArgs, $OpcUaUrl, $AlarmNodeId + + $alarmOutput = Receive-Job -Job $job -Wait -AutoRemoveJob + $alarmText = ($alarmOutput | Out-String) + if ($alarmText -match "Active" -or $alarmText -match "HighAlarm" -or $alarmText -match "Severity") { + Write-Pass "alarm subscription received an event" + $results += @{ Passed = $true } + } + else { + Write-Fail "expected alarm event in subscription output" + Write-Host $alarmText + $results += @{ Passed = $false; Reason = "alarm did not fire" } + } + } + + # --- Assertion 3: alarm clears --- + Write-Header "Scripted alarm — clears when VT falls below threshold" + $clearValue = 10 # VT = 20, below threshold + $w = Invoke-Cli -Cli $modbusCli -Args (@("write") + $commonModbus + ` + @("-r", "HoldingRegisters", "-a", "100", "-t", "UInt16", "-v", $clearValue)) + if ($w.ExitCode -eq 0) { + Write-Info "wrote HR[100]=$clearValue (VT=$($clearValue*2)); alarm should clear" + # We don't re-subscribe here — the clear is asserted via the virtual + # tag's current value (the Phase 7 engine's commitment is that state + # propagates on the next tick; the OPC UA alarm transition follows). + Start-Sleep -Seconds 3 + $r = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $VirtualNodeId) + if ($r.Output -match "Value:\s+$($clearValue*2)\b") { + Write-Pass "virtual tag returned to below-threshold ($($clearValue*2))" + $results += @{ Passed = $true } + } + else { + Write-Fail "virtual tag did not reflect cleared state" + Write-Host $r.Output + $results += @{ Passed = $false; Reason = "clear state mismatch" } + } + } +} + +Write-Summary -Title "Phase 7 virtual tags + scripted alarms" -Results $results +if ($results | Where-Object { -not $_.Passed }) { exit 1 } diff --git a/scripts/e2e/test-s7.ps1 b/scripts/e2e/test-s7.ps1 new file mode 100644 index 0000000..ef7a36c --- /dev/null +++ b/scripts/e2e/test-s7.ps1 @@ -0,0 +1,82 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + End-to-end CLI test for the Siemens S7 driver bridged through the OtOpcUa server. + +.DESCRIPTION + Probe + driver-loopback + server-bridge against a Siemens S7-300/400/1200/1500 + or compatible soft-PLC. python-snap7 simulator (task #216) or real hardware + both work. + + Prereqs: + - S7 simulator / PLC on $S7Host:$S7Port + - On real S7-1200/1500: PUT/GET communication enabled in TIA Portal. + - OtOpcUa server running with an S7 DriverInstance bound to the same + endpoint + a Tag at DB1.DBW0 Int16 published under -BridgeNodeId. + +.PARAMETER S7Host + Host:port of the S7 simulator / PLC. Default 127.0.0.1:102. + +.PARAMETER Cpu + S7200 / S7200Smart / S7300 / S7400 / S71200 / S71500 (default S71500). + +.PARAMETER Slot + CPU slot. Default 0 (S7-1200/1500). S7-300 uses 2. + +.PARAMETER Address + S7 address to exercise. Default DB1.DBW0. + +.PARAMETER OpcUaUrl + OtOpcUa server endpoint. + +.PARAMETER BridgeNodeId + NodeId at which the server publishes the Address. +#> + +param( + [string]$S7Host = "127.0.0.1:102", + [string]$Cpu = "S71500", + [int]$Slot = 0, + [string]$Address = "DB1.DBW0", + [string]$OpcUaUrl = "opc.tcp://localhost:4840", + [Parameter(Mandatory)] [string]$BridgeNodeId +) + +$ErrorActionPreference = "Stop" +. "$PSScriptRoot/_common.ps1" + +$hostPart, $portPart = $S7Host.Split(":") +$port = [int]$portPart + +$s7Cli = Get-CliInvocation ` + -ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli" ` + -ExeName "otopcua-s7-cli" +$opcUaCli = Get-CliInvocation ` + -ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" ` + -ExeName "otopcua-cli" + +$commonS7 = @("-h", $hostPart, "-p", $port, "-c", $Cpu, "--slot", $Slot) +$results = @() + +$results += Test-Probe ` + -Cli $s7Cli ` + -ProbeArgs (@("probe") + $commonS7) + +$writeValue = Get-Random -Minimum 1 -Maximum 9999 +$results += Test-DriverLoopback ` + -Cli $s7Cli ` + -WriteArgs (@("write") + $commonS7 + @("-a", $Address, "-t", "Int16", "-v", $writeValue)) ` + -ReadArgs (@("read") + $commonS7 + @("-a", $Address, "-t", "Int16")) ` + -ExpectedValue "$writeValue" + +$bridgeValue = Get-Random -Minimum 10000 -Maximum 19999 +$results += Test-ServerBridge ` + -DriverCli $s7Cli ` + -DriverWriteArgs (@("write") + $commonS7 + @("-a", $Address, "-t", "Int16", "-v", $bridgeValue)) ` + -OpcUaCli $opcUaCli ` + -OpcUaUrl $OpcUaUrl ` + -OpcUaNodeId $BridgeNodeId ` + -ExpectedValue "$bridgeValue" + +Write-Summary -Title "S7 e2e" -Results $results +if ($results | Where-Object { -not $_.Passed }) { exit 1 } diff --git a/scripts/e2e/test-twincat.ps1 b/scripts/e2e/test-twincat.ps1 new file mode 100644 index 0000000..770719c --- /dev/null +++ b/scripts/e2e/test-twincat.ps1 @@ -0,0 +1,81 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + End-to-end CLI test for the TwinCAT (Beckhoff ADS) driver. + +.DESCRIPTION + Requires a reachable AMS router (local TwinCAT XAR, Beckhoff.TwinCAT.Ads. + TcpRouter NuGet, or an authorised remote AMS route) + a live TwinCAT + runtime on -AmsNetId. Without one the driver surfaces a transport error + on InitializeAsync + the script's probe fails. + + Set TWINCAT_TRUST_WIRE=1 to promise the endpoint is live. Without it the + script skips (task #221 tracks the 7-day-trial CI fixture — until that + lands, TwinCAT testing is a manual operator task). + +.PARAMETER AmsNetId + AMS Net ID of the target (e.g. 127.0.0.1.1.1 for local XAR, + 192.168.1.40.1.1 for a remote PLC). + +.PARAMETER AmsPort + AMS port. Default 851 (TC3 PLC runtime). TC2 uses 801. + +.PARAMETER SymbolPath + TwinCAT symbol to exercise. Default 'MAIN.iCounter' — substitute with + whatever your project actually declares. + +.PARAMETER OpcUaUrl + OtOpcUa server endpoint. + +.PARAMETER BridgeNodeId + NodeId at which the server publishes the Symbol. +#> + +param( + [string]$AmsNetId = "127.0.0.1.1.1", + [int]$AmsPort = 851, + [string]$SymbolPath = "MAIN.iCounter", + [string]$OpcUaUrl = "opc.tcp://localhost:4840", + [Parameter(Mandatory)] [string]$BridgeNodeId +) + +$ErrorActionPreference = "Stop" +. "$PSScriptRoot/_common.ps1" + +if (-not ($env:TWINCAT_TRUST_WIRE -eq "1" -or $env:TWINCAT_TRUST_WIRE -eq "true")) { + Write-Skip "TWINCAT_TRUST_WIRE not set — requires reachable AMS router + live TC runtime (task #221 tracks the CI fixture). Set =1 once the router is up." + exit 0 +} + +$twinCatCli = Get-CliInvocation ` + -ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli" ` + -ExeName "otopcua-twincat-cli" +$opcUaCli = Get-CliInvocation ` + -ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" ` + -ExeName "otopcua-cli" + +$commonTc = @("-n", $AmsNetId, "-p", $AmsPort) +$results = @() + +$results += Test-Probe ` + -Cli $twinCatCli ` + -ProbeArgs (@("probe") + $commonTc + @("-s", $SymbolPath, "--type", "DInt")) + +$writeValue = Get-Random -Minimum 1 -Maximum 9999 +$results += Test-DriverLoopback ` + -Cli $twinCatCli ` + -WriteArgs (@("write") + $commonTc + @("-s", $SymbolPath, "-t", "DInt", "-v", $writeValue)) ` + -ReadArgs (@("read") + $commonTc + @("-s", $SymbolPath, "-t", "DInt")) ` + -ExpectedValue "$writeValue" + +$bridgeValue = Get-Random -Minimum 10000 -Maximum 19999 +$results += Test-ServerBridge ` + -DriverCli $twinCatCli ` + -DriverWriteArgs (@("write") + $commonTc + @("-s", $SymbolPath, "-t", "DInt", "-v", $bridgeValue)) ` + -OpcUaCli $opcUaCli ` + -OpcUaUrl $OpcUaUrl ` + -OpcUaNodeId $BridgeNodeId ` + -ExpectedValue "$bridgeValue" + +Write-Summary -Title "TwinCAT e2e" -Results $results +if ($results | Where-Object { -not $_.Passed }) { exit 1 } diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/ProbeCommand.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/ProbeCommand.cs new file mode 100644 index 0000000..f0e6ef9 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/ProbeCommand.cs @@ -0,0 +1,57 @@ +using CliFx.Attributes; +using CliFx.Infrastructure; +using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Commands; + +/// +/// Probes a Fanuc CNC: opens a FOCAS session + reads one PMC address. No public +/// simulator exists — this command only produces meaningful results against a real +/// CNC with Fwlib32.dll present. Against a dev box it surfaces +/// BadCommunicationError (DLL missing) which is still a useful signal that +/// the CLI wire-up is correct. +/// +[Command("probe", Description = "Verify the CNC is reachable + a sample FOCAS read succeeds.")] +public sealed class ProbeCommand : FocasCommandBase +{ + [CommandOption("address", 'a', Description = + "FOCAS address to probe (default R100 — PMC R-file register 100).")] + public string Address { get; init; } = "R100"; + + [CommandOption("type", Description = "Data type (default Int16).")] + public FocasDataType DataType { get; init; } = FocasDataType.Int16; + + public override async ValueTask ExecuteAsync(IConsole console) + { + ConfigureLogging(); + var ct = console.RegisterCancellationHandler(); + + var probeTag = new FocasTagDefinition( + Name: "__probe", + DeviceHostAddress: HostAddress, + Address: Address, + DataType: DataType, + Writable: false); + var options = BuildOptions([probeTag]); + + await using var driver = new FocasDriver(options, DriverInstanceId); + try + { + await driver.InitializeAsync("{}", ct); + var snapshot = await driver.ReadAsync(["__probe"], ct); + var health = driver.GetHealth(); + + await console.Output.WriteLineAsync($"CNC: {CncHost}:{CncPort}"); + await console.Output.WriteLineAsync($"Series: {Series}"); + await console.Output.WriteLineAsync($"Health: {health.State}"); + if (health.LastError is { } err) + await console.Output.WriteLineAsync($"Last error: {err}"); + await console.Output.WriteLineAsync(); + await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0])); + } + finally + { + await driver.ShutdownAsync(CancellationToken.None); + } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/ReadCommand.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/ReadCommand.cs new file mode 100644 index 0000000..c32054b --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/ReadCommand.cs @@ -0,0 +1,52 @@ +using CliFx.Attributes; +using CliFx.Infrastructure; +using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Commands; + +/// +/// Read one FOCAS address (PMC R/G/F file, parameter, macro, axis register). +/// +[Command("read", Description = "Read a single FOCAS address.")] +public sealed class ReadCommand : FocasCommandBase +{ + [CommandOption("address", 'a', Description = + "FOCAS address. Examples: R100 (PMC R-file word); X0.0 (PMC X-bit); " + + "PARAM:1815/0 (parameter 1815, axis 0); MACRO:500 (macro variable 500).", + IsRequired = true)] + public string Address { get; init; } = default!; + + [CommandOption("type", 't', Description = + "Bit / Byte / Int16 / Int32 / Float32 / Float64 / String (default Int16).")] + public FocasDataType DataType { get; init; } = FocasDataType.Int16; + + public override async ValueTask ExecuteAsync(IConsole console) + { + ConfigureLogging(); + var ct = console.RegisterCancellationHandler(); + + var tagName = SynthesiseTagName(Address, DataType); + var tag = new FocasTagDefinition( + Name: tagName, + DeviceHostAddress: HostAddress, + Address: Address, + DataType: DataType, + Writable: false); + var options = BuildOptions([tag]); + + await using var driver = new FocasDriver(options, DriverInstanceId); + try + { + await driver.InitializeAsync("{}", ct); + var snapshot = await driver.ReadAsync([tagName], ct); + await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0])); + } + finally + { + await driver.ShutdownAsync(CancellationToken.None); + } + } + + internal static string SynthesiseTagName(string address, FocasDataType type) + => $"{address}:{type}"; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/SubscribeCommand.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/SubscribeCommand.cs new file mode 100644 index 0000000..45a6362 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/SubscribeCommand.cs @@ -0,0 +1,76 @@ +using CliFx.Attributes; +using CliFx.Infrastructure; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Commands; + +/// +/// Watch a FOCAS address via polled subscription until Ctrl+C. FOCAS has no push +/// model; PollGroupEngine handles the tick loop. +/// +[Command("subscribe", Description = "Watch a FOCAS address via polled subscription until Ctrl+C.")] +public sealed class SubscribeCommand : FocasCommandBase +{ + [CommandOption("address", 'a', Description = "FOCAS address — same format as `read`.", IsRequired = true)] + public string Address { get; init; } = default!; + + [CommandOption("type", 't', Description = + "Bit / Byte / Int16 / Int32 / Float32 / Float64 / String (default Int16).")] + public FocasDataType DataType { get; init; } = FocasDataType.Int16; + + [CommandOption("interval-ms", 'i', Description = "Publishing interval ms (default 1000).")] + public int IntervalMs { get; init; } = 1000; + + public override async ValueTask ExecuteAsync(IConsole console) + { + ConfigureLogging(); + var ct = console.RegisterCancellationHandler(); + + var tagName = ReadCommand.SynthesiseTagName(Address, DataType); + var tag = new FocasTagDefinition( + Name: tagName, + DeviceHostAddress: HostAddress, + Address: Address, + DataType: DataType, + Writable: false); + var options = BuildOptions([tag]); + + await using var driver = new FocasDriver(options, DriverInstanceId); + ISubscriptionHandle? handle = null; + try + { + await driver.InitializeAsync("{}", ct); + + driver.OnDataChange += (_, e) => + { + var line = $"[{DateTime.UtcNow:HH:mm:ss.fff}] " + + $"{e.FullReference} = {SnapshotFormatter.FormatValue(e.Snapshot.Value)} " + + $"({SnapshotFormatter.FormatStatus(e.Snapshot.StatusCode)})"; + console.Output.WriteLine(line); + }; + + handle = await driver.SubscribeAsync([tagName], TimeSpan.FromMilliseconds(IntervalMs), ct); + + await console.Output.WriteLineAsync( + $"Subscribed to {Address} @ {IntervalMs}ms. Ctrl+C to stop."); + try + { + await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, ct); + } + catch (OperationCanceledException) + { + // Expected on Ctrl+C. + } + } + finally + { + if (handle is not null) + { + try { await driver.UnsubscribeAsync(handle, CancellationToken.None); } + catch { /* teardown best-effort */ } + } + await driver.ShutdownAsync(CancellationToken.None); + } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/WriteCommand.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/WriteCommand.cs new file mode 100644 index 0000000..f972eda --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/WriteCommand.cs @@ -0,0 +1,77 @@ +using System.Globalization; +using CliFx.Attributes; +using CliFx.Infrastructure; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Commands; + +/// +/// Write one value to a FOCAS address. PMC G/R writes are real — be careful +/// which file you hit on a running machine. Parameter writes may require the +/// CNC to be in MDI mode + the parameter-write switch enabled. +/// +[Command("write", Description = "Write a single FOCAS address.")] +public sealed class WriteCommand : FocasCommandBase +{ + [CommandOption("address", 'a', Description = "FOCAS address — same format as `read`.", IsRequired = true)] + public string Address { get; init; } = default!; + + [CommandOption("type", 't', Description = + "Bit / Byte / Int16 / Int32 / Float32 / Float64 / String (default Int16).")] + public FocasDataType DataType { get; init; } = FocasDataType.Int16; + + [CommandOption("value", 'v', Description = + "Value to write. Parsed per --type (booleans accept true/false/1/0).", + IsRequired = true)] + public string Value { get; init; } = default!; + + public override async ValueTask ExecuteAsync(IConsole console) + { + ConfigureLogging(); + var ct = console.RegisterCancellationHandler(); + + var tagName = ReadCommand.SynthesiseTagName(Address, DataType); + var tag = new FocasTagDefinition( + Name: tagName, + DeviceHostAddress: HostAddress, + Address: Address, + DataType: DataType, + Writable: true); + var options = BuildOptions([tag]); + + var parsed = ParseValue(Value, DataType); + + await using var driver = new FocasDriver(options, DriverInstanceId); + try + { + await driver.InitializeAsync("{}", ct); + var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct); + await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(Address, results[0])); + } + finally + { + await driver.ShutdownAsync(CancellationToken.None); + } + } + + internal static object ParseValue(string raw, FocasDataType type) => type switch + { + FocasDataType.Bit => ParseBool(raw), + FocasDataType.Byte => sbyte.Parse(raw, CultureInfo.InvariantCulture), + FocasDataType.Int16 => short.Parse(raw, CultureInfo.InvariantCulture), + FocasDataType.Int32 => int.Parse(raw, CultureInfo.InvariantCulture), + FocasDataType.Float32 => float.Parse(raw, CultureInfo.InvariantCulture), + FocasDataType.Float64 => double.Parse(raw, CultureInfo.InvariantCulture), + FocasDataType.String => raw, + _ => throw new CliFx.Exceptions.CommandException($"Unsupported DataType '{type}' for write."), + }; + + private static bool ParseBool(string raw) => raw.Trim().ToLowerInvariant() switch + { + "1" or "true" or "on" or "yes" => true, + "0" or "false" or "off" or "no" => false, + _ => throw new CliFx.Exceptions.CommandException( + $"Boolean value '{raw}' is not recognised. Use true/false, 1/0, on/off, or yes/no."), + }; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs new file mode 100644 index 0000000..a8413b4 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs @@ -0,0 +1,58 @@ +using CliFx.Attributes; +using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli; + +/// +/// Base for every FOCAS CLI command. Carries the CNC endpoint options +/// (host / port / series) + exposes so each command +/// can synthesise a with one device + one tag. +/// +public abstract class FocasCommandBase : DriverCommandBase +{ + [CommandOption("cnc-host", 'h', Description = + "CNC IP address or hostname. FOCAS-over-EIP listens on port 8193 by default.", + IsRequired = true)] + public string CncHost { get; init; } = default!; + + [CommandOption("cnc-port", 'p', Description = "FOCAS TCP port (default 8193).")] + public int CncPort { get; init; } = 8193; + + [CommandOption("series", 's', Description = + "CNC series: Unknown / Zero_i_D / Zero_i_F / Zero_i_MF / Zero_i_TF / Sixteen_i / " + + "Thirty_i / ThirtyOne_i / ThirtyTwo_i / PowerMotion_i (default Unknown).")] + public FocasCncSeries Series { get; init; } = FocasCncSeries.Unknown; + + [CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 2000).")] + public int TimeoutMs { get; init; } = 2000; + + /// + public override TimeSpan Timeout + { + get => TimeSpan.FromMilliseconds(TimeoutMs); + init { /* driven by TimeoutMs */ } + } + + /// Canonical FOCAS host-address string, shape focas://host:port. + protected string HostAddress => $"focas://{CncHost}:{CncPort}"; + + /// + /// Build a with the CNC target this base collected + /// + the tag list a subclass supplies. Probe disabled; the default + /// attempts Fwlib32.dll P/Invoke, which + /// throws at first call when the DLL is absent — + /// surfaced through the driver as BadCommunicationError. + /// + protected FocasDriverOptions BuildOptions(IReadOnlyList tags) => new() + { + Devices = [new FocasDeviceOptions( + HostAddress: HostAddress, + DeviceName: $"cli-{CncHost}:{CncPort}", + Series: Series)], + Tags = tags, + Timeout = Timeout, + Probe = new FocasProbeOptions { Enabled = false }, + }; + + protected string DriverInstanceId => $"focas-cli-{CncHost}:{CncPort}"; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Program.cs new file mode 100644 index 0000000..b94257d --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Program.cs @@ -0,0 +1,12 @@ +using CliFx; + +return await new CliApplicationBuilder() + .AddCommandsFromThisAssembly() + .SetExecutableName("otopcua-focas-cli") + .SetDescription( + "OtOpcUa FOCAS test-client — ad-hoc probe + PMC/param/macro reads/writes + polled " + + "subscriptions against Fanuc CNCs via the FOCAS/2 protocol. Requires a real CNC + a " + + "licensed Fwlib32.dll on PATH (or next to the executable) — no public simulator " + + "exists. Addresses use FocasAddressParser syntax: R100, X0.0, PARAM:1815/0, MACRO:500.") + .Build() + .RunAsync(args); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.csproj new file mode 100644 index 0000000..76520db --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.csproj @@ -0,0 +1,25 @@ + + + + Exe + net10.0 + enable + enable + latest + true + true + $(NoWarn);CS1591 + ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli + otopcua-focas-cli + + + + + + + + + + + +