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)
|
# LiteDB local config cache (Phase 6.1 Stream D — runtime artifact, not source)
|
||||||
src/ZB.MOM.WW.OtOpcUa.Server/config_cache.db
|
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.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.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.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"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Analyzers/ZB.MOM.WW.OtOpcUa.Analyzers.csproj"/>
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/tests/">
|
<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