Task #253 — E2E CLI test scripts + FOCAS test-client CLI
The driver-layer integration tests confirm the driver sees the PLC, and
the Client.CLI tests confirm the client sees the server. Nothing glued
them end-to-end until this PR.
- scripts/e2e/_common.ps1: shared helpers — CLI invocation (published-
binary OR `dotnet run` fallback), Test-Probe / Test-DriverLoopback /
Test-ServerBridge (all return @{Passed;Reason} hashtables).
- scripts/e2e/test-<modbus|abcip|ablegacy|s7|focas|twincat>.ps1: per-
driver three-stage script (probe → driver-loopback → server-bridge).
AB Legacy / FOCAS / TwinCAT are gated behind *_TRUST_WIRE env vars
since they need real hardware (#222) or a licensed runtime (#221).
- scripts/e2e/test-phase7-virtualtags.ps1: writes a Modbus HR, reads
the server-side VirtualTag (VT = input * 2) back via OPC UA, triggers
+ clears a scripted alarm. Exercises the Phase 7 CachedTagUpstreamSource
+ ScriptedAlarmEngine path.
- scripts/e2e/test-all.ps1: reads e2e-config.json sidecar, runs each
present driver, prints a FINAL MATRIX (PASS/FAIL/SKIP). Missing
sections SKIP rather than fail hard.
- scripts/e2e/e2e-config.sample.json: commented sample — each dev's
NodeIds are local-seed-specific so e2e-config.json is .gitignore-d.
- scripts/e2e/README.md: full walkthrough — prereqs, three-stage design,
env-var gates, expected matrix, why this is separate from `dotnet test`.
Tasks #249-#251 shipped Modbus/AbCip/AbLegacy/S7/TwinCAT CLIs but left
FOCAS out. Since test-focas.ps1 needs it, the 6th CLI ships here:
- src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli: probe/read/write/subscribe
commands, AssemblyName `otopcua-focas-cli`. WriteCommand.ParseValue
handles the full FocasDataType enum (Bit/Byte/Int16/Int32/Float32/
Float64/String — no UInt variants; the FOCAS protocol exposes signed
PMC + Fanuc-Float only). Default DataType is Int16 to match the PMC
register convention.
Full-solution build clean (0 errors). FOCAS CLI wired into
ZB.MOM.WW.OtOpcUa.slnx. No .Tests project for the FOCAS CLI yet —
symmetric with how ProbeCommand has no unit-testable pure logic in the
other 5 CLIs either; WriteCommand.ParseValue parity will land in a
follow-up to keep this PR scoped.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Analyzers/ZB.MOM.WW.OtOpcUa.Analyzers.csproj"/>
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
|
||||
135
scripts/e2e/README.md
Normal file
135
scripts/e2e/README.md
Normal file
@@ -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.<Name>.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.
|
||||
219
scripts/e2e/_common.ps1
Normal file
219
scripts/e2e/_common.ps1
Normal file
@@ -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=<bool>; Reason=<string> }
|
||||
# - 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 <exe> there
|
||||
# 2. Fall back to `dotnet run --project src/<ProjectFolder> --`
|
||||
#
|
||||
# $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" })
|
||||
}
|
||||
56
scripts/e2e/e2e-config.sample.json
Normal file
56
scripts/e2e/e2e-config.sample.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
85
scripts/e2e/test-abcip.ps1
Normal file
85
scripts/e2e/test-abcip.ps1
Normal file
@@ -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 }
|
||||
80
scripts/e2e/test-ablegacy.ps1
Normal file
80
scripts/e2e/test-ablegacy.ps1
Normal file
@@ -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 }
|
||||
192
scripts/e2e/test-all.ps1
Normal file
192
scripts/e2e/test-all.ps1
Normal file
@@ -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
|
||||
78
scripts/e2e/test-focas.ps1
Normal file
78
scripts/e2e/test-focas.ps1
Normal file
@@ -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 }
|
||||
74
scripts/e2e/test-modbus.ps1
Normal file
74
scripts/e2e/test-modbus.ps1
Normal file
@@ -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 }
|
||||
156
scripts/e2e/test-phase7-virtualtags.ps1
Normal file
156
scripts/e2e/test-phase7-virtualtags.ps1
Normal file
@@ -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 }
|
||||
82
scripts/e2e/test-s7.ps1
Normal file
82
scripts/e2e/test-s7.ps1
Normal file
@@ -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 }
|
||||
81
scripts/e2e/test-twincat.ps1
Normal file
81
scripts/e2e/test-twincat.ps1
Normal file
@@ -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 }
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <c>BadCommunicationError</c> (DLL missing) which is still a useful signal that
|
||||
/// the CLI wire-up is correct.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Read one FOCAS address (PMC R/G/F file, parameter, macro, axis register).
|
||||
/// </summary>
|
||||
[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}";
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Watch a FOCAS address via polled subscription until Ctrl+C. FOCAS has no push
|
||||
/// model; <c>PollGroupEngine</c> handles the tick loop.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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."),
|
||||
};
|
||||
}
|
||||
58
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs
Normal file
58
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using CliFx.Attributes;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// Base for every FOCAS CLI command. Carries the CNC endpoint options
|
||||
/// (host / port / series) + exposes <see cref="BuildOptions"/> so each command
|
||||
/// can synthesise a <see cref="FocasDriverOptions"/> with one device + one tag.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override TimeSpan Timeout
|
||||
{
|
||||
get => TimeSpan.FromMilliseconds(TimeoutMs);
|
||||
init { /* driven by TimeoutMs */ }
|
||||
}
|
||||
|
||||
/// <summary>Canonical FOCAS host-address string, shape <c>focas://host:port</c>.</summary>
|
||||
protected string HostAddress => $"focas://{CncHost}:{CncPort}";
|
||||
|
||||
/// <summary>
|
||||
/// Build a <see cref="FocasDriverOptions"/> with the CNC target this base collected
|
||||
/// + the tag list a subclass supplies. Probe disabled; the default
|
||||
/// <see cref="FwlibFocasClientFactory"/> attempts <c>Fwlib32.dll</c> P/Invoke, which
|
||||
/// throws <see cref="DllNotFoundException"/> at first call when the DLL is absent —
|
||||
/// surfaced through the driver as <c>BadCommunicationError</c>.
|
||||
/// </summary>
|
||||
protected FocasDriverOptions BuildOptions(IReadOnlyList<FocasTagDefinition> 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}";
|
||||
}
|
||||
12
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Program.cs
Normal file
12
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Program.cs
Normal file
@@ -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);
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli</RootNamespace>
|
||||
<AssemblyName>otopcua-focas-cli</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CliFx" Version="2.3.6"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Cli.Common\ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user