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
+
+
+
+
+
+
+
+
+
+
+
+