Compare commits
19 Commits
phase-7-fu
...
v2
| Author | SHA1 | Date | |
|---|---|---|---|
| 4446a3ce5b | |||
|
|
4dc685a365 | ||
| ff50aac59f | |||
|
|
b2065f8730 | ||
| 9020b5854c | |||
|
|
5dac2e9375 | ||
| b644b26310 | |||
|
|
012c6a4e7a | ||
| ae07fea630 | |||
|
|
c41831794a | ||
| 3e3c7206dd | |||
|
|
4e96f228b2 | ||
| 443474f58f | |||
|
|
dfe3731c73 | ||
| 6863cc4652 | |||
|
|
8221fac8c1 | ||
| bc44711dca | |||
|
|
acf31fd943 | ||
| 7e143e293b |
@@ -24,6 +24,12 @@
|
|||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.UI/ZB.MOM.WW.OtOpcUa.Client.UI.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.UI/ZB.MOM.WW.OtOpcUa.Client.UI.csproj"/>
|
||||||
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.csproj"/>
|
||||||
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.csproj"/>
|
||||||
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.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.TwinCAT.Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.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/">
|
||||||
@@ -44,6 +50,12 @@
|
|||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj"/>
|
||||||
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests.csproj"/>
|
||||||
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests.csproj"/>
|
||||||
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests.csproj"/>
|
||||||
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests.csproj"/>
|
||||||
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests.csproj"/>
|
||||||
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj"/>
|
||||||
|
|||||||
83
docs/Driver.AbCip.Cli.md
Normal file
83
docs/Driver.AbCip.Cli.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# `otopcua-abcip-cli` — AB CIP test client
|
||||||
|
|
||||||
|
Ad-hoc probe / read / write / subscribe tool for ControlLogix / CompactLogix /
|
||||||
|
Micro800 / GuardLogix PLCs, talking to the **same** `AbCipDriver` the OtOpcUa
|
||||||
|
server uses (libplctag under the hood).
|
||||||
|
|
||||||
|
Second of four driver test-client CLIs (Modbus → AB CIP → AB Legacy → S7 →
|
||||||
|
TwinCAT). Shares `Driver.Cli.Common` with the others.
|
||||||
|
|
||||||
|
## Build + run
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli -- --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common flags
|
||||||
|
|
||||||
|
| Flag | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `-g` / `--gateway` | **required** | Canonical `ab://host[:port]/cip-path` |
|
||||||
|
| `-f` / `--family` | `ControlLogix` | ControlLogix / CompactLogix / Micro800 / GuardLogix |
|
||||||
|
| `--timeout-ms` | `5000` | Per-operation timeout |
|
||||||
|
| `--verbose` | off | Serilog debug output |
|
||||||
|
|
||||||
|
Family ↔ CIP-path cheat sheet:
|
||||||
|
- **ControlLogix / CompactLogix / GuardLogix** — `1,0` (slot 0 of chassis)
|
||||||
|
- **Micro800** — empty path, just `ab://host/`
|
||||||
|
- **Sub-slot Logix** (rare) — `1,3` for slot 3
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### `probe` — is the PLC up?
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# ControlLogix — read the canonical libplctag system tag
|
||||||
|
otopcua-abcip-cli probe -g ab://10.0.0.5/1,0 -t @raw_cpu_type --type DInt
|
||||||
|
|
||||||
|
# Micro800 — point at a user-supplied global
|
||||||
|
otopcua-abcip-cli probe -g ab://10.0.0.6/ -f Micro800 -t _SYSVA_CLOCK_HOUR --type DInt
|
||||||
|
```
|
||||||
|
|
||||||
|
### `read` — single Logix tag
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Controller scope
|
||||||
|
otopcua-abcip-cli read -g ab://10.0.0.5/1,0 -t Motor01_Speed --type Real
|
||||||
|
|
||||||
|
# Program scope
|
||||||
|
otopcua-abcip-cli read -g ab://10.0.0.5/1,0 -t "Program:Main.Counter" --type DInt
|
||||||
|
|
||||||
|
# Array element
|
||||||
|
otopcua-abcip-cli read -g ab://10.0.0.5/1,0 -t "Recipe[3]" --type Real
|
||||||
|
|
||||||
|
# UDT member (dotted path)
|
||||||
|
otopcua-abcip-cli read -g ab://10.0.0.5/1,0 -t "Motor01.Speed" --type Real
|
||||||
|
```
|
||||||
|
|
||||||
|
### `write` — single Logix tag
|
||||||
|
|
||||||
|
Same shape as `read` plus `-v`. Values parse per `--type` using invariant
|
||||||
|
culture. Booleans accept `true`/`false`/`1`/`0`/`yes`/`no`/`on`/`off`.
|
||||||
|
Structure (UDT) writes need the member layout declared in a real driver config
|
||||||
|
and are refused by the CLI.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
otopcua-abcip-cli write -g ab://10.0.0.5/1,0 -t Motor01_Speed --type Real -v 3.14
|
||||||
|
otopcua-abcip-cli write -g ab://10.0.0.5/1,0 -t StartCommand --type Bool -v true
|
||||||
|
```
|
||||||
|
|
||||||
|
### `subscribe` — watch a tag until Ctrl+C
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
otopcua-abcip-cli subscribe -g ab://10.0.0.5/1,0 -t Motor01_Speed --type Real -i 500
|
||||||
|
```
|
||||||
|
|
||||||
|
## Typical workflows
|
||||||
|
|
||||||
|
- **"Is the PLC reachable?"** → `probe`.
|
||||||
|
- **"Did my recipe write land?"** → `write` + `read` back.
|
||||||
|
- **"Why is tag X flipping?"** → `subscribe`.
|
||||||
|
- **"Is this GuardLogix safety tag writable from non-safety?"** → `write` and
|
||||||
|
read the status code — safety tags surface `BadNotWritable` / CIP errors,
|
||||||
|
non-safety tags surface `Good`.
|
||||||
105
docs/Driver.AbLegacy.Cli.md
Normal file
105
docs/Driver.AbLegacy.Cli.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# `otopcua-ablegacy-cli` — AB Legacy (PCCC) test client
|
||||||
|
|
||||||
|
Ad-hoc probe / read / write / subscribe tool for SLC 500 / MicroLogix 1100 /
|
||||||
|
MicroLogix 1400 / PLC-5 devices, talking to the **same** `AbLegacyDriver` the
|
||||||
|
OtOpcUa server uses (libplctag PCCC back-end).
|
||||||
|
|
||||||
|
Third of four driver test-client CLIs. Shares `Driver.Cli.Common` with the
|
||||||
|
others.
|
||||||
|
|
||||||
|
## Build + run
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli -- --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common flags
|
||||||
|
|
||||||
|
| Flag | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `-g` / `--gateway` | **required** | Canonical `ab://host[:port]/cip-path` |
|
||||||
|
| `-P` / `--plc-type` | `Slc500` | Slc500 / MicroLogix / Plc5 / LogixPccc |
|
||||||
|
| `--timeout-ms` | `5000` | Per-operation timeout |
|
||||||
|
| `--verbose` | off | Serilog debug output |
|
||||||
|
|
||||||
|
Family ↔ CIP-path cheat sheet:
|
||||||
|
- **SLC 5/05 / PLC-5** — `1,0`
|
||||||
|
- **MicroLogix 1100 / 1400** — empty path (`ab://host/`) — they use direct EIP
|
||||||
|
with no backplane
|
||||||
|
- **LogixPccc** — `1,0` (Logix controller accessed via the PCCC compatibility
|
||||||
|
layer; rare)
|
||||||
|
|
||||||
|
## PCCC address primer
|
||||||
|
|
||||||
|
File letters imply data type; type flag still required so the CLI knows how to
|
||||||
|
parse your `--value`.
|
||||||
|
|
||||||
|
| File | Type | CLI `--type` |
|
||||||
|
|---|---|---|
|
||||||
|
| `N` | signed int16 | `Int` |
|
||||||
|
| `F` | float32 | `Float` |
|
||||||
|
| `B` | bit-packed (`B3:0/3` addresses bit 3 of word 0) | `Bit` |
|
||||||
|
| `L` | long int32 (SLC 5/05+ only) | `Long` |
|
||||||
|
| `A` | analog int (semantically like N) | `AnalogInt` |
|
||||||
|
| `ST` | ASCII string (82-byte + length header) | `String` |
|
||||||
|
| `T` | timer sub-element (`T4:0.ACC` / `.PRE` / `.EN` / `.DN`) | `TimerElement` |
|
||||||
|
| `C` | counter sub-element (`C5:0.ACC` / `.PRE` / `.CU` / `.CD` / `.DN`) | `CounterElement` |
|
||||||
|
| `R` | control sub-element (`R6:0.LEN` / `.POS` / `.EN` / `.DN` / `.ER`) | `ControlElement` |
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### `probe`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# SLC 5/05 — default probe address N7:0
|
||||||
|
otopcua-ablegacy-cli probe -g ab://192.168.1.20/1,0
|
||||||
|
|
||||||
|
# MicroLogix 1100 — status file first word
|
||||||
|
otopcua-ablegacy-cli probe -g ab://192.168.1.30/ -P MicroLogix -a S:0
|
||||||
|
```
|
||||||
|
|
||||||
|
### `read`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Integer
|
||||||
|
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a N7:10 -t Int
|
||||||
|
|
||||||
|
# Float
|
||||||
|
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a F8:0 -t Float
|
||||||
|
|
||||||
|
# Bit-within-word
|
||||||
|
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a B3:0/3 -t Bit
|
||||||
|
|
||||||
|
# Long (SLC 5/05+)
|
||||||
|
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a L19:0 -t Long
|
||||||
|
|
||||||
|
# Timer ACC
|
||||||
|
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a T4:0.ACC -t TimerElement
|
||||||
|
```
|
||||||
|
|
||||||
|
### `write`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
otopcua-ablegacy-cli write -g ab://192.168.1.20/1,0 -a N7:10 -t Int -v 42
|
||||||
|
otopcua-ablegacy-cli write -g ab://192.168.1.20/1,0 -a F8:0 -t Float -v 3.14
|
||||||
|
otopcua-ablegacy-cli write -g ab://192.168.1.20/1,0 -a B3:0/3 -t Bit -v on
|
||||||
|
```
|
||||||
|
|
||||||
|
Writes to timer / counter / control sub-elements land at the wire level but
|
||||||
|
the PLC's runtime semantics (EN/DN edge-triggering, preset reload) are
|
||||||
|
PLC-managed — use with caution.
|
||||||
|
|
||||||
|
### `subscribe`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
otopcua-ablegacy-cli subscribe -g ab://192.168.1.20/1,0 -a N7:10 -t Int -i 500
|
||||||
|
```
|
||||||
|
|
||||||
|
## Known caveat — ab_server upstream gap
|
||||||
|
|
||||||
|
The integration-fixture `ab_server` Docker container accepts TCP but its PCCC
|
||||||
|
dispatcher doesn't actually respond — see
|
||||||
|
[`tests/...AbLegacy.IntegrationTests/Docker/README.md`](../tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/README.md).
|
||||||
|
Point `--gateway` at real hardware or an RSEmulate 500 box for end-to-end
|
||||||
|
wire-level validation. The CLI itself is correct regardless of which endpoint
|
||||||
|
you target.
|
||||||
121
docs/Driver.Modbus.Cli.md
Normal file
121
docs/Driver.Modbus.Cli.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# `otopcua-modbus-cli` — Modbus-TCP test client
|
||||||
|
|
||||||
|
Ad-hoc probe / read / write / subscribe tool for talking to Modbus-TCP devices
|
||||||
|
through the **same** `ModbusDriver` the OtOpcUa server uses. Mirrors the v1
|
||||||
|
OPC UA `otopcua-cli` shape so the muscle memory carries over: drop to a shell,
|
||||||
|
point at a PLC, watch registers move.
|
||||||
|
|
||||||
|
First of four driver test-client CLIs (Modbus → AB CIP → AB Legacy → S7 →
|
||||||
|
TwinCAT). Built on the shared `ZB.MOM.WW.OtOpcUa.Driver.Cli.Common` library
|
||||||
|
so each downstream CLI inherits verbose/log wiring + snapshot formatting
|
||||||
|
without copy-paste.
|
||||||
|
|
||||||
|
## Build + run
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet build src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli
|
||||||
|
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli -- --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Or publish a self-contained binary:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet publish src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli -c Release -o publish/modbus-cli
|
||||||
|
publish/modbus-cli/otopcua-modbus-cli.exe --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common flags
|
||||||
|
|
||||||
|
Every command accepts:
|
||||||
|
|
||||||
|
| Flag | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `-h` / `--host` | **required** | Modbus-TCP server hostname or IP |
|
||||||
|
| `-p` / `--port` | `502` | TCP port |
|
||||||
|
| `-U` / `--unit-id` | `1` | Modbus unit / slave ID |
|
||||||
|
| `--timeout-ms` | `2000` | Per-PDU timeout |
|
||||||
|
| `--disable-reconnect` | off | Turn off mid-transaction reconnect-and-retry |
|
||||||
|
| `--verbose` | off | Serilog debug output |
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### `probe` — is the PLC up?
|
||||||
|
|
||||||
|
Connects, reads one holding register, prints driver health. Fastest sanity
|
||||||
|
check after swapping a network cable or deploying a new device.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
otopcua-modbus-cli probe -h 192.168.1.10
|
||||||
|
otopcua-modbus-cli probe -h 192.168.1.10 --probe-address 100 # device locks HR[0]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `read` — single register / coil / string
|
||||||
|
|
||||||
|
Synthesises a one-tag driver config on the fly from `--region` + `--address`
|
||||||
|
+ `--type` flags.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Holding register as UInt16
|
||||||
|
otopcua-modbus-cli read -h 192.168.1.10 -r HoldingRegisters -a 100 -t UInt16
|
||||||
|
|
||||||
|
# Float32 with word-swap (CDAB) — common on Siemens / some AB families
|
||||||
|
otopcua-modbus-cli read -h 192.168.1.10 -r HoldingRegisters -a 200 -t Float32 --byte-order WordSwap
|
||||||
|
|
||||||
|
# Single bit out of a packed holding register
|
||||||
|
otopcua-modbus-cli read -h 192.168.1.10 -r HoldingRegisters -a 10 -t BitInRegister --bit-index 3
|
||||||
|
|
||||||
|
# 40-char ASCII string — DirectLOGIC packs the first char in the low byte
|
||||||
|
otopcua-modbus-cli read -h 192.168.1.10 -r HoldingRegisters -a 300 -t String --string-length 40 --string-byte-order LowByteFirst
|
||||||
|
|
||||||
|
# Discrete input / coil
|
||||||
|
otopcua-modbus-cli read -h 192.168.1.10 -r DiscreteInputs -a 5 -t Bool
|
||||||
|
```
|
||||||
|
|
||||||
|
### `write` — single value
|
||||||
|
|
||||||
|
Same flag shape as `read` plus `-v` / `--value`. Values parse per `--type`
|
||||||
|
using invariant culture (period as decimal separator). Booleans accept
|
||||||
|
`true`/`false`/`1`/`0`/`yes`/`no`/`on`/`off`.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
otopcua-modbus-cli write -h 192.168.1.10 -r HoldingRegisters -a 100 -t UInt16 -v 42
|
||||||
|
otopcua-modbus-cli write -h 192.168.1.10 -r HoldingRegisters -a 200 -t Float32 -v 3.14
|
||||||
|
otopcua-modbus-cli write -h 192.168.1.10 -r Coils -a 5 -t Bool -v on
|
||||||
|
```
|
||||||
|
|
||||||
|
**Writes are non-idempotent by default** — a timeout after the device
|
||||||
|
already applied the write will NOT auto-retry. This matches the driver's
|
||||||
|
production contract (plan decisions #44 + #45).
|
||||||
|
|
||||||
|
### `subscribe` — watch a register until Ctrl+C
|
||||||
|
|
||||||
|
Uses the driver's `ISubscribable` surface (polling under the hood via
|
||||||
|
`PollGroupEngine`). Prints every data-change event with a timestamp.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
otopcua-modbus-cli subscribe -h 192.168.1.10 -r HoldingRegisters -a 100 -t Int16 -i 500
|
||||||
|
```
|
||||||
|
|
||||||
|
## Output format
|
||||||
|
|
||||||
|
- `probe` / `read` emit a multi-line per-tag block: `Tag / Value / Status /
|
||||||
|
Source Time / Server Time`.
|
||||||
|
- `write` emits one line: `Write <tag>: 0x... (Good | BadCommunicationError | …)`.
|
||||||
|
- `subscribe` emits one line per change: `[HH:mm:ss.fff] <tag> = <value> (<status>)`.
|
||||||
|
|
||||||
|
Status codes are rendered as `0xXXXXXXXX (Name)` for the OPC UA shortlist
|
||||||
|
(`Good`, `BadCommunicationError`, `BadTimeout`, `BadNodeIdUnknown`,
|
||||||
|
`BadTypeMismatch`, `Uncertain`, …). Unknown codes fall back to bare hex.
|
||||||
|
|
||||||
|
## Typical workflows
|
||||||
|
|
||||||
|
**"Is the PLC alive?"** → `probe`.
|
||||||
|
|
||||||
|
**"Does my recipe write land?"** → `write` + `read` back against the same
|
||||||
|
address.
|
||||||
|
|
||||||
|
**"Why is tag X flipping?"** → `subscribe` + wait for the operator scenario.
|
||||||
|
|
||||||
|
**"What's the right byte order for this family?"** → `read` with
|
||||||
|
`--byte-order BigEndian`, then with `--byte-order WordSwap`. The one that
|
||||||
|
gives plausible values is the correct one for that device.
|
||||||
93
docs/Driver.S7.Cli.md
Normal file
93
docs/Driver.S7.Cli.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# `otopcua-s7-cli` — Siemens S7 test client
|
||||||
|
|
||||||
|
Ad-hoc probe / read / write / subscribe tool for Siemens S7-300 / S7-400 /
|
||||||
|
S7-1200 / S7-1500 (and compatible soft-PLCs) over S7comm / ISO-on-TCP port 102.
|
||||||
|
Uses the **same** `S7Driver` the OtOpcUa server does (S7.Net under the hood).
|
||||||
|
|
||||||
|
Fourth of four driver test-client CLIs.
|
||||||
|
|
||||||
|
## Build + run
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli -- --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common flags
|
||||||
|
|
||||||
|
| Flag | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `-h` / `--host` | **required** | PLC IP or hostname |
|
||||||
|
| `-p` / `--port` | `102` | ISO-on-TCP port (rarely changes) |
|
||||||
|
| `-c` / `--cpu` | `S71500` | S7200 / S7200Smart / S7300 / S7400 / S71200 / S71500 |
|
||||||
|
| `--rack` | `0` | Hardware rack (S7-400 distributed setups only) |
|
||||||
|
| `--slot` | `0` | CPU slot (S7-300 = 2, S7-400 = 2 or 3, S7-1200/1500 = 0) |
|
||||||
|
| `--timeout-ms` | `5000` | Per-operation timeout |
|
||||||
|
| `--verbose` | off | Serilog debug output |
|
||||||
|
|
||||||
|
## PUT/GET must be enabled
|
||||||
|
|
||||||
|
S7-1200 / S7-1500 ship with PUT/GET communication **disabled** by default.
|
||||||
|
Enable it in TIA Portal: *Device config → Protection & Security → Connection
|
||||||
|
mechanisms → "Permit access with PUT/GET communication from remote partner"*.
|
||||||
|
Without it the CLI's first read will surface `BadNotSupported`.
|
||||||
|
|
||||||
|
## S7 address grammar cheat sheet
|
||||||
|
|
||||||
|
| Form | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `DB1.DBW0` | DB number 1, word offset 0 |
|
||||||
|
| `DB1.DBD4` | DB number 1, dword offset 4 |
|
||||||
|
| `DB1.DBX2.3` | DB number 1, byte 2, bit 3 |
|
||||||
|
| `DB10.STRING[0]` | DB 10 string starting at offset 0 |
|
||||||
|
| `M0.0` | Merker bit 0.0 |
|
||||||
|
| `MW0` / `MD4` | Merker word / dword |
|
||||||
|
| `IW4` | Input word 4 |
|
||||||
|
| `QD8` | Output dword 8 |
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### `probe`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# S7-1500 — default probe MW0
|
||||||
|
otopcua-s7-cli probe -h 192.168.1.30
|
||||||
|
|
||||||
|
# S7-300 (slot 2)
|
||||||
|
otopcua-s7-cli probe -h 192.168.1.31 -c S7300 --slot 2 -a DB1.DBW0
|
||||||
|
```
|
||||||
|
|
||||||
|
### `read`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# DB word
|
||||||
|
otopcua-s7-cli read -h 192.168.1.30 -a DB1.DBW0 -t Int16
|
||||||
|
|
||||||
|
# Float32 from DB dword
|
||||||
|
otopcua-s7-cli read -h 192.168.1.30 -a DB1.DBD4 -t Float32
|
||||||
|
|
||||||
|
# Merker bit
|
||||||
|
otopcua-s7-cli read -h 192.168.1.30 -a M0.0 -t Bool
|
||||||
|
|
||||||
|
# 80-char S7 string
|
||||||
|
otopcua-s7-cli read -h 192.168.1.30 -a DB10.STRING[0] -t String --string-length 80
|
||||||
|
```
|
||||||
|
|
||||||
|
### `write`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
otopcua-s7-cli write -h 192.168.1.30 -a DB1.DBW0 -t Int16 -v 42
|
||||||
|
otopcua-s7-cli write -h 192.168.1.30 -a DB1.DBD4 -t Float32 -v 3.14
|
||||||
|
otopcua-s7-cli write -h 192.168.1.30 -a M0.0 -t Bool -v true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Writes to M / Q are real** — they drive the PLC program. Be careful what you
|
||||||
|
flip on a running machine.
|
||||||
|
|
||||||
|
### `subscribe`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
otopcua-s7-cli subscribe -h 192.168.1.30 -a DB1.DBW0 -t Int16 -i 500
|
||||||
|
```
|
||||||
|
|
||||||
|
S7comm has no native push — the CLI polls through `PollGroupEngine` just like
|
||||||
|
Modbus / AB.
|
||||||
101
docs/Driver.TwinCAT.Cli.md
Normal file
101
docs/Driver.TwinCAT.Cli.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# `otopcua-twincat-cli` — Beckhoff TwinCAT test client
|
||||||
|
|
||||||
|
Ad-hoc probe / read / write / subscribe tool for Beckhoff TwinCAT 2 / TwinCAT 3
|
||||||
|
runtimes via ADS. Uses the **same** `TwinCATDriver` the OtOpcUa server does
|
||||||
|
(`Beckhoff.TwinCAT.Ads` package). Native ADS notifications by default;
|
||||||
|
`--poll-only` falls back to the shared `PollGroupEngine`.
|
||||||
|
|
||||||
|
Fifth (final) of the driver test-client CLIs.
|
||||||
|
|
||||||
|
## Build + run
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli -- --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prerequisite: AMS router
|
||||||
|
|
||||||
|
The `Beckhoff.TwinCAT.Ads` library needs a reachable AMS router to open ADS
|
||||||
|
sessions. Pick one:
|
||||||
|
|
||||||
|
1. **Local TwinCAT XAR** — install the free TwinCAT 3 XAR Engineering install
|
||||||
|
on the machine running the CLI; it ships the router.
|
||||||
|
2. **Beckhoff.TwinCAT.Ads.TcpRouter** — standalone NuGet router. Run in a
|
||||||
|
sidecar process when no XAR is installed.
|
||||||
|
3. **Remote AMS route** — any Windows box with TwinCAT installed, with an AMS
|
||||||
|
route authorised to the CLI host.
|
||||||
|
|
||||||
|
The CLI compiles + runs without a router, but every wire call fails with a
|
||||||
|
transport error until one is reachable.
|
||||||
|
|
||||||
|
## Common flags
|
||||||
|
|
||||||
|
| Flag | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `-n` / `--ams-net-id` | **required** | AMS Net ID (e.g. `192.168.1.40.1.1`) |
|
||||||
|
| `-p` / `--ams-port` | `851` | AMS port (TwinCAT 3 PLC = 851, TwinCAT 2 = 801) |
|
||||||
|
| `--timeout-ms` | `5000` | Per-operation timeout |
|
||||||
|
| `--poll-only` | off | Disable native ADS notifications, use `PollGroupEngine` instead |
|
||||||
|
| `--verbose` | off | Serilog debug output |
|
||||||
|
|
||||||
|
## Data types
|
||||||
|
|
||||||
|
TwinCAT exposes the IEC 61131-3 atomic set: `Bool`, `SInt`, `USInt`, `Int`,
|
||||||
|
`UInt`, `DInt`, `UDInt`, `LInt`, `ULInt`, `Real`, `LReal`, `String`, `WString`,
|
||||||
|
`Time`, `Date`, `DateTime`, `TimeOfDay`. The four IEC time/date variants
|
||||||
|
marshal as `UDINT` on the wire — CLI takes a numeric raw value and lets the
|
||||||
|
caller interpret semantics.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### `probe`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Local TwinCAT 3, probe a canonical global
|
||||||
|
otopcua-twincat-cli probe -n 127.0.0.1.1.1 -s "TwinCAT_SystemInfoVarList._AppInfo.OnlineChangeCnt"
|
||||||
|
|
||||||
|
# Remote, probe a project variable
|
||||||
|
otopcua-twincat-cli probe -n 192.168.1.40.1.1 -s MAIN.bRunning --type Bool
|
||||||
|
```
|
||||||
|
|
||||||
|
### `read`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Bool symbol
|
||||||
|
otopcua-twincat-cli read -n 192.168.1.40.1.1 -s MAIN.bStart -t Bool
|
||||||
|
|
||||||
|
# Counter
|
||||||
|
otopcua-twincat-cli read -n 192.168.1.40.1.1 -s GVL.Counter -t DInt
|
||||||
|
|
||||||
|
# Nested UDT member
|
||||||
|
otopcua-twincat-cli read -n 192.168.1.40.1.1 -s Motor1.Status.Running -t Bool
|
||||||
|
|
||||||
|
# Array element
|
||||||
|
otopcua-twincat-cli read -n 192.168.1.40.1.1 -s "Recipe[3]" -t Real
|
||||||
|
|
||||||
|
# WString
|
||||||
|
otopcua-twincat-cli read -n 192.168.1.40.1.1 -s GVL.sMessage -t WString
|
||||||
|
```
|
||||||
|
|
||||||
|
### `write`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
otopcua-twincat-cli write -n 192.168.1.40.1.1 -s MAIN.bStart -t Bool -v true
|
||||||
|
otopcua-twincat-cli write -n 192.168.1.40.1.1 -s GVL.Counter -t DInt -v 42
|
||||||
|
otopcua-twincat-cli write -n 192.168.1.40.1.1 -s GVL.sMessage -t WString -v "running"
|
||||||
|
```
|
||||||
|
|
||||||
|
Structure writes refused — drop to driver config JSON for those.
|
||||||
|
|
||||||
|
### `subscribe`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Native ADS notifications (default) — PLC pushes on its own cycle
|
||||||
|
otopcua-twincat-cli subscribe -n 192.168.1.40.1.1 -s GVL.Counter -t DInt -i 500
|
||||||
|
|
||||||
|
# Fall back to polling for runtimes where native notifications are constrained
|
||||||
|
otopcua-twincat-cli subscribe -n 192.168.1.40.1.1 -s GVL.Counter -t DInt -i 500 --poll-only
|
||||||
|
```
|
||||||
|
|
||||||
|
The subscribe banner announces which mechanism is in play — "ADS notification"
|
||||||
|
or "polling" — so it's obvious in screen-recorded bug reports.
|
||||||
59
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/AbCipCommandBase.cs
Normal file
59
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/AbCipCommandBase.cs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base for every AB CIP CLI command. Carries the libplctag endpoint options
|
||||||
|
/// (<c>--gateway</c> + <c>--family</c>) and exposes <see cref="BuildOptions"/> so each
|
||||||
|
/// command can synthesise an <see cref="AbCipDriverOptions"/> from CLI flags + its own
|
||||||
|
/// tag list.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class AbCipCommandBase : DriverCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("gateway", 'g', Description =
|
||||||
|
"Canonical AB CIP gateway: ab://host[:port]/cip-path. Port defaults to 44818 " +
|
||||||
|
"(EtherNet/IP). cip-path is family-specific: ControlLogix / CompactLogix need " +
|
||||||
|
"'1,0' to reach slot 0 of the CPU chassis; Micro800 takes an empty path; " +
|
||||||
|
"GuardLogix typically '1,0' same as ControlLogix.",
|
||||||
|
IsRequired = true)]
|
||||||
|
public string Gateway { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("family", 'f', Description =
|
||||||
|
"ControlLogix / CompactLogix / Micro800 / GuardLogix (default ControlLogix).")]
|
||||||
|
public AbCipPlcFamily Family { get; init; } = AbCipPlcFamily.ControlLogix;
|
||||||
|
|
||||||
|
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
|
||||||
|
public int TimeoutMs { get; init; } = 5000;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override TimeSpan Timeout
|
||||||
|
{
|
||||||
|
get => TimeSpan.FromMilliseconds(TimeoutMs);
|
||||||
|
init { /* driven by TimeoutMs */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build an <see cref="AbCipDriverOptions"/> with the device + tag list a subclass
|
||||||
|
/// supplies. Probe + alarm projection are disabled — CLI runs are one-shot; the
|
||||||
|
/// probe loop would race the operator's own reads.
|
||||||
|
/// </summary>
|
||||||
|
protected AbCipDriverOptions BuildOptions(IReadOnlyList<AbCipTagDefinition> tags) => new()
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions(
|
||||||
|
HostAddress: Gateway,
|
||||||
|
PlcFamily: Family,
|
||||||
|
DeviceName: $"cli-{Family}")],
|
||||||
|
Tags = tags,
|
||||||
|
Timeout = Timeout,
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
EnableControllerBrowse = false,
|
||||||
|
EnableAlarmProjection = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Short instance id used in Serilog output so operators running the CLI against
|
||||||
|
/// multiple gateways in parallel can distinguish the logs.
|
||||||
|
/// </summary>
|
||||||
|
protected string DriverInstanceId => $"abcip-cli-{Gateway}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Probes an AB CIP gateway: initialises the driver (connects via libplctag), reads a
|
||||||
|
/// single tag, and prints health + the read result. Fastest way to answer "is the PLC
|
||||||
|
/// up + reachable + speaking CIP via this path?".
|
||||||
|
/// </summary>
|
||||||
|
[Command("probe", Description = "Verify the AB CIP gateway is reachable and a sample tag reads.")]
|
||||||
|
public sealed class ProbeCommand : AbCipCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("tag", 't', Description =
|
||||||
|
"Tag path to probe. ControlLogix default is '@raw_cpu_type' (the canonical libplctag " +
|
||||||
|
"system tag); Micro800 takes a user-supplied global (e.g. '_SYSVA_CLOCK_HOUR').",
|
||||||
|
IsRequired = true)]
|
||||||
|
public string TagPath { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("type", Description =
|
||||||
|
"Logix atomic type of the probe tag (default DInt).")]
|
||||||
|
public AbCipDataType DataType { get; init; } = AbCipDataType.DInt;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
var probeTag = new AbCipTagDefinition(
|
||||||
|
Name: "__probe",
|
||||||
|
DeviceHostAddress: Gateway,
|
||||||
|
TagPath: TagPath,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: false);
|
||||||
|
var options = BuildOptions([probeTag]);
|
||||||
|
|
||||||
|
await using var driver = new AbCipDriver(options, DriverInstanceId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
var snapshot = await driver.ReadAsync(["__probe"], ct);
|
||||||
|
var health = driver.GetHealth();
|
||||||
|
|
||||||
|
await console.Output.WriteLineAsync($"Gateway: {Gateway}");
|
||||||
|
await console.Output.WriteLineAsync($"Family: {Family}");
|
||||||
|
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(TagPath, snapshot[0]));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read one Logix tag by symbolic path. Operator specifies <c>--tag</c> + <c>--type</c>;
|
||||||
|
/// the CLI synthesises a one-tag driver config, reads once, prints the snapshot, shuts
|
||||||
|
/// down. UDT / Structure reads are out of scope here — those need the member layout
|
||||||
|
/// declared, which belongs in a real driver config.
|
||||||
|
/// </summary>
|
||||||
|
[Command("read", Description = "Read a single Logix tag by symbolic path.")]
|
||||||
|
public sealed class ReadCommand : AbCipCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("tag", 't', Description =
|
||||||
|
"Logix symbolic path. Controller scope: 'Motor01_Speed'. Program scope: " +
|
||||||
|
"'Program:Main.Motor01_Speed'. Array element: 'Recipe[3]'. UDT member: " +
|
||||||
|
"'Motor01.Speed'.", IsRequired = true)]
|
||||||
|
public string TagPath { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("type", Description =
|
||||||
|
"Bool / SInt / Int / DInt / LInt / USInt / UInt / UDInt / ULInt / Real / LReal / " +
|
||||||
|
"String / Dt / Structure (default DInt).")]
|
||||||
|
public AbCipDataType DataType { get; init; } = AbCipDataType.DInt;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
var tagName = SynthesiseTagName(TagPath, DataType);
|
||||||
|
var tag = new AbCipTagDefinition(
|
||||||
|
Name: tagName,
|
||||||
|
DeviceHostAddress: Gateway,
|
||||||
|
TagPath: TagPath,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: false);
|
||||||
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
|
await using var driver = new AbCipDriver(options, DriverInstanceId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
var snapshot = await driver.ReadAsync([tagName], ct);
|
||||||
|
await console.Output.WriteLineAsync(SnapshotFormatter.Format(TagPath, snapshot[0]));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tag-name key the driver uses internally. The path + type pair is already unique
|
||||||
|
/// so we use them verbatim — keeps tag-level diagnostics readable without mangling.
|
||||||
|
/// </summary>
|
||||||
|
internal static string SynthesiseTagName(string tagPath, AbCipDataType type)
|
||||||
|
=> $"{tagPath}:{type}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
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.AbCip.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Watch a Logix tag via polled subscription until Ctrl+C. Uses the driver's
|
||||||
|
/// <c>ISubscribable</c> surface (PollGroupEngine under the hood). Prints each change
|
||||||
|
/// event with an HH:mm:ss.fff timestamp.
|
||||||
|
/// </summary>
|
||||||
|
[Command("subscribe", Description = "Watch a Logix tag via polled subscription until Ctrl+C.")]
|
||||||
|
public sealed class SubscribeCommand : AbCipCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("tag", 't', Description =
|
||||||
|
"Logix symbolic path — same format as `read`.", IsRequired = true)]
|
||||||
|
public string TagPath { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("type", Description =
|
||||||
|
"Bool / SInt / Int / DInt / LInt / USInt / UInt / UDInt / ULInt / Real / LReal / " +
|
||||||
|
"String / Dt (default DInt).")]
|
||||||
|
public AbCipDataType DataType { get; init; } = AbCipDataType.DInt;
|
||||||
|
|
||||||
|
[CommandOption("interval-ms", 'i', Description =
|
||||||
|
"Publishing interval in milliseconds (default 1000). PollGroupEngine floors " +
|
||||||
|
"sub-250ms values.")]
|
||||||
|
public int IntervalMs { get; init; } = 1000;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
var tagName = ReadCommand.SynthesiseTagName(TagPath, DataType);
|
||||||
|
var tag = new AbCipTagDefinition(
|
||||||
|
Name: tagName,
|
||||||
|
DeviceHostAddress: Gateway,
|
||||||
|
TagPath: TagPath,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: false);
|
||||||
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
|
await using var driver = new AbCipDriver(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 {TagPath} @ {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,94 @@
|
|||||||
|
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.AbCip.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Write one value to a Logix tag by symbolic path. Mirrors <see cref="ReadCommand"/>'s
|
||||||
|
/// flag shape + adds <c>--value</c>. Value parsing respects <c>--type</c> so you can
|
||||||
|
/// write <c>--value 3.14 --type Real</c> without hex-encoding. GuardLogix safety tags
|
||||||
|
/// are refused at the driver level (they're forced to ViewOnly by PR 12).
|
||||||
|
/// </summary>
|
||||||
|
[Command("write", Description = "Write a single Logix tag by symbolic path.")]
|
||||||
|
public sealed class WriteCommand : AbCipCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("tag", 't', Description =
|
||||||
|
"Logix symbolic path — same format as `read`.", IsRequired = true)]
|
||||||
|
public string TagPath { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("type", Description =
|
||||||
|
"Bool / SInt / Int / DInt / LInt / USInt / UInt / UDInt / ULInt / Real / LReal / " +
|
||||||
|
"String / Dt (default DInt).")]
|
||||||
|
public AbCipDataType DataType { get; init; } = AbCipDataType.DInt;
|
||||||
|
|
||||||
|
[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();
|
||||||
|
|
||||||
|
if (DataType == AbCipDataType.Structure)
|
||||||
|
throw new CliFx.Exceptions.CommandException(
|
||||||
|
"Structure (UDT) writes need an explicit member layout — drop to the driver's " +
|
||||||
|
"config JSON for those. The CLI covers atomic types only.");
|
||||||
|
|
||||||
|
var tagName = ReadCommand.SynthesiseTagName(TagPath, DataType);
|
||||||
|
var tag = new AbCipTagDefinition(
|
||||||
|
Name: tagName,
|
||||||
|
DeviceHostAddress: Gateway,
|
||||||
|
TagPath: TagPath,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: true);
|
||||||
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
|
var parsed = ParseValue(Value, DataType);
|
||||||
|
|
||||||
|
await using var driver = new AbCipDriver(options, DriverInstanceId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
|
||||||
|
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(TagPath, results[0]));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse the operator's <c>--value</c> string into the CLR type the driver expects
|
||||||
|
/// for the declared <see cref="AbCipDataType"/>. Invariant culture everywhere.
|
||||||
|
/// </summary>
|
||||||
|
internal static object ParseValue(string raw, AbCipDataType type) => type switch
|
||||||
|
{
|
||||||
|
AbCipDataType.Bool => ParseBool(raw),
|
||||||
|
AbCipDataType.SInt => sbyte.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
AbCipDataType.Int => short.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
AbCipDataType.DInt or AbCipDataType.Dt => int.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
AbCipDataType.LInt => long.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
AbCipDataType.USInt => byte.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
AbCipDataType.UInt => ushort.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
AbCipDataType.UDInt => uint.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
AbCipDataType.ULInt => ulong.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
AbCipDataType.Real => float.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
AbCipDataType.LReal => double.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
AbCipDataType.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."),
|
||||||
|
};
|
||||||
|
}
|
||||||
11
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Program.cs
Normal file
11
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Program.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
using CliFx;
|
||||||
|
|
||||||
|
return await new CliApplicationBuilder()
|
||||||
|
.AddCommandsFromThisAssembly()
|
||||||
|
.SetExecutableName("otopcua-abcip-cli")
|
||||||
|
.SetDescription(
|
||||||
|
"OtOpcUa AB CIP test-client — ad-hoc probe + Logix symbolic reads/writes + polled " +
|
||||||
|
"subscriptions against ControlLogix / CompactLogix / Micro800 / GuardLogix families " +
|
||||||
|
"via libplctag. Second of four driver CLIs; mirrors otopcua-modbus-cli's shape.")
|
||||||
|
.Build()
|
||||||
|
.RunAsync(args);
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<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.AbCip.Cli</RootNamespace>
|
||||||
|
<AssemblyName>otopcua-abcip-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.AbCip\ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base for every AB Legacy CLI command. Carries the PCCC-specific endpoint options
|
||||||
|
/// (<c>--gateway</c> + <c>--plc-type</c>) on top of <see cref="DriverCommandBase"/>'s
|
||||||
|
/// shared verbose + timeout + logging helpers.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class AbLegacyCommandBase : DriverCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("gateway", 'g', Description =
|
||||||
|
"Canonical AB Legacy gateway: ab://host[:port]/cip-path. Port defaults to 44818. " +
|
||||||
|
"cip-path depends on the family: SLC 5/05 + PLC-5 typically '1,0'; MicroLogix " +
|
||||||
|
"1100/1400 takes an empty path (direct EIP, no backplane).",
|
||||||
|
IsRequired = true)]
|
||||||
|
public string Gateway { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("plc-type", 'P', Description =
|
||||||
|
"Slc500 / MicroLogix / Plc5 / LogixPccc (default Slc500).")]
|
||||||
|
public AbLegacyPlcFamily PlcType { get; init; } = AbLegacyPlcFamily.Slc500;
|
||||||
|
|
||||||
|
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
|
||||||
|
public int TimeoutMs { get; init; } = 5000;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override TimeSpan Timeout
|
||||||
|
{
|
||||||
|
get => TimeSpan.FromMilliseconds(TimeoutMs);
|
||||||
|
init { /* driven by TimeoutMs */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build an <see cref="AbLegacyDriverOptions"/> with the device + tag list a subclass
|
||||||
|
/// supplies. Probe disabled for CLI one-shot runs.
|
||||||
|
/// </summary>
|
||||||
|
protected AbLegacyDriverOptions BuildOptions(IReadOnlyList<AbLegacyTagDefinition> tags) => new()
|
||||||
|
{
|
||||||
|
Devices = [new AbLegacyDeviceOptions(
|
||||||
|
HostAddress: Gateway,
|
||||||
|
PlcFamily: PlcType,
|
||||||
|
DeviceName: $"cli-{PlcType}")],
|
||||||
|
Tags = tags,
|
||||||
|
Timeout = Timeout,
|
||||||
|
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||||
|
};
|
||||||
|
|
||||||
|
protected string DriverInstanceId => $"ablegacy-cli-{Gateway}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Probes an AB Legacy (PCCC) endpoint: reads one N-file word + reports driver health.
|
||||||
|
/// Default probe address <c>N7:0</c> matches the integration-fixture seed so operators
|
||||||
|
/// can point the CLI at the ab_server Docker container + real hardware interchangeably.
|
||||||
|
/// </summary>
|
||||||
|
[Command("probe", Description = "Verify the AB Legacy endpoint is reachable and a sample PCCC read succeeds.")]
|
||||||
|
public sealed class ProbeCommand : AbLegacyCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("address", 'a', Description =
|
||||||
|
"PCCC address to probe (default N7:0). Use S:0 for the status file when you want " +
|
||||||
|
"the pre-populated register every SLC / MicroLogix / PLC-5 ships with.")]
|
||||||
|
public string Address { get; init; } = "N7:0";
|
||||||
|
|
||||||
|
[CommandOption("type", Description =
|
||||||
|
"PCCC data type of the probe address (default Int — matches N files).")]
|
||||||
|
public AbLegacyDataType DataType { get; init; } = AbLegacyDataType.Int;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
var probeTag = new AbLegacyTagDefinition(
|
||||||
|
Name: "__probe",
|
||||||
|
DeviceHostAddress: Gateway,
|
||||||
|
Address: Address,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: false);
|
||||||
|
var options = BuildOptions([probeTag]);
|
||||||
|
|
||||||
|
await using var driver = new AbLegacyDriver(options, DriverInstanceId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
var snapshot = await driver.ReadAsync(["__probe"], ct);
|
||||||
|
var health = driver.GetHealth();
|
||||||
|
|
||||||
|
await console.Output.WriteLineAsync($"Gateway: {Gateway}");
|
||||||
|
await console.Output.WriteLineAsync($"PLC type: {PlcType}");
|
||||||
|
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,55 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read one PCCC address (N7:0, F8:0, B3:0/3, L19:0, ST17:0, T4:0.ACC, etc.).
|
||||||
|
/// </summary>
|
||||||
|
[Command("read", Description = "Read a single PCCC file address.")]
|
||||||
|
public sealed class ReadCommand : AbLegacyCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("address", 'a', Description =
|
||||||
|
"PCCC file address. File letter implies storage; bit-within-word via slash " +
|
||||||
|
"(B3:0/3 or N7:0/5). Sub-element access for timers/counters/controls uses " +
|
||||||
|
"dot notation (T4:0.ACC, C5:0.PRE, R6:0.LEN).",
|
||||||
|
IsRequired = true)]
|
||||||
|
public string Address { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("type", 't', Description =
|
||||||
|
"Bit / Int / Long / Float / AnalogInt / String / TimerElement / CounterElement / " +
|
||||||
|
"ControlElement (default Int).")]
|
||||||
|
public AbLegacyDataType DataType { get; init; } = AbLegacyDataType.Int;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
var tagName = SynthesiseTagName(Address, DataType);
|
||||||
|
var tag = new AbLegacyTagDefinition(
|
||||||
|
Name: tagName,
|
||||||
|
DeviceHostAddress: Gateway,
|
||||||
|
Address: Address,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: false);
|
||||||
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
|
await using var driver = new AbLegacyDriver(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Tag-name key the driver uses internally. Address+type is already unique.</summary>
|
||||||
|
internal static string SynthesiseTagName(string address, AbLegacyDataType type)
|
||||||
|
=> $"{address}:{type}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
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.AbLegacy.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Watch a PCCC file address via polled subscription until Ctrl+C. Mirrors the Modbus /
|
||||||
|
/// AB CIP subscribe shape — PollGroupEngine handles the tick loop.
|
||||||
|
/// </summary>
|
||||||
|
[Command("subscribe", Description = "Watch a PCCC file address via polled subscription until Ctrl+C.")]
|
||||||
|
public sealed class SubscribeCommand : AbLegacyCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("address", 'a', Description = "PCCC file address — same format as `read`.", IsRequired = true)]
|
||||||
|
public string Address { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("type", 't', Description =
|
||||||
|
"Bit / Int / Long / Float / AnalogInt / String / TimerElement / CounterElement / " +
|
||||||
|
"ControlElement (default Int).")]
|
||||||
|
public AbLegacyDataType DataType { get; init; } = AbLegacyDataType.Int;
|
||||||
|
|
||||||
|
[CommandOption("interval-ms", 'i', Description =
|
||||||
|
"Publishing interval in milliseconds (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 AbLegacyTagDefinition(
|
||||||
|
Name: tagName,
|
||||||
|
DeviceHostAddress: Gateway,
|
||||||
|
Address: Address,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: false);
|
||||||
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
|
await using var driver = new AbLegacyDriver(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,81 @@
|
|||||||
|
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.AbLegacy.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Write one value to a PCCC file address. Writes to timer / counter / control
|
||||||
|
/// sub-elements go through at the wire level but land on the integer field of the
|
||||||
|
/// sub-element — the PLC's runtime semantics (edge-triggered EN/DN bits, preset reloads)
|
||||||
|
/// are PLC-managed, not CLI-manipulable; write these with caution.
|
||||||
|
/// </summary>
|
||||||
|
[Command("write", Description = "Write a single PCCC file address.")]
|
||||||
|
public sealed class WriteCommand : AbLegacyCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("address", 'a', Description =
|
||||||
|
"PCCC file address — same format as `read`.", IsRequired = true)]
|
||||||
|
public string Address { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("type", 't', Description =
|
||||||
|
"Bit / Int / Long / Float / AnalogInt / String / TimerElement / CounterElement / " +
|
||||||
|
"ControlElement (default Int).")]
|
||||||
|
public AbLegacyDataType DataType { get; init; } = AbLegacyDataType.Int;
|
||||||
|
|
||||||
|
[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 AbLegacyTagDefinition(
|
||||||
|
Name: tagName,
|
||||||
|
DeviceHostAddress: Gateway,
|
||||||
|
Address: Address,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: true);
|
||||||
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
|
var parsed = ParseValue(Value, DataType);
|
||||||
|
|
||||||
|
await using var driver = new AbLegacyDriver(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Parse <c>--value</c> per <see cref="AbLegacyDataType"/>, invariant culture.</summary>
|
||||||
|
internal static object ParseValue(string raw, AbLegacyDataType type) => type switch
|
||||||
|
{
|
||||||
|
AbLegacyDataType.Bit => ParseBool(raw),
|
||||||
|
AbLegacyDataType.Int or AbLegacyDataType.AnalogInt => short.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
AbLegacyDataType.Long => int.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
AbLegacyDataType.Float => float.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
AbLegacyDataType.String => raw,
|
||||||
|
AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement
|
||||||
|
or AbLegacyDataType.ControlElement => int.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
_ => 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."),
|
||||||
|
};
|
||||||
|
}
|
||||||
11
src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Program.cs
Normal file
11
src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Program.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
using CliFx;
|
||||||
|
|
||||||
|
return await new CliApplicationBuilder()
|
||||||
|
.AddCommandsFromThisAssembly()
|
||||||
|
.SetExecutableName("otopcua-ablegacy-cli")
|
||||||
|
.SetDescription(
|
||||||
|
"OtOpcUa AB Legacy test-client — ad-hoc probe + PCCC N/F/B/L-file reads/writes + " +
|
||||||
|
"polled subscriptions against SLC 500 / MicroLogix / PLC-5 devices via libplctag. " +
|
||||||
|
"Addresses use PCCC convention: N7:0, F8:0, B3:0/3, L19:0, ST17:0.")
|
||||||
|
.Build()
|
||||||
|
.RunAsync(args);
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<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.AbLegacy.Cli</RootNamespace>
|
||||||
|
<AssemblyName>otopcua-ablegacy-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.AbLegacy\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
60
src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/DriverCommandBase.cs
Normal file
60
src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/DriverCommandBase.cs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
using CliFx;
|
||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared base for every driver test-client command (Modbus / AB CIP / AB Legacy / S7 /
|
||||||
|
/// TwinCAT). Carries the options that are meaningful regardless of protocol — verbose
|
||||||
|
/// logging + the standard timeout — plus helpers every command implementation wants:
|
||||||
|
/// Serilog configuration + cancellation-token capture.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Each driver CLI sub-classes this with its own protocol-specific base (e.g.
|
||||||
|
/// <c>ModbusCommandBase</c>) that adds host/port/unit-id + a <c>BuildDriver()</c>
|
||||||
|
/// factory. That second layer is the point where the driver's <c>{Driver}DriverOptions</c>
|
||||||
|
/// type plugs in; keeping it out of this common base lets each driver CLI stay a thin
|
||||||
|
/// executable with no dependency on the other drivers' projects.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Why a shared base at all — without this every CLI re-authored the same ~40 lines
|
||||||
|
/// of Serilog wiring + cancel-token plumbing + verbose flag.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public abstract class DriverCommandBase : ICommand
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Enable Serilog debug-level output. Leave off for clean one-line-per-call output;
|
||||||
|
/// switch on when diagnosing a connect / PDU-framing / retry problem.
|
||||||
|
/// </summary>
|
||||||
|
[CommandOption("verbose", Description = "Enable verbose/debug Serilog output")]
|
||||||
|
public bool Verbose { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request-level timeout used by the driver's <c>Initialize</c> / <c>Read</c> /
|
||||||
|
/// <c>Write</c> / probe calls. Defaults per-protocol (Modbus: 2s, AB: 5s, S7: 5s,
|
||||||
|
/// TwinCAT: 5s) — each driver CLI overrides this property with the appropriate
|
||||||
|
/// <c>[CommandOption]</c> default.
|
||||||
|
/// </summary>
|
||||||
|
public abstract TimeSpan Timeout { get; init; }
|
||||||
|
|
||||||
|
public abstract ValueTask ExecuteAsync(IConsole console);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures the process-global Serilog logger. Commands call this at the top of
|
||||||
|
/// <see cref="ExecuteAsync"/> so driver-internal <c>Log.Logger</c> writes land on the
|
||||||
|
/// same sink as the CLI's operator-facing output.
|
||||||
|
/// </summary>
|
||||||
|
protected void ConfigureLogging()
|
||||||
|
{
|
||||||
|
var config = new LoggerConfiguration();
|
||||||
|
if (Verbose)
|
||||||
|
config.MinimumLevel.Debug().WriteTo.Console();
|
||||||
|
else
|
||||||
|
config.MinimumLevel.Warning().WriteTo.Console();
|
||||||
|
Log.Logger = config.CreateLogger();
|
||||||
|
}
|
||||||
|
}
|
||||||
131
src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/SnapshotFormatter.cs
Normal file
131
src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/SnapshotFormatter.cs
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders <see cref="DataValueSnapshot"/> + <see cref="WriteResult"/> payloads as the
|
||||||
|
/// plain-text lines every driver CLI prints to its console. Matches the one-field-per-line
|
||||||
|
/// style the existing OPC UA <c>otopcua-cli</c> uses so combined runs (read a tag via both
|
||||||
|
/// CLIs side-by-side) look coherent.
|
||||||
|
/// </summary>
|
||||||
|
public static class SnapshotFormatter
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Single-tag multi-line render. Shape:
|
||||||
|
/// <code>
|
||||||
|
/// Tag: <name>
|
||||||
|
/// Value: <value>
|
||||||
|
/// Status: 0x... (Good|BadCommunicationError|...)
|
||||||
|
/// Source Time: 2026-04-21T12:34:56.789Z
|
||||||
|
/// Server Time: 2026-04-21T12:34:56.790Z
|
||||||
|
/// </code>
|
||||||
|
/// </summary>
|
||||||
|
public static string Format(string tagName, DataValueSnapshot snapshot)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(snapshot);
|
||||||
|
var lines = new[]
|
||||||
|
{
|
||||||
|
$"Tag: {tagName}",
|
||||||
|
$"Value: {FormatValue(snapshot.Value)}",
|
||||||
|
$"Status: {FormatStatus(snapshot.StatusCode)}",
|
||||||
|
$"Source Time: {FormatTimestamp(snapshot.SourceTimestampUtc)}",
|
||||||
|
$"Server Time: {FormatTimestamp(snapshot.ServerTimestampUtc)}",
|
||||||
|
};
|
||||||
|
return string.Join(Environment.NewLine, lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Write-result render, one line: <c>Write <tag>: 0x... (Good|...)</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static string FormatWrite(string tagName, WriteResult result)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(result);
|
||||||
|
return $"Write {tagName}: {FormatStatus(result.StatusCode)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Table-style render for batch reads. Emits an aligned 4-column layout:
|
||||||
|
/// tag / value / status / source-time.
|
||||||
|
/// </summary>
|
||||||
|
public static string FormatTable(
|
||||||
|
IReadOnlyList<string> tagNames, IReadOnlyList<DataValueSnapshot> snapshots)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(tagNames);
|
||||||
|
ArgumentNullException.ThrowIfNull(snapshots);
|
||||||
|
if (tagNames.Count != snapshots.Count)
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"tagNames ({tagNames.Count}) and snapshots ({snapshots.Count}) must be the same length");
|
||||||
|
|
||||||
|
var rows = tagNames.Select((t, i) => new
|
||||||
|
{
|
||||||
|
Tag = t,
|
||||||
|
Value = FormatValue(snapshots[i].Value),
|
||||||
|
Status = FormatStatus(snapshots[i].StatusCode),
|
||||||
|
Time = FormatTimestamp(snapshots[i].SourceTimestampUtc),
|
||||||
|
}).ToArray();
|
||||||
|
|
||||||
|
int tagW = Math.Max("TAG".Length, rows.Max(r => r.Tag.Length));
|
||||||
|
int valW = Math.Max("VALUE".Length, rows.Max(r => r.Value.Length));
|
||||||
|
int statW = Math.Max("STATUS".Length, rows.Max(r => r.Status.Length));
|
||||||
|
// source-time column is fixed-width (ISO-8601 to ms) so no max-measurement needed.
|
||||||
|
|
||||||
|
var sb = new System.Text.StringBuilder();
|
||||||
|
sb.Append("TAG".PadRight(tagW)).Append(" ")
|
||||||
|
.Append("VALUE".PadRight(valW)).Append(" ")
|
||||||
|
.Append("STATUS".PadRight(statW)).Append(" ")
|
||||||
|
.Append("SOURCE TIME").AppendLine();
|
||||||
|
sb.Append(new string('-', tagW)).Append(" ")
|
||||||
|
.Append(new string('-', valW)).Append(" ")
|
||||||
|
.Append(new string('-', statW)).Append(" ")
|
||||||
|
.Append(new string('-', "SOURCE TIME".Length)).AppendLine();
|
||||||
|
foreach (var r in rows)
|
||||||
|
{
|
||||||
|
sb.Append(r.Tag.PadRight(tagW)).Append(" ")
|
||||||
|
.Append(r.Value.PadRight(valW)).Append(" ")
|
||||||
|
.Append(r.Status.PadRight(statW)).Append(" ")
|
||||||
|
.Append(r.Time).AppendLine();
|
||||||
|
}
|
||||||
|
return sb.ToString().TrimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string FormatValue(object? value) => value switch
|
||||||
|
{
|
||||||
|
null => "<null>",
|
||||||
|
bool b => b ? "true" : "false",
|
||||||
|
string s => $"\"{s}\"",
|
||||||
|
IFormattable f => f.ToString(null, CultureInfo.InvariantCulture),
|
||||||
|
_ => value.ToString() ?? "<null>",
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string FormatStatus(uint statusCode)
|
||||||
|
{
|
||||||
|
// Match the OPC UA shorthand for the statuses most-likely to land in a CLI run.
|
||||||
|
// Anything outside this short-list surfaces as hex — operators can cross-reference
|
||||||
|
// against OPC UA Part 6 § 7.34 (StatusCode tables) or Core.Abstractions status mappers.
|
||||||
|
var name = statusCode switch
|
||||||
|
{
|
||||||
|
0x00000000u => "Good",
|
||||||
|
0x80000000u => "Bad",
|
||||||
|
0x80050000u => "BadCommunicationError",
|
||||||
|
0x80060000u => "BadTimeout",
|
||||||
|
0x80070000u => "BadNoCommunication",
|
||||||
|
0x80080000u => "BadWaitingForInitialData",
|
||||||
|
0x80340000u => "BadNodeIdUnknown",
|
||||||
|
0x80350000u => "BadNodeIdInvalid",
|
||||||
|
0x80740000u => "BadTypeMismatch",
|
||||||
|
0x40000000u => "Uncertain",
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
return name is null
|
||||||
|
? $"0x{statusCode:X8}"
|
||||||
|
: $"0x{statusCode:X8} ({name})";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string FormatTimestamp(DateTime? ts)
|
||||||
|
{
|
||||||
|
if (ts is null) return "-";
|
||||||
|
var utc = ts.Value.Kind == DateTimeKind.Utc ? ts.Value : ts.Value.ToUniversalTime();
|
||||||
|
return utc.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<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.Cli.Common</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="CliFx" Version="2.3.6"/>
|
||||||
|
<PackageReference Include="Serilog" Version="4.2.0"/>
|
||||||
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Ipc;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Static factory registration helper for <see cref="FocasDriver"/>. Server's Program.cs
|
||||||
|
/// calls <see cref="Register"/> once at startup; the bootstrapper (task #248) then
|
||||||
|
/// materialises FOCAS DriverInstance rows from the central config DB into live driver
|
||||||
|
/// instances. Mirrors <c>GalaxyProxyDriverFactoryExtensions</c>; no dependency on
|
||||||
|
/// Microsoft.Extensions.DependencyInjection so the driver project stays DI-free.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The DriverConfig JSON selects the <see cref="IFocasClientFactory"/> backend:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>"Backend": "ipc"</c> (default) — wires <see cref="IpcFocasClientFactory"/>
|
||||||
|
/// against a named-pipe <see cref="FocasIpcClient"/> talking to a separate
|
||||||
|
/// <c>Driver.FOCAS.Host</c> process (Tier-C isolation). Requires <c>PipeName</c> +
|
||||||
|
/// <c>SharedSecret</c>.</item>
|
||||||
|
/// <item><c>"Backend": "fwlib"</c> — direct in-process Fwlib32.dll P/Invoke via
|
||||||
|
/// <see cref="FwlibFocasClientFactory"/>. Use only when the main server is licensed
|
||||||
|
/// for FOCAS and you accept the native-crash blast-radius trade-off.</item>
|
||||||
|
/// <item><c>"Backend": "unimplemented"</c> — returns the no-op factory; useful for
|
||||||
|
/// scaffolding DriverInstance rows before the Host is deployed so the server boots.</item>
|
||||||
|
/// </list>
|
||||||
|
/// Devices / Tags / Probe / Timeout / Series come from the same JSON and feed directly
|
||||||
|
/// into <see cref="FocasDriverOptions"/>.
|
||||||
|
/// </remarks>
|
||||||
|
public static class FocasDriverFactoryExtensions
|
||||||
|
{
|
||||||
|
public const string DriverTypeName = "FOCAS";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Register the FOCAS driver factory in the supplied <see cref="DriverFactoryRegistry"/>.
|
||||||
|
/// Throws if 'FOCAS' is already registered — single-instance per process.
|
||||||
|
/// </summary>
|
||||||
|
public static void Register(DriverFactoryRegistry registry)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(registry);
|
||||||
|
registry.Register(DriverTypeName, CreateInstance);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static FocasDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||||
|
|
||||||
|
var dto = JsonSerializer.Deserialize<FocasDriverConfigDto>(driverConfigJson, JsonOptions)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"FOCAS driver config for '{driverInstanceId}' deserialised to null");
|
||||||
|
|
||||||
|
// Eager-validate top-level Series so a typo fails fast regardless of whether Devices
|
||||||
|
// are populated yet (common during rollout when rows are seeded before CNCs arrive).
|
||||||
|
_ = ParseSeries(dto.Series);
|
||||||
|
|
||||||
|
var options = new FocasDriverOptions
|
||||||
|
{
|
||||||
|
Devices = dto.Devices is { Count: > 0 }
|
||||||
|
? [.. dto.Devices.Select(d => new FocasDeviceOptions(
|
||||||
|
HostAddress: d.HostAddress ?? throw new InvalidOperationException(
|
||||||
|
$"FOCAS config for '{driverInstanceId}' has a device missing HostAddress"),
|
||||||
|
DeviceName: d.DeviceName,
|
||||||
|
Series: ParseSeries(d.Series ?? dto.Series)))]
|
||||||
|
: [],
|
||||||
|
Tags = dto.Tags is { Count: > 0 }
|
||||||
|
? [.. dto.Tags.Select(t => new FocasTagDefinition(
|
||||||
|
Name: t.Name ?? throw new InvalidOperationException(
|
||||||
|
$"FOCAS config for '{driverInstanceId}' has a tag missing Name"),
|
||||||
|
DeviceHostAddress: t.DeviceHostAddress ?? throw new InvalidOperationException(
|
||||||
|
$"FOCAS tag '{t.Name}' in '{driverInstanceId}' missing DeviceHostAddress"),
|
||||||
|
Address: t.Address ?? throw new InvalidOperationException(
|
||||||
|
$"FOCAS tag '{t.Name}' in '{driverInstanceId}' missing Address"),
|
||||||
|
DataType: ParseDataType(t.DataType, t.Name!, driverInstanceId),
|
||||||
|
Writable: t.Writable ?? true,
|
||||||
|
WriteIdempotent: t.WriteIdempotent ?? false))]
|
||||||
|
: [],
|
||||||
|
Probe = new FocasProbeOptions
|
||||||
|
{
|
||||||
|
Enabled = dto.Probe?.Enabled ?? true,
|
||||||
|
Interval = TimeSpan.FromMilliseconds(dto.Probe?.IntervalMs ?? 5_000),
|
||||||
|
Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000),
|
||||||
|
},
|
||||||
|
Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 2_000),
|
||||||
|
};
|
||||||
|
|
||||||
|
var clientFactory = BuildClientFactory(dto, driverInstanceId);
|
||||||
|
return new FocasDriver(options, driverInstanceId, clientFactory);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static IFocasClientFactory BuildClientFactory(
|
||||||
|
FocasDriverConfigDto dto, string driverInstanceId)
|
||||||
|
{
|
||||||
|
var backend = (dto.Backend ?? "ipc").Trim().ToLowerInvariant();
|
||||||
|
return backend switch
|
||||||
|
{
|
||||||
|
"ipc" => BuildIpcFactory(dto, driverInstanceId),
|
||||||
|
"fwlib" or "fwlib32" => new FwlibFocasClientFactory(),
|
||||||
|
"unimplemented" or "none" or "stub" => new UnimplementedFocasClientFactory(),
|
||||||
|
_ => throw new InvalidOperationException(
|
||||||
|
$"FOCAS driver config for '{driverInstanceId}' has unknown Backend '{dto.Backend}'. " +
|
||||||
|
"Expected one of: ipc, fwlib, unimplemented."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IpcFocasClientFactory BuildIpcFactory(
|
||||||
|
FocasDriverConfigDto dto, string driverInstanceId)
|
||||||
|
{
|
||||||
|
var pipeName = dto.PipeName
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"FOCAS driver config for '{driverInstanceId}' missing required PipeName (Tier-C ipc backend)");
|
||||||
|
var sharedSecret = dto.SharedSecret
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"FOCAS driver config for '{driverInstanceId}' missing required SharedSecret (Tier-C ipc backend)");
|
||||||
|
var connectTimeout = TimeSpan.FromMilliseconds(dto.ConnectTimeoutMs ?? 10_000);
|
||||||
|
var series = ParseSeries(dto.Series);
|
||||||
|
|
||||||
|
// Each IFocasClientFactory.Create() call opens a fresh pipe to the Host — matches the
|
||||||
|
// driver's one-client-per-device invariant. FocasIpcClient.ConnectAsync is awaited
|
||||||
|
// synchronously via GetAwaiter().GetResult() because IFocasClientFactory.Create is a
|
||||||
|
// sync contract; the blocking call lands inside FocasDriver.EnsureConnectedAsync,
|
||||||
|
// which immediately awaits IFocasClient.ConnectAsync afterwards so the perceived
|
||||||
|
// latency is identical to a fully-async factory.
|
||||||
|
return new IpcFocasClientFactory(
|
||||||
|
ipcClientFactory: () => FocasIpcClient.ConnectAsync(
|
||||||
|
pipeName: pipeName,
|
||||||
|
sharedSecret: sharedSecret,
|
||||||
|
connectTimeout: connectTimeout,
|
||||||
|
ct: CancellationToken.None).GetAwaiter().GetResult(),
|
||||||
|
series: series);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FocasCncSeries ParseSeries(string? raw)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(raw)) return FocasCncSeries.Unknown;
|
||||||
|
return Enum.TryParse<FocasCncSeries>(raw, ignoreCase: true, out var s)
|
||||||
|
? s
|
||||||
|
: throw new InvalidOperationException(
|
||||||
|
$"FOCAS Series '{raw}' is not one of {string.Join(", ", Enum.GetNames<FocasCncSeries>())}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FocasDataType ParseDataType(string? raw, string tagName, string driverInstanceId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"FOCAS tag '{tagName}' in '{driverInstanceId}' missing DataType");
|
||||||
|
return Enum.TryParse<FocasDataType>(raw, ignoreCase: true, out var dt)
|
||||||
|
? dt
|
||||||
|
: throw new InvalidOperationException(
|
||||||
|
$"FOCAS tag '{tagName}' has unknown DataType '{raw}'. " +
|
||||||
|
$"Expected one of {string.Join(", ", Enum.GetNames<FocasDataType>())}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||||
|
AllowTrailingCommas = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
internal sealed class FocasDriverConfigDto
|
||||||
|
{
|
||||||
|
public string? Backend { get; init; }
|
||||||
|
public string? PipeName { get; init; }
|
||||||
|
public string? SharedSecret { get; init; }
|
||||||
|
public int? ConnectTimeoutMs { get; init; }
|
||||||
|
public string? Series { get; init; }
|
||||||
|
public int? TimeoutMs { get; init; }
|
||||||
|
public List<FocasDeviceDto>? Devices { get; init; }
|
||||||
|
public List<FocasTagDto>? Tags { get; init; }
|
||||||
|
public FocasProbeDto? Probe { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class FocasDeviceDto
|
||||||
|
{
|
||||||
|
public string? HostAddress { get; init; }
|
||||||
|
public string? DeviceName { get; init; }
|
||||||
|
public string? Series { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class FocasTagDto
|
||||||
|
{
|
||||||
|
public string? Name { get; init; }
|
||||||
|
public string? DeviceHostAddress { get; init; }
|
||||||
|
public string? Address { get; init; }
|
||||||
|
public string? DataType { get; init; }
|
||||||
|
public bool? Writable { get; init; }
|
||||||
|
public bool? WriteIdempotent { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class FocasProbeDto
|
||||||
|
{
|
||||||
|
public bool? Enabled { get; init; }
|
||||||
|
public int? IntervalMs { get; init; }
|
||||||
|
public int? TimeoutMs { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj"/>
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Probes a Modbus-TCP endpoint: opens a socket via <see cref="ModbusDriver"/>'s
|
||||||
|
/// <c>InitializeAsync</c>, issues a single FC03 at the configured probe address, and
|
||||||
|
/// prints the driver's <c>GetHealth()</c>. Fastest way to answer "is the PLC up + talking
|
||||||
|
/// Modbus on this host:port?".
|
||||||
|
/// </summary>
|
||||||
|
[Command("probe", Description = "Verify the Modbus-TCP endpoint is reachable and speaks Modbus.")]
|
||||||
|
public sealed class ProbeCommand : ModbusCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("probe-address", Description =
|
||||||
|
"Holding-register address used as the cheap-read probe (default 0). Some PLCs lock " +
|
||||||
|
"register 0 — set this to a known-good address on your device.")]
|
||||||
|
public ushort ProbeAddress { get; init; }
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
// Build with one probe tag + Probe.Enabled=false so InitializeAsync connects the
|
||||||
|
// transport, we issue a single read to verify the device responds, then shut down.
|
||||||
|
var probeTag = new ModbusTagDefinition(
|
||||||
|
Name: "__probe",
|
||||||
|
Region: ModbusRegion.HoldingRegisters,
|
||||||
|
Address: ProbeAddress,
|
||||||
|
DataType: ModbusDataType.UInt16);
|
||||||
|
var options = BuildOptions([probeTag]);
|
||||||
|
|
||||||
|
await using var driver = new ModbusDriver(options, DriverInstanceId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
var snapshot = await driver.ReadAsync(["__probe"], ct);
|
||||||
|
var health = driver.GetHealth();
|
||||||
|
|
||||||
|
await console.Output.WriteLineAsync($"Host: {Host}:{Port} (unit {UnitId})");
|
||||||
|
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($"HR[{ProbeAddress}]", snapshot[0]));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read one Modbus register / coil. Operator specifies the address via
|
||||||
|
/// <c>--region</c> + <c>--address</c> + <c>--type</c>; the CLI synthesises a single
|
||||||
|
/// <see cref="ModbusTagDefinition"/>, spins up the driver, reads once, prints the snapshot,
|
||||||
|
/// and shuts down. Multi-register types (Int32 / Float32 / String / BCD32) respect
|
||||||
|
/// <c>--byte-order</c> the same way real driver configs do.
|
||||||
|
/// </summary>
|
||||||
|
[Command("read", Description = "Read a single Modbus register or coil.")]
|
||||||
|
public sealed class ReadCommand : ModbusCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("region", 'r', Description =
|
||||||
|
"Coils / DiscreteInputs / InputRegisters / HoldingRegisters", IsRequired = true)]
|
||||||
|
public ModbusRegion Region { get; init; }
|
||||||
|
|
||||||
|
[CommandOption("address", 'a', Description =
|
||||||
|
"Zero-based address within the region.", IsRequired = true)]
|
||||||
|
public ushort Address { get; init; }
|
||||||
|
|
||||||
|
[CommandOption("type", 't', Description =
|
||||||
|
"Bool / Int16 / UInt16 / Int32 / UInt32 / Int64 / UInt64 / Float32 / Float64 / " +
|
||||||
|
"BitInRegister / String / Bcd16 / Bcd32", IsRequired = true)]
|
||||||
|
public ModbusDataType DataType { get; init; }
|
||||||
|
|
||||||
|
[CommandOption("byte-order", Description =
|
||||||
|
"BigEndian (default, spec ABCD) or WordSwap (CDAB). Ignored for single-register types.")]
|
||||||
|
public ModbusByteOrder ByteOrder { get; init; } = ModbusByteOrder.BigEndian;
|
||||||
|
|
||||||
|
[CommandOption("bit-index", Description =
|
||||||
|
"For type=BitInRegister: bit 0-15 LSB-first.")]
|
||||||
|
public byte BitIndex { get; init; }
|
||||||
|
|
||||||
|
[CommandOption("string-length", Description =
|
||||||
|
"For type=String: character count (2 per register, rounded up).")]
|
||||||
|
public ushort StringLength { get; init; }
|
||||||
|
|
||||||
|
[CommandOption("string-byte-order", Description =
|
||||||
|
"For type=String: HighByteFirst (standard) or LowByteFirst (DirectLOGIC et al).")]
|
||||||
|
public ModbusStringByteOrder StringByteOrder { get; init; } = ModbusStringByteOrder.HighByteFirst;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
var tagName = SynthesiseTagName(Region, Address, DataType);
|
||||||
|
var tag = new ModbusTagDefinition(
|
||||||
|
Name: tagName,
|
||||||
|
Region: Region,
|
||||||
|
Address: Address,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: false,
|
||||||
|
ByteOrder: ByteOrder,
|
||||||
|
BitIndex: BitIndex,
|
||||||
|
StringLength: StringLength,
|
||||||
|
StringByteOrder: StringByteOrder);
|
||||||
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
|
await using var driver = new ModbusDriver(options, DriverInstanceId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
var snapshot = await driver.ReadAsync([tagName], ct);
|
||||||
|
await console.Output.WriteLineAsync(SnapshotFormatter.Format(tagName, snapshot[0]));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a human-readable tag name matching the operator's conceptual model
|
||||||
|
/// (<c>HR[100]</c>, <c>Coil[5]</c>, <c>IR[42]</c>) — the driver treats the name
|
||||||
|
/// purely as a lookup key, so any stable string works.
|
||||||
|
/// </summary>
|
||||||
|
internal static string SynthesiseTagName(
|
||||||
|
ModbusRegion region, ushort address, ModbusDataType type)
|
||||||
|
{
|
||||||
|
var prefix = region switch
|
||||||
|
{
|
||||||
|
ModbusRegion.Coils => "Coil",
|
||||||
|
ModbusRegion.DiscreteInputs => "DI",
|
||||||
|
ModbusRegion.InputRegisters => "IR",
|
||||||
|
ModbusRegion.HoldingRegisters => "HR",
|
||||||
|
_ => "Reg",
|
||||||
|
};
|
||||||
|
return $"{prefix}[{address}]:{type}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
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.Modbus.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Long-running poll of one Modbus register via the driver's <c>ISubscribable</c> surface
|
||||||
|
/// (under the hood: <c>PollGroupEngine</c>). Prints each data-change event until the
|
||||||
|
/// operator Ctrl+C's the CLI. Useful for watching a changing PLC signal during
|
||||||
|
/// commissioning or while reproducing a customer bug.
|
||||||
|
/// </summary>
|
||||||
|
[Command("subscribe", Description = "Watch a Modbus register via polled subscription until Ctrl+C.")]
|
||||||
|
public sealed class SubscribeCommand : ModbusCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("region", 'r', Description =
|
||||||
|
"Coils / DiscreteInputs / InputRegisters / HoldingRegisters", IsRequired = true)]
|
||||||
|
public ModbusRegion Region { get; init; }
|
||||||
|
|
||||||
|
[CommandOption("address", 'a', Description = "Zero-based address within the region.", IsRequired = true)]
|
||||||
|
public ushort Address { get; init; }
|
||||||
|
|
||||||
|
[CommandOption("type", 't', Description =
|
||||||
|
"Bool / Int16 / UInt16 / Int32 / UInt32 / Int64 / UInt64 / Float32 / Float64 / " +
|
||||||
|
"BitInRegister / String / Bcd16 / Bcd32", IsRequired = true)]
|
||||||
|
public ModbusDataType DataType { get; init; }
|
||||||
|
|
||||||
|
[CommandOption("interval-ms", 'i', Description =
|
||||||
|
"Publishing interval in milliseconds (default 1000). The PollGroupEngine enforces " +
|
||||||
|
"a floor of ~250ms; values below it get rounded up.")]
|
||||||
|
public int IntervalMs { get; init; } = 1000;
|
||||||
|
|
||||||
|
[CommandOption("byte-order", Description =
|
||||||
|
"BigEndian (default) or WordSwap.")]
|
||||||
|
public ModbusByteOrder ByteOrder { get; init; } = ModbusByteOrder.BigEndian;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
var tagName = ReadCommand.SynthesiseTagName(Region, Address, DataType);
|
||||||
|
var tag = new ModbusTagDefinition(
|
||||||
|
Name: tagName,
|
||||||
|
Region: Region,
|
||||||
|
Address: Address,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: false,
|
||||||
|
ByteOrder: ByteOrder);
|
||||||
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
|
await using var driver = new ModbusDriver(options, DriverInstanceId);
|
||||||
|
ISubscriptionHandle? handle = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
|
||||||
|
// Route every data-change event to the CliFx console (not System.Console — the
|
||||||
|
// analyzer flags it + IConsole is the testable abstraction).
|
||||||
|
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 {tagName} @ {IntervalMs}ms. Ctrl+C to stop.");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, ct);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Expected on Ctrl+C — fall through to the unsubscribe in finally.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (handle is not null)
|
||||||
|
{
|
||||||
|
try { await driver.UnsubscribeAsync(handle, CancellationToken.None); }
|
||||||
|
catch { /* teardown best-effort */ }
|
||||||
|
}
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
118
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/WriteCommand.cs
Normal file
118
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/WriteCommand.cs
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
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.Modbus.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Write one value to a Modbus coil or holding register. Mirrors <see cref="ReadCommand"/>'s
|
||||||
|
/// region / address / type flags + adds <c>--value</c>. Input parsing respects the
|
||||||
|
/// declared <c>--type</c> so you can write <c>--value=3.14 --type=Float32</c> without
|
||||||
|
/// hex-encoding floats. The write is non-idempotent by default (driver's
|
||||||
|
/// <c>WriteIdempotent=false</c>) — replay is the operator's choice, not the driver's.
|
||||||
|
/// </summary>
|
||||||
|
[Command("write", Description = "Write a single Modbus coil or holding register.")]
|
||||||
|
public sealed class WriteCommand : ModbusCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("region", 'r', Description =
|
||||||
|
"Coils or HoldingRegisters (the only writable regions per the protocol spec).",
|
||||||
|
IsRequired = true)]
|
||||||
|
public ModbusRegion Region { get; init; }
|
||||||
|
|
||||||
|
[CommandOption("address", 'a', Description =
|
||||||
|
"Zero-based address within the region.", IsRequired = true)]
|
||||||
|
public ushort Address { get; init; }
|
||||||
|
|
||||||
|
[CommandOption("type", 't', Description =
|
||||||
|
"Bool / Int16 / UInt16 / Int32 / UInt32 / Int64 / UInt64 / Float32 / Float64 / " +
|
||||||
|
"BitInRegister / String / Bcd16 / Bcd32", IsRequired = true)]
|
||||||
|
public ModbusDataType DataType { get; init; }
|
||||||
|
|
||||||
|
[CommandOption("value", 'v', Description =
|
||||||
|
"Value to write. Parsed per --type (booleans accept true/false/0/1).",
|
||||||
|
IsRequired = true)]
|
||||||
|
public string Value { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("byte-order", Description =
|
||||||
|
"BigEndian (default, ABCD) or WordSwap (CDAB). Ignored for single-register types.")]
|
||||||
|
public ModbusByteOrder ByteOrder { get; init; } = ModbusByteOrder.BigEndian;
|
||||||
|
|
||||||
|
[CommandOption("bit-index", Description =
|
||||||
|
"For type=BitInRegister: which bit of the holding register (0-15, LSB-first).")]
|
||||||
|
public byte BitIndex { get; init; }
|
||||||
|
|
||||||
|
[CommandOption("string-length", Description =
|
||||||
|
"For type=String: character count (2 per register, rounded up).")]
|
||||||
|
public ushort StringLength { get; init; }
|
||||||
|
|
||||||
|
[CommandOption("string-byte-order", Description =
|
||||||
|
"For type=String: HighByteFirst (standard) or LowByteFirst (DirectLOGIC).")]
|
||||||
|
public ModbusStringByteOrder StringByteOrder { get; init; } = ModbusStringByteOrder.HighByteFirst;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
if (Region is not (ModbusRegion.Coils or ModbusRegion.HoldingRegisters))
|
||||||
|
throw new CliFx.Exceptions.CommandException(
|
||||||
|
$"Region '{Region}' is read-only in the Modbus spec; writes require Coils or HoldingRegisters.");
|
||||||
|
|
||||||
|
var tagName = ReadCommand.SynthesiseTagName(Region, Address, DataType);
|
||||||
|
var tag = new ModbusTagDefinition(
|
||||||
|
Name: tagName,
|
||||||
|
Region: Region,
|
||||||
|
Address: Address,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: true,
|
||||||
|
ByteOrder: ByteOrder,
|
||||||
|
BitIndex: BitIndex,
|
||||||
|
StringLength: StringLength,
|
||||||
|
StringByteOrder: StringByteOrder);
|
||||||
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
|
var parsed = ParseValue(Value, DataType);
|
||||||
|
|
||||||
|
await using var driver = new ModbusDriver(options, DriverInstanceId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
|
||||||
|
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(tagName, results[0]));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse the operator's <c>--value</c> string into the CLR type the driver expects
|
||||||
|
/// for the declared <see cref="ModbusDataType"/>. Uses invariant culture everywhere
|
||||||
|
/// so <c>3.14</c> and <c>3,14</c> don't swap meaning between runs.
|
||||||
|
/// </summary>
|
||||||
|
internal static object ParseValue(string raw, ModbusDataType type) => type switch
|
||||||
|
{
|
||||||
|
ModbusDataType.Bool or ModbusDataType.BitInRegister => ParseBool(raw),
|
||||||
|
ModbusDataType.Int16 => short.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
ModbusDataType.UInt16 or ModbusDataType.Bcd16 => ushort.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
ModbusDataType.Int32 => int.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
ModbusDataType.UInt32 or ModbusDataType.Bcd32 => uint.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
ModbusDataType.Int64 => long.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
ModbusDataType.UInt64 => ulong.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
ModbusDataType.Float32 => float.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
ModbusDataType.Float64 => double.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
ModbusDataType.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."),
|
||||||
|
};
|
||||||
|
}
|
||||||
60
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/ModbusCommandBase.cs
Normal file
60
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/ModbusCommandBase.cs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base for every Modbus CLI command. Carries the Modbus-TCP endpoint options
|
||||||
|
/// (host / port / unit-id) on top of <see cref="DriverCommandBase"/>'s verbose + timeout
|
||||||
|
/// + logging helpers, and exposes <see cref="BuildOptions"/> so each command can turn its
|
||||||
|
/// parsed flags into a <see cref="ModbusDriverOptions"/> ready to hand to the driver ctor.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class ModbusCommandBase : DriverCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("host", 'h', Description = "Modbus-TCP server hostname or IP", IsRequired = true)]
|
||||||
|
public string Host { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("port", 'p', Description = "Modbus-TCP port (default 502)")]
|
||||||
|
public int Port { get; init; } = 502;
|
||||||
|
|
||||||
|
[CommandOption("unit-id", 'U', Description = "Modbus unit / slave ID (1-247, default 1)")]
|
||||||
|
public byte UnitId { get; init; } = 1;
|
||||||
|
|
||||||
|
[CommandOption("timeout-ms", Description = "Per-PDU timeout in milliseconds (default 2000)")]
|
||||||
|
public int TimeoutMs { get; init; } = 2000;
|
||||||
|
|
||||||
|
[CommandOption("disable-reconnect", Description =
|
||||||
|
"Disable the built-in mid-transaction reconnect-and-retry. Matches the driver's " +
|
||||||
|
"AutoReconnect=false setting — use when diagnosing socket teardown behaviour.")]
|
||||||
|
public bool DisableAutoReconnect { get; init; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override TimeSpan Timeout
|
||||||
|
{
|
||||||
|
get => TimeSpan.FromMilliseconds(TimeoutMs);
|
||||||
|
init { /* driven by TimeoutMs property; setter required to satisfy base's init contract */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Construct a <see cref="ModbusDriverOptions"/> with the endpoint fields this base
|
||||||
|
/// collected + whatever <paramref name="tags"/> the subclass declares. Probe is
|
||||||
|
/// disabled — CLI runs are one-shot, the probe loop would race the operator's
|
||||||
|
/// command against its own keep-alive reads.
|
||||||
|
/// </summary>
|
||||||
|
protected ModbusDriverOptions BuildOptions(IReadOnlyList<ModbusTagDefinition> tags) => new()
|
||||||
|
{
|
||||||
|
Host = Host,
|
||||||
|
Port = Port,
|
||||||
|
UnitId = UnitId,
|
||||||
|
Timeout = Timeout,
|
||||||
|
AutoReconnect = !DisableAutoReconnect,
|
||||||
|
Tags = tags,
|
||||||
|
Probe = new ModbusProbeOptions { Enabled = false },
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Short instance id used in Serilog output so operators running the CLI against
|
||||||
|
/// multiple endpoints in parallel can distinguish the logs.
|
||||||
|
/// </summary>
|
||||||
|
protected string DriverInstanceId => $"modbus-cli-{Host}:{Port}";
|
||||||
|
}
|
||||||
11
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Program.cs
Normal file
11
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Program.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
using CliFx;
|
||||||
|
|
||||||
|
return await new CliApplicationBuilder()
|
||||||
|
.AddCommandsFromThisAssembly()
|
||||||
|
.SetExecutableName("otopcua-modbus-cli")
|
||||||
|
.SetDescription(
|
||||||
|
"OtOpcUa Modbus test-client — ad-hoc connectivity + register reads/writes + polled " +
|
||||||
|
"subscriptions against Modbus-TCP devices. Mirrors the otopcua-cli shape for v1-style " +
|
||||||
|
"manual validation against PLCs + the integration fixture. See docs/Driver.Modbus.Cli.md.")
|
||||||
|
.Build()
|
||||||
|
.RunAsync(args);
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<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.Modbus.Cli</RootNamespace>
|
||||||
|
<AssemblyName>otopcua-modbus-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.Modbus\ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
56
src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/ProbeCommand.cs
Normal file
56
src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/ProbeCommand.cs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Probes an S7 endpoint: connects via S7.Net, reads one merker word, prints health.
|
||||||
|
/// If the PLC is fresh out of TIA Portal the probe will surface
|
||||||
|
/// <c>BadNotSupported</c> — PUT/GET communication has to be enabled in the hardware
|
||||||
|
/// config for any S7-1200/1500 for the driver to get past the handshake.
|
||||||
|
/// </summary>
|
||||||
|
[Command("probe", Description = "Verify the S7 endpoint is reachable and a sample read succeeds.")]
|
||||||
|
public sealed class ProbeCommand : S7CommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("address", 'a', Description =
|
||||||
|
"Probe address (default MW0 — merker word 0). DB1.DBW0 if your PLC project " +
|
||||||
|
"reserves a fingerprint DB.")]
|
||||||
|
public string Address { get; init; } = "MW0";
|
||||||
|
|
||||||
|
[CommandOption("type", Description = "Probe data type (default Int16).")]
|
||||||
|
public S7DataType DataType { get; init; } = S7DataType.Int16;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
var probeTag = new S7TagDefinition(
|
||||||
|
Name: "__probe",
|
||||||
|
Address: Address,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: false);
|
||||||
|
var options = BuildOptions([probeTag]);
|
||||||
|
|
||||||
|
await using var driver = new S7Driver(options, DriverInstanceId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
var snapshot = await driver.ReadAsync(["__probe"], ct);
|
||||||
|
var health = driver.GetHealth();
|
||||||
|
|
||||||
|
await console.Output.WriteLineAsync($"Host: {Host}:{Port}");
|
||||||
|
await console.Output.WriteLineAsync($"CPU: {CpuType} rack={Rack} slot={Slot}");
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/ReadCommand.cs
Normal file
61
src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/ReadCommand.cs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read one S7 address (DB / M / I / Q area). Addresses use S7.Net grammar — the driver
|
||||||
|
/// parses them via <c>S7AddressParser</c> so whatever the server accepts the CLI accepts
|
||||||
|
/// too.
|
||||||
|
/// </summary>
|
||||||
|
[Command("read", Description = "Read a single S7 address.")]
|
||||||
|
public sealed class ReadCommand : S7CommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("address", 'a', Description =
|
||||||
|
"S7 address. Examples: DB1.DBW0 (DB1, word 0); M0.0 (merker bit); IW4 (input word 4); " +
|
||||||
|
"QD8 (output dword 8); DB2.DBD20 (DB2, dword 20); DB5.DBX4.3 (DB5, byte 4, bit 3); " +
|
||||||
|
"DB10.STRING[0] (DB10 string). Bit addresses use dot notation.",
|
||||||
|
IsRequired = true)]
|
||||||
|
public string Address { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("type", 't', Description =
|
||||||
|
"Bool / Byte / Int16 / UInt16 / Int32 / UInt32 / Int64 / UInt64 / Float32 / Float64 / " +
|
||||||
|
"String / DateTime (default Int16).")]
|
||||||
|
public S7DataType DataType { get; init; } = S7DataType.Int16;
|
||||||
|
|
||||||
|
[CommandOption("string-length", Description =
|
||||||
|
"For type=String: S7-string max length (default 254, S7 max).")]
|
||||||
|
public int StringLength { get; init; } = 254;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
var tagName = SynthesiseTagName(Address, DataType);
|
||||||
|
var tag = new S7TagDefinition(
|
||||||
|
Name: tagName,
|
||||||
|
Address: Address,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: false,
|
||||||
|
StringLength: StringLength);
|
||||||
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
|
await using var driver = new S7Driver(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Tag-name key used internally. Address + type is already unique.</summary>
|
||||||
|
internal static string SynthesiseTagName(string address, S7DataType 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.S7.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Watch an S7 address via polled subscription until Ctrl+C. S7comm has no native push
|
||||||
|
/// model so this goes through <c>PollGroupEngine</c> same as Modbus / AB.
|
||||||
|
/// </summary>
|
||||||
|
[Command("subscribe", Description = "Watch an S7 address via polled subscription until Ctrl+C.")]
|
||||||
|
public sealed class SubscribeCommand : S7CommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("address", 'a', Description = "S7 address — same format as `read`.", IsRequired = true)]
|
||||||
|
public string Address { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("type", 't', Description =
|
||||||
|
"Bool / Byte / Int16 / UInt16 / Int32 / UInt32 / Int64 / UInt64 / Float32 / Float64 / " +
|
||||||
|
"String / DateTime (default Int16).")]
|
||||||
|
public S7DataType DataType { get; init; } = S7DataType.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 S7TagDefinition(
|
||||||
|
Name: tagName,
|
||||||
|
Address: Address,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: false);
|
||||||
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
|
await using var driver = new S7Driver(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/WriteCommand.cs
Normal file
89
src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/WriteCommand.cs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
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.S7.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Write one value to an S7 address. Mirrors <see cref="ReadCommand"/>'s flag shape.
|
||||||
|
/// Writes to M (merker) bits or Q (output) coils that drive edge-triggered routines
|
||||||
|
/// are real — be careful what you hit on a running PLC.
|
||||||
|
/// </summary>
|
||||||
|
[Command("write", Description = "Write a single S7 address.")]
|
||||||
|
public sealed class WriteCommand : S7CommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("address", 'a', Description =
|
||||||
|
"S7 address — same format as `read`.", IsRequired = true)]
|
||||||
|
public string Address { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("type", 't', Description =
|
||||||
|
"Bool / Byte / Int16 / UInt16 / Int32 / UInt32 / Int64 / UInt64 / Float32 / Float64 / " +
|
||||||
|
"String / DateTime (default Int16).")]
|
||||||
|
public S7DataType DataType { get; init; } = S7DataType.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!;
|
||||||
|
|
||||||
|
[CommandOption("string-length", Description =
|
||||||
|
"For type=String: S7-string max length (default 254).")]
|
||||||
|
public int StringLength { get; init; } = 254;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
var tagName = ReadCommand.SynthesiseTagName(Address, DataType);
|
||||||
|
var tag = new S7TagDefinition(
|
||||||
|
Name: tagName,
|
||||||
|
Address: Address,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: true,
|
||||||
|
StringLength: StringLength);
|
||||||
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
|
var parsed = ParseValue(Value, DataType);
|
||||||
|
|
||||||
|
await using var driver = new S7Driver(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Parse <c>--value</c> per <see cref="S7DataType"/>, invariant culture throughout.</summary>
|
||||||
|
internal static object ParseValue(string raw, S7DataType type) => type switch
|
||||||
|
{
|
||||||
|
S7DataType.Bool => ParseBool(raw),
|
||||||
|
S7DataType.Byte => byte.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
S7DataType.Int16 => short.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
S7DataType.UInt16 => ushort.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
S7DataType.Int32 => int.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
S7DataType.UInt32 => uint.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
S7DataType.Int64 => long.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
S7DataType.UInt64 => ulong.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
S7DataType.Float32 => float.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
S7DataType.Float64 => double.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
S7DataType.String => raw,
|
||||||
|
S7DataType.DateTime => DateTime.Parse(raw, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind),
|
||||||
|
_ => 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."),
|
||||||
|
};
|
||||||
|
}
|
||||||
11
src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Program.cs
Normal file
11
src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Program.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
using CliFx;
|
||||||
|
|
||||||
|
return await new CliApplicationBuilder()
|
||||||
|
.AddCommandsFromThisAssembly()
|
||||||
|
.SetExecutableName("otopcua-s7-cli")
|
||||||
|
.SetDescription(
|
||||||
|
"OtOpcUa S7 test-client — ad-hoc probe + S7comm reads/writes + polled subscriptions " +
|
||||||
|
"against Siemens S7-300 / S7-400 / S7-1200 / S7-1500 (and compatible soft-PLCs) via " +
|
||||||
|
"S7.Net / ISO-on-TCP port 102. Addresses use S7.Net syntax: DB1.DBW0, M0.0, IW4, QD8.")
|
||||||
|
.Build()
|
||||||
|
.RunAsync(args);
|
||||||
61
src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/S7CommandBase.cs
Normal file
61
src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/S7CommandBase.cs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
using S7NetCpuType = global::S7.Net.CpuType;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base for every S7 CLI command. Carries the ISO-on-TCP endpoint options
|
||||||
|
/// (host / port / CPU type / rack / slot) that S7.Net needs for its handshake +
|
||||||
|
/// exposes <see cref="BuildOptions"/> so each command can synthesise an
|
||||||
|
/// <see cref="S7DriverOptions"/> on demand.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class S7CommandBase : DriverCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("host", 'h', Description = "PLC IP address or hostname.", IsRequired = true)]
|
||||||
|
public string Host { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("port", 'p', Description = "ISO-on-TCP port (default 102).")]
|
||||||
|
public int Port { get; init; } = 102;
|
||||||
|
|
||||||
|
[CommandOption("cpu", 'c', Description =
|
||||||
|
"S7 CPU family: S7200 / S7200Smart / S7300 / S7400 / S71200 / S71500 " +
|
||||||
|
"(default S71500). Determines the ISO-TSAP slot byte.")]
|
||||||
|
public S7NetCpuType CpuType { get; init; } = S7NetCpuType.S71500;
|
||||||
|
|
||||||
|
[CommandOption("rack", Description = "Rack number (default 0 — single-rack).")]
|
||||||
|
public short Rack { get; init; } = 0;
|
||||||
|
|
||||||
|
[CommandOption("slot", Description =
|
||||||
|
"CPU slot. S7-300 = 2, S7-400 = 2 or 3, S7-1200 / S7-1500 = 0 (default 0).")]
|
||||||
|
public short Slot { get; init; } = 0;
|
||||||
|
|
||||||
|
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
|
||||||
|
public int TimeoutMs { get; init; } = 5000;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override TimeSpan Timeout
|
||||||
|
{
|
||||||
|
get => TimeSpan.FromMilliseconds(TimeoutMs);
|
||||||
|
init { /* driven by TimeoutMs */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build an <see cref="S7DriverOptions"/> with the endpoint fields this base
|
||||||
|
/// collected + whatever <paramref name="tags"/> the subclass declares. Probe
|
||||||
|
/// disabled — CLI runs are one-shot.
|
||||||
|
/// </summary>
|
||||||
|
protected S7DriverOptions BuildOptions(IReadOnlyList<S7TagDefinition> tags) => new()
|
||||||
|
{
|
||||||
|
Host = Host,
|
||||||
|
Port = Port,
|
||||||
|
CpuType = CpuType,
|
||||||
|
Rack = Rack,
|
||||||
|
Slot = Slot,
|
||||||
|
Timeout = Timeout,
|
||||||
|
Tags = tags,
|
||||||
|
Probe = new S7ProbeOptions { Enabled = false },
|
||||||
|
};
|
||||||
|
|
||||||
|
protected string DriverInstanceId => $"s7-cli-{Host}:{Port}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<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.S7.Cli</RootNamespace>
|
||||||
|
<AssemblyName>otopcua-s7-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.S7\ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Probes a TwinCAT runtime: opens an ADS session, reads one symbol, prints driver health.
|
||||||
|
/// Use this first after configuring a new AMS route — it'll surface "no route" /
|
||||||
|
/// "port unreachable" / "AMS router down" errors up-front before you bring the OtOpcUa
|
||||||
|
/// server near the endpoint.
|
||||||
|
/// </summary>
|
||||||
|
[Command("probe", Description = "Verify the TwinCAT runtime is reachable and a sample symbol reads.")]
|
||||||
|
public sealed class ProbeCommand : TwinCATCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("symbol", 's', Description =
|
||||||
|
"Symbol path to probe. System-global examples: " +
|
||||||
|
"'TwinCAT_SystemInfoVarList._AppInfo.OnlineChangeCnt', 'MAIN.bRunning'. " +
|
||||||
|
"User-project: a GVL or program variable.",
|
||||||
|
IsRequired = true)]
|
||||||
|
public string SymbolPath { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("type", Description = "Data type (default DInt — TwinCAT DINT maps to int32).")]
|
||||||
|
public TwinCATDataType DataType { get; init; } = TwinCATDataType.DInt;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
var probeTag = new TwinCATTagDefinition(
|
||||||
|
Name: "__probe",
|
||||||
|
DeviceHostAddress: Gateway,
|
||||||
|
SymbolPath: SymbolPath,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: false);
|
||||||
|
var options = BuildOptions([probeTag]);
|
||||||
|
|
||||||
|
await using var driver = new TwinCATDriver(options, DriverInstanceId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
var snapshot = await driver.ReadAsync(["__probe"], ct);
|
||||||
|
var health = driver.GetHealth();
|
||||||
|
|
||||||
|
await console.Output.WriteLineAsync($"AMS: {AmsNetId}:{AmsPort}");
|
||||||
|
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(SymbolPath, snapshot[0]));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read one TwinCAT symbol by path. Structure writes/reads are out of scope — fan the
|
||||||
|
/// member list into individual reads if you need them.
|
||||||
|
/// </summary>
|
||||||
|
[Command("read", Description = "Read a single TwinCAT symbol.")]
|
||||||
|
public sealed class ReadCommand : TwinCATCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("symbol", 's', Description =
|
||||||
|
"Symbol path. Program scope: 'MAIN.bStart'. Global: 'GVL.Counter'. " +
|
||||||
|
"Nested UDT member: 'Motor1.Status.Running'. Array element: 'Recipe[3]'.",
|
||||||
|
IsRequired = true)]
|
||||||
|
public string SymbolPath { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("type", 't', Description =
|
||||||
|
"Bool / SInt / USInt / Int / UInt / DInt / UDInt / LInt / ULInt / Real / LReal / " +
|
||||||
|
"String / WString / Time / Date / DateTime / TimeOfDay (default DInt).")]
|
||||||
|
public TwinCATDataType DataType { get; init; } = TwinCATDataType.DInt;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
var tagName = SynthesiseTagName(SymbolPath, DataType);
|
||||||
|
var tag = new TwinCATTagDefinition(
|
||||||
|
Name: tagName,
|
||||||
|
DeviceHostAddress: Gateway,
|
||||||
|
SymbolPath: SymbolPath,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: false);
|
||||||
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
|
await using var driver = new TwinCATDriver(options, DriverInstanceId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
var snapshot = await driver.ReadAsync([tagName], ct);
|
||||||
|
await console.Output.WriteLineAsync(SnapshotFormatter.Format(SymbolPath, snapshot[0]));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string SynthesiseTagName(string symbolPath, TwinCATDataType type)
|
||||||
|
=> $"{symbolPath}:{type}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
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.TwinCAT.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Watch a TwinCAT symbol until Ctrl+C. Native ADS notifications by default (TwinCAT
|
||||||
|
/// pushes on its own cycle); pass <c>--poll-only</c> to fall through to PollGroupEngine.
|
||||||
|
/// </summary>
|
||||||
|
[Command("subscribe", Description = "Watch a TwinCAT symbol via ADS notification or poll, until Ctrl+C.")]
|
||||||
|
public sealed class SubscribeCommand : TwinCATCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("symbol", 's', Description = "Symbol path — same format as `read`.", IsRequired = true)]
|
||||||
|
public string SymbolPath { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("type", 't', Description =
|
||||||
|
"Bool / SInt / USInt / Int / UInt / DInt / UDInt / LInt / ULInt / Real / LReal / " +
|
||||||
|
"String / WString / Time / Date / DateTime / TimeOfDay (default DInt).")]
|
||||||
|
public TwinCATDataType DataType { get; init; } = TwinCATDataType.DInt;
|
||||||
|
|
||||||
|
[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(SymbolPath, DataType);
|
||||||
|
var tag = new TwinCATTagDefinition(
|
||||||
|
Name: tagName,
|
||||||
|
DeviceHostAddress: Gateway,
|
||||||
|
SymbolPath: SymbolPath,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: false);
|
||||||
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
|
await using var driver = new TwinCATDriver(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);
|
||||||
|
|
||||||
|
var mode = PollOnly ? "polling" : "ADS notification";
|
||||||
|
await console.Output.WriteLineAsync(
|
||||||
|
$"Subscribed to {SymbolPath} @ {IntervalMs}ms ({mode}). 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,94 @@
|
|||||||
|
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.TwinCAT.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Write one value to a TwinCAT symbol. Structure writes refused — drop to driver config
|
||||||
|
/// JSON for those.
|
||||||
|
/// </summary>
|
||||||
|
[Command("write", Description = "Write a single TwinCAT symbol.")]
|
||||||
|
public sealed class WriteCommand : TwinCATCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("symbol", 's', Description =
|
||||||
|
"Symbol path — same format as `read`.", IsRequired = true)]
|
||||||
|
public string SymbolPath { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("type", 't', Description =
|
||||||
|
"Bool / SInt / USInt / Int / UInt / DInt / UDInt / LInt / ULInt / Real / LReal / " +
|
||||||
|
"String / WString / Time / Date / DateTime / TimeOfDay (default DInt).")]
|
||||||
|
public TwinCATDataType DataType { get; init; } = TwinCATDataType.DInt;
|
||||||
|
|
||||||
|
[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();
|
||||||
|
|
||||||
|
if (DataType == TwinCATDataType.Structure)
|
||||||
|
throw new CliFx.Exceptions.CommandException(
|
||||||
|
"Structure (UDT) writes need an explicit member layout — drop to the driver's " +
|
||||||
|
"config JSON for those. The CLI covers atomic types only.");
|
||||||
|
|
||||||
|
var tagName = ReadCommand.SynthesiseTagName(SymbolPath, DataType);
|
||||||
|
var tag = new TwinCATTagDefinition(
|
||||||
|
Name: tagName,
|
||||||
|
DeviceHostAddress: Gateway,
|
||||||
|
SymbolPath: SymbolPath,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: true);
|
||||||
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
|
var parsed = ParseValue(Value, DataType);
|
||||||
|
|
||||||
|
await using var driver = new TwinCATDriver(options, DriverInstanceId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
|
||||||
|
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(SymbolPath, results[0]));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Parse <c>--value</c> per <see cref="TwinCATDataType"/>, invariant culture.</summary>
|
||||||
|
internal static object ParseValue(string raw, TwinCATDataType type) => type switch
|
||||||
|
{
|
||||||
|
TwinCATDataType.Bool => ParseBool(raw),
|
||||||
|
TwinCATDataType.SInt => sbyte.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
TwinCATDataType.USInt => byte.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
TwinCATDataType.Int => short.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
TwinCATDataType.UInt => ushort.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
TwinCATDataType.DInt => int.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
TwinCATDataType.UDInt => uint.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
TwinCATDataType.LInt => long.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
TwinCATDataType.ULInt => ulong.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
TwinCATDataType.Real => float.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
TwinCATDataType.LReal => double.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
TwinCATDataType.String or TwinCATDataType.WString => raw,
|
||||||
|
// IEC 61131-3 time/date types are stored as UDINT on the wire — accept a numeric raw
|
||||||
|
// value + let the caller handle the encoding semantics.
|
||||||
|
TwinCATDataType.Time or TwinCATDataType.Date
|
||||||
|
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay
|
||||||
|
=> uint.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
_ => 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."),
|
||||||
|
};
|
||||||
|
}
|
||||||
12
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Program.cs
Normal file
12
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Program.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
using CliFx;
|
||||||
|
|
||||||
|
return await new CliApplicationBuilder()
|
||||||
|
.AddCommandsFromThisAssembly()
|
||||||
|
.SetExecutableName("otopcua-twincat-cli")
|
||||||
|
.SetDescription(
|
||||||
|
"OtOpcUa TwinCAT test-client — ad-hoc probe + ADS symbolic reads/writes + " +
|
||||||
|
"subscriptions against Beckhoff TwinCAT 2/3 runtimes. Requires a reachable AMS " +
|
||||||
|
"router (local TwinCAT XAR or the Beckhoff.TwinCAT.Ads.TcpRouter NuGet). Addresses " +
|
||||||
|
"use symbolic paths: MAIN.bStart, GVL.Counter, Motor1.Status.Running.")
|
||||||
|
.Build()
|
||||||
|
.RunAsync(args);
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base for every TwinCAT CLI command. Carries the AMS target options
|
||||||
|
/// (<c>--ams-net-id</c> + <c>--ams-port</c>) + the notification-mode toggle that the
|
||||||
|
/// driver itself takes. Exposes <see cref="BuildOptions"/> so each command can build a
|
||||||
|
/// single-device / single-tag <see cref="TwinCATDriverOptions"/> from flag input.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class TwinCATCommandBase : DriverCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("ams-net-id", 'n', Description =
|
||||||
|
"AMS Net ID of the target runtime (e.g. '192.168.1.40.1.1' or '127.0.0.1.1.1' for local).",
|
||||||
|
IsRequired = true)]
|
||||||
|
public string AmsNetId { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("ams-port", 'p', Description =
|
||||||
|
"AMS port. TwinCAT 3 PLC runtime defaults to 851; TwinCAT 2 uses 801.")]
|
||||||
|
public int AmsPort { get; init; } = 851;
|
||||||
|
|
||||||
|
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
|
||||||
|
public int TimeoutMs { get; init; } = 5000;
|
||||||
|
|
||||||
|
[CommandOption("poll-only", Description =
|
||||||
|
"Disable native ADS notifications and fall through to the shared PollGroupEngine " +
|
||||||
|
"(same as setting UseNativeNotifications=false in a real driver config).")]
|
||||||
|
public bool PollOnly { get; init; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override TimeSpan Timeout
|
||||||
|
{
|
||||||
|
get => TimeSpan.FromMilliseconds(TimeoutMs);
|
||||||
|
init { /* driven by TimeoutMs */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Canonical TwinCAT gateway string the driver's <c>TwinCATAmsAddress.TryParse</c>
|
||||||
|
/// consumes — shape <c>ads://{AmsNetId}:{AmsPort}</c>.
|
||||||
|
/// </summary>
|
||||||
|
protected string Gateway => $"ads://{AmsNetId}:{AmsPort}";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build a <see cref="TwinCATDriverOptions"/> with the AMS target this base collected +
|
||||||
|
/// the tag list a subclass supplies. Probe disabled, controller-browse disabled,
|
||||||
|
/// native notifications toggled by <see cref="PollOnly"/>.
|
||||||
|
/// </summary>
|
||||||
|
protected TwinCATDriverOptions BuildOptions(IReadOnlyList<TwinCATTagDefinition> tags) => new()
|
||||||
|
{
|
||||||
|
Devices = [new TwinCATDeviceOptions(
|
||||||
|
HostAddress: Gateway,
|
||||||
|
DeviceName: $"cli-{AmsNetId}:{AmsPort}")],
|
||||||
|
Tags = tags,
|
||||||
|
Timeout = Timeout,
|
||||||
|
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||||
|
UseNativeNotifications = !PollOnly,
|
||||||
|
EnableControllerBrowse = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
protected string DriverInstanceId => $"twincat-cli-{AmsNetId}:{AmsPort}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<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.TwinCAT.Cli</RootNamespace>
|
||||||
|
<AssemblyName>otopcua-twincat-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.TwinCAT\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -371,7 +371,20 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
|||||||
BrowseName = new QualifiedName(_variable.BrowseName.Name + "_Condition", _owner.NamespaceIndex),
|
BrowseName = new QualifiedName(_variable.BrowseName.Name + "_Condition", _owner.NamespaceIndex),
|
||||||
DisplayName = new LocalizedText(info.SourceName),
|
DisplayName = new LocalizedText(info.SourceName),
|
||||||
};
|
};
|
||||||
alarm.Create(_owner.SystemContext, alarm.NodeId, alarm.BrowseName, alarm.DisplayName, false);
|
// assignNodeIds=true makes the stack allocate NodeIds for every inherited
|
||||||
|
// AlarmConditionState child (Severity / Message / ActiveState / AckedState /
|
||||||
|
// EnabledState / …). Without this the children keep Foundation (ns=0) type-
|
||||||
|
// declaration NodeIds that aren't in the node manager's predefined-node index.
|
||||||
|
// The newly-allocated NodeIds default to ns=0 via the shared identifier
|
||||||
|
// counter — we remap them to the node manager's namespace below so client
|
||||||
|
// Read/Browse on children resolves against the predefined-node dictionary.
|
||||||
|
alarm.Create(_owner.SystemContext, alarm.NodeId, alarm.BrowseName, alarm.DisplayName, true);
|
||||||
|
// Assign every descendant a stable, collision-free NodeId in the node manager's
|
||||||
|
// namespace keyed on the condition path. The stack's default assignNodeIds path
|
||||||
|
// allocates from a shared ns=0 counter and does not update parent→child
|
||||||
|
// references when we remap, so we do the rename up front, symbolically:
|
||||||
|
// {condition-full-ref}/{symbolic-path-under-condition}
|
||||||
|
AssignSymbolicDescendantIds(alarm, alarm.NodeId, _owner.NamespaceIndex);
|
||||||
alarm.SourceName.Value = info.SourceName;
|
alarm.SourceName.Value = info.SourceName;
|
||||||
alarm.Severity.Value = (ushort)MapSeverity(info.InitialSeverity);
|
alarm.Severity.Value = (ushort)MapSeverity(info.InitialSeverity);
|
||||||
alarm.Message.Value = new LocalizedText(info.InitialDescription ?? info.SourceName);
|
alarm.Message.Value = new LocalizedText(info.InitialDescription ?? info.SourceName);
|
||||||
@@ -382,10 +395,20 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
|||||||
alarm.AckedState.Id.Value = true;
|
alarm.AckedState.Id.Value = true;
|
||||||
alarm.ActiveState.Value = new LocalizedText("Inactive");
|
alarm.ActiveState.Value = new LocalizedText("Inactive");
|
||||||
alarm.ActiveState.Id.Value = false;
|
alarm.ActiveState.Id.Value = false;
|
||||||
|
// Enable ConditionRefresh support so clients that connect *after* a transition
|
||||||
|
// can pull the current retained-condition snapshot.
|
||||||
|
alarm.ClientUserId.Value = string.Empty;
|
||||||
|
alarm.BranchId.Value = NodeId.Null;
|
||||||
|
|
||||||
_variable.AddChild(alarm);
|
_variable.AddChild(alarm);
|
||||||
_owner.AddPredefinedNode(_owner.SystemContext, alarm);
|
_owner.AddPredefinedNode(_owner.SystemContext, alarm);
|
||||||
|
|
||||||
|
// Part 9 event propagation: AddRootNotifier registers the alarm as an event
|
||||||
|
// source reachable from Objects/Server so subscriptions placed on Server-object
|
||||||
|
// EventNotifier receive the ReportEvent calls ConditionSink.OnTransition emits.
|
||||||
|
// Without this the Report fires but has no subscribers to deliver to.
|
||||||
|
_owner.AddRootNotifier(alarm);
|
||||||
|
|
||||||
return new ConditionSink(_owner, alarm);
|
return new ConditionSink(_owner, alarm);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -398,6 +421,26 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
|||||||
AlarmSeverity.Critical => 900,
|
AlarmSeverity.Critical => 900,
|
||||||
_ => 500,
|
_ => 500,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// After alarm.Create(assignNodeIds=true), every descendant has *some* NodeId but
|
||||||
|
// they default to ns=0 via the shared identifier counter — allocations from two
|
||||||
|
// different alarms collide when we move them into the driver's namespace. Rewriting
|
||||||
|
// symbolically based on the condition path gives each descendant a unique, stable
|
||||||
|
// NodeId in the node manager's namespace. Browse + Read resolve against the current
|
||||||
|
// NodeId because the stack's CustomNodeManager2.Browse traverses NodeState.Children
|
||||||
|
// (NodeState references) and uses each child's current .NodeId in the response.
|
||||||
|
private static void AssignSymbolicDescendantIds(
|
||||||
|
NodeState parent, NodeId parentNodeId, ushort namespaceIndex)
|
||||||
|
{
|
||||||
|
var children = new List<BaseInstanceState>();
|
||||||
|
parent.GetChildren(null!, children);
|
||||||
|
foreach (var child in children)
|
||||||
|
{
|
||||||
|
child.NodeId = new NodeId(
|
||||||
|
$"{parentNodeId.Identifier}.{child.SymbolicName}", namespaceIndex);
|
||||||
|
AssignSymbolicDescendantIds(child, child.NodeId, namespaceIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class ConditionSink(DriverNodeManager owner, AlarmConditionState alarm)
|
private sealed class ConditionSink(DriverNodeManager owner, AlarmConditionState alarm)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy;
|
||||||
using ZB.MOM.WW.OtOpcUa.Server;
|
using ZB.MOM.WW.OtOpcUa.Server;
|
||||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||||
@@ -98,6 +99,7 @@ builder.Services.AddSingleton<DriverFactoryRegistry>(_ =>
|
|||||||
{
|
{
|
||||||
var registry = new DriverFactoryRegistry();
|
var registry = new DriverFactoryRegistry();
|
||||||
GalaxyProxyDriverFactoryExtensions.Register(registry);
|
GalaxyProxyDriverFactoryExtensions.Register(registry);
|
||||||
|
FocasDriverFactoryExtensions.Register(registry);
|
||||||
return registry;
|
return registry;
|
||||||
});
|
});
|
||||||
builder.Services.AddSingleton<DriverInstanceBootstrapper>();
|
builder.Services.AddSingleton<DriverInstanceBootstrapper>();
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj"/>
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj"/>
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Analyzers\ZB.MOM.WW.OtOpcUa.Analyzers.csproj"
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Analyzers\ZB.MOM.WW.OtOpcUa.Analyzers.csproj"
|
||||||
OutputItemType="Analyzer" ReferenceOutputAssembly="false"/>
|
OutputItemType="Analyzer" ReferenceOutputAssembly="false"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Builder;
|
|||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
@@ -32,13 +33,43 @@ public sealed class AdminWebAppFactory : IAsyncDisposable
|
|||||||
public long SeededGenerationId { get; private set; }
|
public long SeededGenerationId { get; private set; }
|
||||||
public string SeededClusterId { get; } = "e2e-cluster";
|
public string SeededClusterId { get; } = "e2e-cluster";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Root service provider of the running host. Tests use this to create scopes that
|
||||||
|
/// share the InMemory DB with the Blazor-rendered page — e.g. to assert post-commit
|
||||||
|
/// state, or to simulate a concurrent peer edit that bumps the DraftRevisionToken
|
||||||
|
/// between preview-open and Confirm-click.
|
||||||
|
/// </summary>
|
||||||
|
public IServiceProvider Services => _app?.Services
|
||||||
|
?? throw new InvalidOperationException("AdminWebAppFactory: StartAsync has not been called");
|
||||||
|
|
||||||
public async Task StartAsync()
|
public async Task StartAsync()
|
||||||
{
|
{
|
||||||
var port = GetFreeTcpPort();
|
var port = GetFreeTcpPort();
|
||||||
BaseUrl = $"http://127.0.0.1:{port}";
|
BaseUrl = $"http://127.0.0.1:{port}";
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(Array.Empty<string>());
|
// Point the content root at the Admin project's build output so the Admin
|
||||||
|
// assembly + its sibling staticwebassets manifest are discoverable. The manifest
|
||||||
|
// maps /_framework/* to the framework NuGet cache + /app.css to the Admin source
|
||||||
|
// wwwroot; StaticWebAssetsLoader.UseStaticWebAssets reads it and wires a composite
|
||||||
|
// file provider automatically.
|
||||||
|
var adminAssemblyDir = System.IO.Path.GetDirectoryName(
|
||||||
|
typeof(Admin.Components.App).Assembly.Location)!;
|
||||||
|
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
|
||||||
|
{
|
||||||
|
ContentRootPath = adminAssemblyDir,
|
||||||
|
ApplicationName = typeof(Admin.Components.App).Assembly.GetName().Name,
|
||||||
|
});
|
||||||
builder.WebHost.UseUrls(BaseUrl);
|
builder.WebHost.UseUrls(BaseUrl);
|
||||||
|
// UseStaticWebAssets reads {ApplicationName}.staticwebassets.runtime.json (or the
|
||||||
|
// development variant via the ASPNETCORE_HOSTINGSTARTUPASSEMBLIES convention) and
|
||||||
|
// composes a PhysicalFileProvider per declared ContentRoot. This is what
|
||||||
|
// `dotnet run` does automatically via the MSBuild targets — we replicate it
|
||||||
|
// explicitly for the test-owned pipeline.
|
||||||
|
builder.WebHost.UseStaticWebAssets();
|
||||||
|
// E2E host runs in Development so unhandled exceptions during Blazor render surface
|
||||||
|
// as visible 500s with stacks the test can capture — prod-style generic errors make
|
||||||
|
// diagnosis of circuit / DI misconfig effectively impossible.
|
||||||
|
builder.Environment.EnvironmentName = Microsoft.Extensions.Hosting.Environments.Development;
|
||||||
|
|
||||||
// --- Mirror the Admin composition in Program.cs, but with the InMemory DB + test
|
// --- Mirror the Admin composition in Program.cs, but with the InMemory DB + test
|
||||||
// auth swaps instead of SQL Server + LDAP cookie auth.
|
// auth swaps instead of SQL Server + LDAP cookie auth.
|
||||||
@@ -54,8 +85,13 @@ public sealed class AdminWebAppFactory : IAsyncDisposable
|
|||||||
.AddPolicy("CanPublish", p => p.RequireRole(Admin.Services.AdminRoles.FleetAdmin));
|
.AddPolicy("CanPublish", p => p.RequireRole(Admin.Services.AdminRoles.FleetAdmin));
|
||||||
builder.Services.AddCascadingAuthenticationState();
|
builder.Services.AddCascadingAuthenticationState();
|
||||||
|
|
||||||
|
// One InMemory database name per fixture — the lambda below runs on every DbContext
|
||||||
|
// construction, so capturing a stable string (not calling Guid.NewGuid() inline) is
|
||||||
|
// critical: every scope (seed, Blazor circuit, test assertions) must share the same
|
||||||
|
// backing store or rows written in one scope disappear in the next.
|
||||||
|
var dbName = $"e2e-{Guid.NewGuid():N}";
|
||||||
builder.Services.AddDbContext<OtOpcUaConfigDbContext>(opt =>
|
builder.Services.AddDbContext<OtOpcUaConfigDbContext>(opt =>
|
||||||
opt.UseInMemoryDatabase($"e2e-{Guid.NewGuid():N}"));
|
opt.UseInMemoryDatabase(dbName));
|
||||||
|
|
||||||
builder.Services.AddScoped<Admin.Services.ClusterService>();
|
builder.Services.AddScoped<Admin.Services.ClusterService>();
|
||||||
builder.Services.AddScoped<Admin.Services.GenerationService>();
|
builder.Services.AddScoped<Admin.Services.GenerationService>();
|
||||||
@@ -72,6 +108,12 @@ public sealed class AdminWebAppFactory : IAsyncDisposable
|
|||||||
_app.UseAuthorization();
|
_app.UseAuthorization();
|
||||||
_app.UseAntiforgery();
|
_app.UseAntiforgery();
|
||||||
_app.MapRazorComponents<Admin.Components.App>().AddInteractiveServerRenderMode();
|
_app.MapRazorComponents<Admin.Components.App>().AddInteractiveServerRenderMode();
|
||||||
|
// The ClusterDetail + other pages connect SignalR hubs at render time — the
|
||||||
|
// endpoints must exist or the Blazor circuit surfaces a 500 on first interactive
|
||||||
|
// step. No background pollers (FleetStatusPoller etc.) are registered so the hubs
|
||||||
|
// stay quiet until something pushes through IHubContext, which the E2E tests don't.
|
||||||
|
_app.MapHub<FleetStatusHub>("/hubs/fleet");
|
||||||
|
_app.MapHub<AlertHub>("/hubs/alerts");
|
||||||
|
|
||||||
// Seed the draft BEFORE starting the host so Playwright sees a ready page on first nav.
|
// Seed the draft BEFORE starting the host so Playwright sees a ready page on first nav.
|
||||||
using (var scope = _app.Services.CreateScope())
|
using (var scope = _app.Services.CreateScope())
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Playwright;
|
using Microsoft.Playwright;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Admin.E2ETests;
|
namespace ZB.MOM.WW.OtOpcUa.Admin.E2ETests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Phase 6.4 UnsTab drag-drop E2E smoke (task #199). This PR lands the Playwright +
|
/// Phase 6.4 UnsTab drag-drop E2E. Task #199 landed the scaffolding; task #242 (this file)
|
||||||
/// WebApplicationFactory-equivalent scaffolding so future E2E coverage builds on it
|
/// drives the Blazor Server interactive circuit through a real drag-drop → confirm-modal
|
||||||
/// rather than setting it up from scratch.
|
/// → apply flow and a 409 concurrent-edit flow, both via Chromium.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
@@ -17,13 +20,15 @@ namespace ZB.MOM.WW.OtOpcUa.Admin.E2ETests;
|
|||||||
/// so CI pipelines that don't run the install step still report green.
|
/// so CI pipelines that don't run the install step still report green.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Current scope.</b> The host-reachability smoke below proves the infra works:
|
/// <b>Harness notes.</b> <see cref="AdminWebAppFactory"/> points the content root at
|
||||||
/// Kestrel-on-a-free-port, InMemory DbContext swap, <see cref="TestAuthHandler"/>
|
/// the Admin assembly directory + sets <c>ApplicationName</c> + calls
|
||||||
/// bypass, and Playwright-to-real-browser are all exercised. The actual drag-drop
|
/// <c>UseStaticWebAssets</c> so <c>/_framework/blazor.web.js</c> + <c>/app.css</c>
|
||||||
/// interactive assertion is filed as a follow-up (task #242) because
|
/// resolve from the Admin's <c>staticwebassets.development.json</c> manifest (which
|
||||||
/// Blazor Server interactive render through a test-owned pipeline needs a dedicated
|
/// stitches together Admin <c>wwwroot</c> + the framework NuGet cache). Hubs
|
||||||
/// diagnosis pass — the scaffolding lands here first so that follow-up can focus on
|
/// <c>/hubs/fleet</c> + <c>/hubs/alerts</c> are mapped so <c>ClusterDetail</c>'s
|
||||||
/// the Blazor-specific wiring instead of rebuilding the harness.
|
/// <c>HubConnection</c> negotiation doesn't 500 at first render. The InMemory
|
||||||
|
/// database name is captured as a stable string per fixture instance so the seed
|
||||||
|
/// scope + Blazor circuit scope + test-assertion scope all share one backing store.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Trait("Category", "E2E")]
|
[Trait("Category", "E2E")]
|
||||||
@@ -35,34 +40,20 @@ public sealed class UnsTabDragDropE2ETests
|
|||||||
await using var app = new AdminWebAppFactory();
|
await using var app = new AdminWebAppFactory();
|
||||||
await app.StartAsync();
|
await app.StartAsync();
|
||||||
|
|
||||||
PlaywrightFixture fixture;
|
var fixture = await TryInitPlaywrightAsync();
|
||||||
try
|
if (fixture is null) return;
|
||||||
{
|
|
||||||
fixture = new PlaywrightFixture();
|
|
||||||
await fixture.InitializeAsync();
|
|
||||||
}
|
|
||||||
catch (PlaywrightBrowserMissingException)
|
|
||||||
{
|
|
||||||
Assert.Skip("Chromium not installed. Run playwright.ps1 install chromium.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var ctx = await fixture.Browser.NewContextAsync();
|
var ctx = await fixture.Browser.NewContextAsync();
|
||||||
var page = await ctx.NewPageAsync();
|
var page = await ctx.NewPageAsync();
|
||||||
|
|
||||||
// Navigate to the root. We only assert the host is live + returns HTML — not
|
|
||||||
// that the Blazor Server interactive render has booted. Booting the interactive
|
|
||||||
// circuit in a test-owned pipeline is task #242.
|
|
||||||
var response = await page.GotoAsync(app.BaseUrl);
|
var response = await page.GotoAsync(app.BaseUrl);
|
||||||
|
|
||||||
response.ShouldNotBeNull();
|
response.ShouldNotBeNull();
|
||||||
response!.Status.ShouldBeLessThan(500,
|
response!.Status.ShouldBeLessThan(500,
|
||||||
$"Admin host returned HTTP {response.Status} at root — scaffolding broken");
|
$"Admin host returned HTTP {response.Status} at root — scaffolding broken");
|
||||||
|
|
||||||
// Static HTML shell should at least include the <body> and some content. This
|
|
||||||
// rules out 404s + verifies the MapRazorComponents route pipeline is wired.
|
|
||||||
var body = await page.Locator("body").InnerHTMLAsync();
|
var body = await page.Locator("body").InnerHTMLAsync();
|
||||||
body.Length.ShouldBeGreaterThan(0, "empty body = routing pipeline didn't hit Razor");
|
body.Length.ShouldBeGreaterThan(0, "empty body = routing pipeline didn't hit Razor");
|
||||||
}
|
}
|
||||||
@@ -71,4 +62,148 @@ public sealed class UnsTabDragDropE2ETests
|
|||||||
await fixture.DisposeAsync();
|
await fixture.DisposeAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Dragging_line_onto_new_area_shows_preview_modal_then_confirms_the_move()
|
||||||
|
{
|
||||||
|
await using var app = new AdminWebAppFactory();
|
||||||
|
await app.StartAsync();
|
||||||
|
|
||||||
|
var fixture = await TryInitPlaywrightAsync();
|
||||||
|
if (fixture is null) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ctx = await fixture.Browser.NewContextAsync();
|
||||||
|
var page = await ctx.NewPageAsync();
|
||||||
|
|
||||||
|
await OpenUnsTabAsync(page, app);
|
||||||
|
|
||||||
|
// The seed wires line 'oven-line' to area 'warsaw' (area-a); dragging it onto
|
||||||
|
// 'berlin' (area-b) should surface the preview modal. Playwright's DragToAsync
|
||||||
|
// dispatches native dragstart / dragover / drop events that the razor's
|
||||||
|
// @ondragstart / @ondragover / @ondrop handlers pick up.
|
||||||
|
var lineRow = page.Locator("table >> tr", new() { HasTextString = "oven-line" });
|
||||||
|
var berlinRow = page.Locator("table >> tr", new() { HasTextString = "berlin" });
|
||||||
|
await lineRow.DragToAsync(berlinRow);
|
||||||
|
|
||||||
|
var modalTitle = page.Locator(".modal-title", new() { HasTextString = "Confirm UNS move" });
|
||||||
|
await modalTitle.WaitForAsync(new() { Timeout = 10_000 });
|
||||||
|
|
||||||
|
var modalBody = await page.Locator(".modal-body").InnerTextAsync();
|
||||||
|
modalBody.ShouldContain("Equipment re-homed",
|
||||||
|
customMessage: "preview modal should render UnsImpactAnalyzer summary");
|
||||||
|
|
||||||
|
await page.Locator("button.btn.btn-primary", new() { HasTextString = "Confirm move" })
|
||||||
|
.ClickAsync();
|
||||||
|
|
||||||
|
// Modal dismisses after the MoveLineAsync round-trip + ReloadAsync.
|
||||||
|
await modalTitle.WaitForAsync(new() { State = WaitForSelectorState.Detached, Timeout = 10_000 });
|
||||||
|
|
||||||
|
// Persisted state: the line row now shows area-b as its Area column value.
|
||||||
|
using var scope = app.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||||
|
var line = await db.UnsLines.AsNoTracking()
|
||||||
|
.FirstAsync(l => l.UnsLineId == "line-a1" && l.GenerationId == app.SeededGenerationId);
|
||||||
|
line.UnsAreaId.ShouldBe("area-b",
|
||||||
|
"drag-drop should have moved the line to the berlin area via UnsService.MoveLineAsync");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await fixture.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Preview_shown_then_peer_edit_applied_surfaces_409_conflict_modal()
|
||||||
|
{
|
||||||
|
await using var app = new AdminWebAppFactory();
|
||||||
|
await app.StartAsync();
|
||||||
|
|
||||||
|
var fixture = await TryInitPlaywrightAsync();
|
||||||
|
if (fixture is null) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ctx = await fixture.Browser.NewContextAsync();
|
||||||
|
var page = await ctx.NewPageAsync();
|
||||||
|
|
||||||
|
await OpenUnsTabAsync(page, app);
|
||||||
|
|
||||||
|
// Open the preview first (same drag as the happy-path test). The preview captures
|
||||||
|
// a RevisionToken under the current draft state.
|
||||||
|
var lineRow = page.Locator("table >> tr", new() { HasTextString = "oven-line" });
|
||||||
|
var berlinRow = page.Locator("table >> tr", new() { HasTextString = "berlin" });
|
||||||
|
await lineRow.DragToAsync(berlinRow);
|
||||||
|
|
||||||
|
var modalTitle = page.Locator(".modal-title", new() { HasTextString = "Confirm UNS move" });
|
||||||
|
await modalTitle.WaitForAsync(new() { Timeout = 10_000 });
|
||||||
|
|
||||||
|
// Simulate a concurrent operator committing their own edit between the preview
|
||||||
|
// open + our Confirm click — bumps the DraftRevisionToken so our stale token hits
|
||||||
|
// DraftRevisionConflictException in UnsService.MoveLineAsync.
|
||||||
|
using (var scope = app.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var uns = scope.ServiceProvider.GetRequiredService<Admin.Services.UnsService>();
|
||||||
|
await uns.AddAreaAsync(app.SeededGenerationId, app.SeededClusterId,
|
||||||
|
"madrid", notes: null, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.Locator("button.btn.btn-primary", new() { HasTextString = "Confirm move" })
|
||||||
|
.ClickAsync();
|
||||||
|
|
||||||
|
var conflictTitle = page.Locator(".modal-title",
|
||||||
|
new() { HasTextString = "Draft changed" });
|
||||||
|
await conflictTitle.WaitForAsync(new() { Timeout = 10_000 });
|
||||||
|
|
||||||
|
// Persisted state: line still points at the original area-a — the conflict short-
|
||||||
|
// circuited the move.
|
||||||
|
using var verifyScope = app.Services.CreateScope();
|
||||||
|
var db = verifyScope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||||
|
var line = await db.UnsLines.AsNoTracking()
|
||||||
|
.FirstAsync(l => l.UnsLineId == "line-a1" && l.GenerationId == app.SeededGenerationId);
|
||||||
|
line.UnsAreaId.ShouldBe("area-a",
|
||||||
|
"conflict path must not overwrite the peer operator's draft state");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await fixture.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<PlaywrightFixture?> TryInitPlaywrightAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fixture = new PlaywrightFixture();
|
||||||
|
await fixture.InitializeAsync();
|
||||||
|
return fixture;
|
||||||
|
}
|
||||||
|
catch (PlaywrightBrowserMissingException)
|
||||||
|
{
|
||||||
|
Assert.Skip("Chromium not installed. Run playwright.ps1 install chromium.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Navigates to the seeded cluster and switches to the UNS Structure tab, waiting for
|
||||||
|
/// the Blazor Server interactive circuit to render the draggable line table. Returns
|
||||||
|
/// once the drop-target cells ("drop here") are visible — that's the signal the
|
||||||
|
/// circuit is live and @ondragstart handlers are wired.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task OpenUnsTabAsync(IPage page, AdminWebAppFactory app)
|
||||||
|
{
|
||||||
|
await page.GotoAsync($"{app.BaseUrl}/clusters/{app.SeededClusterId}",
|
||||||
|
new() { WaitUntil = WaitUntilState.NetworkIdle, Timeout = 20_000 });
|
||||||
|
|
||||||
|
var unsTab = page.Locator("button.nav-link", new() { HasTextString = "UNS Structure" });
|
||||||
|
await unsTab.WaitForAsync(new() { Timeout = 15_000 });
|
||||||
|
await unsTab.ClickAsync();
|
||||||
|
|
||||||
|
// "drop here" is the per-area hint cell — only rendered inside <UnsTab> so its
|
||||||
|
// visibility confirms both the tab switch and the circuit's interactive render.
|
||||||
|
await page.Locator("td", new() { HasTextString = "drop here" })
|
||||||
|
.First.WaitForAsync(new() { Timeout = 15_000 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Covers <see cref="WriteCommand.ParseValue"/>. Every Logix atomic type has at least
|
||||||
|
/// one happy-path case plus a failure case for unparseable input.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class WriteCommandParseValueTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("true", true)]
|
||||||
|
[InlineData("0", false)]
|
||||||
|
[InlineData("on", true)]
|
||||||
|
[InlineData("NO", false)]
|
||||||
|
public void ParseValue_Bool_accepts_common_aliases(string raw, bool expected)
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue(raw, AbCipDataType.Bool).ShouldBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Bool_rejects_garbage()
|
||||||
|
{
|
||||||
|
Should.Throw<CliFx.Exceptions.CommandException>(
|
||||||
|
() => WriteCommand.ParseValue("maybe", AbCipDataType.Bool));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_SInt_widens_to_sbyte()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("-128", AbCipDataType.SInt).ShouldBe((sbyte)-128);
|
||||||
|
WriteCommand.ParseValue("127", AbCipDataType.SInt).ShouldBe((sbyte)127);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Int_signed_16bit()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("-32768", AbCipDataType.Int).ShouldBe((short)-32768);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_DInt_and_Dt_both_land_on_int()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("42", AbCipDataType.DInt).ShouldBeOfType<int>();
|
||||||
|
WriteCommand.ParseValue("1234567", AbCipDataType.Dt).ShouldBeOfType<int>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_LInt_64bit()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("9223372036854775807", AbCipDataType.LInt).ShouldBe(long.MaxValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_unsigned_range_respects_bounds()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("255", AbCipDataType.USInt).ShouldBeOfType<byte>();
|
||||||
|
WriteCommand.ParseValue("65535", AbCipDataType.UInt).ShouldBeOfType<ushort>();
|
||||||
|
WriteCommand.ParseValue("4294967295", AbCipDataType.UDInt).ShouldBeOfType<uint>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Real_invariant_culture_decimal()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("3.14", AbCipDataType.Real).ShouldBe(3.14f);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_LReal_handles_double_precision()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("2.718281828", AbCipDataType.LReal).ShouldBeOfType<double>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_String_passthrough()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("hello logix", AbCipDataType.String).ShouldBe("hello logix");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_non_numeric_for_numeric_types_throws()
|
||||||
|
{
|
||||||
|
Should.Throw<FormatException>(
|
||||||
|
() => WriteCommand.ParseValue("xyz", AbCipDataType.DInt));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Motor01_Speed", AbCipDataType.Real, "Motor01_Speed:Real")]
|
||||||
|
[InlineData("Program:Main.Counter", AbCipDataType.DInt, "Program:Main.Counter:DInt")]
|
||||||
|
[InlineData("Recipe[3]", AbCipDataType.Int, "Recipe[3]:Int")]
|
||||||
|
public void SynthesiseTagName_preserves_path_verbatim(
|
||||||
|
string path, AbCipDataType type, string expected)
|
||||||
|
{
|
||||||
|
ReadCommand.SynthesiseTagName(path, type).ShouldBe(expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||||
|
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli\ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Commands;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Covers <see cref="WriteCommand.ParseValue"/>. PCCC types are narrower than AB CIP
|
||||||
|
/// (no 64-bit, no unsigned variants, no Structure / Dt) so the matrix is smaller.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class WriteCommandParseValueTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("true", true)]
|
||||||
|
[InlineData("0", false)]
|
||||||
|
[InlineData("yes", true)]
|
||||||
|
[InlineData("OFF", false)]
|
||||||
|
public void ParseValue_Bit_accepts_common_aliases(string raw, bool expected)
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue(raw, AbLegacyDataType.Bit).ShouldBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Int_signed_16bit()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("-32768", AbLegacyDataType.Int).ShouldBe((short)-32768);
|
||||||
|
WriteCommand.ParseValue("32767", AbLegacyDataType.Int).ShouldBe((short)32767);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_AnalogInt_parses_same_as_Int()
|
||||||
|
{
|
||||||
|
// A-file uses N-file semantics — 16-bit signed with the same wire format.
|
||||||
|
WriteCommand.ParseValue("100", AbLegacyDataType.AnalogInt).ShouldBeOfType<short>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Long_32bit()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("-2147483648", AbLegacyDataType.Long).ShouldBe(int.MinValue);
|
||||||
|
WriteCommand.ParseValue("2147483647", AbLegacyDataType.Long).ShouldBe(int.MaxValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Float_invariant_culture()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("3.14", AbLegacyDataType.Float).ShouldBe(3.14f);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_String_passthrough()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("hello slc", AbLegacyDataType.String).ShouldBe("hello slc");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(AbLegacyDataType.TimerElement)]
|
||||||
|
[InlineData(AbLegacyDataType.CounterElement)]
|
||||||
|
[InlineData(AbLegacyDataType.ControlElement)]
|
||||||
|
public void ParseValue_Element_types_land_on_int32(AbLegacyDataType type)
|
||||||
|
{
|
||||||
|
// T/C/R sub-elements are 32-bit at the wire level regardless of semantic meaning.
|
||||||
|
WriteCommand.ParseValue("42", type).ShouldBeOfType<int>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Bit_rejects_unknown_strings()
|
||||||
|
{
|
||||||
|
Should.Throw<CliFx.Exceptions.CommandException>(
|
||||||
|
() => WriteCommand.ParseValue("perhaps", AbLegacyDataType.Bit));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_non_numeric_for_numeric_types_throws()
|
||||||
|
{
|
||||||
|
Should.Throw<FormatException>(
|
||||||
|
() => WriteCommand.ParseValue("xyz", AbLegacyDataType.Int));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("N7:0", AbLegacyDataType.Int, "N7:0:Int")]
|
||||||
|
[InlineData("B3:0/3", AbLegacyDataType.Bit, "B3:0/3:Bit")]
|
||||||
|
[InlineData("F8:10", AbLegacyDataType.Float, "F8:10:Float")]
|
||||||
|
[InlineData("T4:0.ACC", AbLegacyDataType.TimerElement, "T4:0.ACC:TimerElement")]
|
||||||
|
public void SynthesiseTagName_preserves_PCCC_address_verbatim(
|
||||||
|
string address, AbLegacyDataType type, string expected)
|
||||||
|
{
|
||||||
|
ReadCommand.SynthesiseTagName(address, type).ShouldBe(expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||||
|
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -28,6 +28,16 @@ public sealed class AbLegacyServerFixture : IAsyncLifetime
|
|||||||
{
|
{
|
||||||
private const string EndpointEnvVar = "AB_LEGACY_ENDPOINT";
|
private const string EndpointEnvVar = "AB_LEGACY_ENDPOINT";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opt-in flag that promises the endpoint can actually round-trip PCCC reads/writes
|
||||||
|
/// (real SLC 5/05 / MicroLogix 1100/1400 / PLC-5 hardware, or a RSEmulate 500
|
||||||
|
/// golden-box per <c>docs/v2/lmx-followups.md</c>). Without this, the fixture assumes
|
||||||
|
/// the endpoint is libplctag's <c>ab_server --plc=SLC500</c> Docker container — whose
|
||||||
|
/// PCCC dispatcher is a known upstream gap — and skips cleanly rather than failing
|
||||||
|
/// every test with <c>BadCommunicationError</c>.
|
||||||
|
/// </summary>
|
||||||
|
private const string TrustWireEnvVar = "AB_LEGACY_TRUST_WIRE";
|
||||||
|
|
||||||
/// <summary>Standard EtherNet/IP port. PCCC-over-CIP rides on the same port as
|
/// <summary>Standard EtherNet/IP port. PCCC-over-CIP rides on the same port as
|
||||||
/// native CIP; the differentiator is the <c>--plc</c> flag ab_server was started
|
/// native CIP; the differentiator is the <c>--plc</c> flag ab_server was started
|
||||||
/// with, not a different TCP listener.</summary>
|
/// with, not a different TCP listener.</summary>
|
||||||
@@ -46,22 +56,49 @@ public sealed class AbLegacyServerFixture : IAsyncLifetime
|
|||||||
if (parts.Length == 2 && int.TryParse(parts[1], out var p)) Port = p;
|
if (parts.Length == 2 && int.TryParse(parts[1], out var p)) Port = p;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!TcpProbe(Host, Port))
|
SkipReason = ResolveSkipReason(Host, Port);
|
||||||
{
|
|
||||||
SkipReason =
|
|
||||||
$"AB Legacy PCCC simulator at {Host}:{Port} not reachable within 2 s. " +
|
|
||||||
$"Start the Docker container (docker compose -f Docker/docker-compose.yml " +
|
|
||||||
$"--profile slc500 up -d) or override {EndpointEnvVar}.";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Used by <see cref="AbLegacyFactAttribute"/> + <see cref="AbLegacyTheoryAttribute"/>
|
||||||
|
/// during test-class construction — gates whether the test runs at all. Duplicates the
|
||||||
|
/// fixture logic because attribute ctors fire before the collection fixture instance
|
||||||
|
/// exists.
|
||||||
|
/// </summary>
|
||||||
public static bool IsServerAvailable()
|
public static bool IsServerAvailable()
|
||||||
{
|
{
|
||||||
var (host, port) = ResolveEndpoint();
|
var (host, port) = ResolveEndpoint();
|
||||||
return TcpProbe(host, port);
|
return ResolveSkipReason(host, port) is null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolveSkipReason(string host, int port)
|
||||||
|
{
|
||||||
|
if (!TcpProbe(host, port))
|
||||||
|
{
|
||||||
|
return $"AB Legacy PCCC endpoint at {host}:{port} not reachable within 2 s. " +
|
||||||
|
$"Start the Docker container (docker compose -f Docker/docker-compose.yml " +
|
||||||
|
$"--profile slc500 up -d), attach real hardware, or override {EndpointEnvVar}.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// TCP reaches — but is the peer a real PLC (wire-trustworthy) or ab_server's PCCC
|
||||||
|
// mode (dispatcher is upstream-broken, every read surfaces BadCommunicationError)?
|
||||||
|
// We can't detect it at the wire without issuing a full libplctag session, so we
|
||||||
|
// require an explicit opt-in for wire-level runs. See
|
||||||
|
// `tests/.../Docker/README.md` §"Known limitations" for the upstream-tracking pointer.
|
||||||
|
if (Environment.GetEnvironmentVariable(TrustWireEnvVar) is not { Length: > 0 } trust
|
||||||
|
|| !(trust == "1" || string.Equals(trust, "true", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
return $"AB Legacy endpoint at {host}:{port} is reachable but {TrustWireEnvVar} is not set. " +
|
||||||
|
"ab_server's PCCC dispatcher is a known upstream gap (libplctag/libplctag), so by " +
|
||||||
|
"default the integration suite assumes the simulator is in play and skips. Set " +
|
||||||
|
$"{TrustWireEnvVar}=1 when pointing at real SLC 5/05 / MicroLogix 1100/1400 / PLC-5 " +
|
||||||
|
"hardware or a RSEmulate 500 golden-box.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static (string Host, int Port) ResolveEndpoint()
|
private static (string Host, int Port) ResolveEndpoint()
|
||||||
@@ -129,16 +166,19 @@ public sealed class AbLegacyServerCollection : Xunit.ICollectionFixture<AbLegacy
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// <c>[Fact]</c>-equivalent that skips when the PCCC simulator isn't reachable.
|
/// <c>[Fact]</c>-equivalent that skips when the PCCC endpoint isn't wire-trustworthy.
|
||||||
|
/// See <see cref="AbLegacyServerFixture"/> for the exact skip semantics.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AbLegacyFactAttribute : FactAttribute
|
public sealed class AbLegacyFactAttribute : FactAttribute
|
||||||
{
|
{
|
||||||
public AbLegacyFactAttribute()
|
public AbLegacyFactAttribute()
|
||||||
{
|
{
|
||||||
if (!AbLegacyServerFixture.IsServerAvailable())
|
if (!AbLegacyServerFixture.IsServerAvailable())
|
||||||
Skip = "AB Legacy PCCC simulator not reachable. Start the Docker container " +
|
Skip = "AB Legacy PCCC endpoint not wire-trustworthy. Either no simulator is " +
|
||||||
"(docker compose -f Docker/docker-compose.yml --profile slc500 up -d) " +
|
"running, or the Docker ab_server is up but AB_LEGACY_TRUST_WIRE is not " +
|
||||||
"or set AB_LEGACY_ENDPOINT.";
|
"set (ab_server's PCCC dispatcher is a known upstream gap). Set " +
|
||||||
|
"AB_LEGACY_TRUST_WIRE=1 when pointing AB_LEGACY_ENDPOINT at real hardware " +
|
||||||
|
"or a RSEmulate 500 golden-box.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,8 +190,10 @@ public sealed class AbLegacyTheoryAttribute : TheoryAttribute
|
|||||||
public AbLegacyTheoryAttribute()
|
public AbLegacyTheoryAttribute()
|
||||||
{
|
{
|
||||||
if (!AbLegacyServerFixture.IsServerAvailable())
|
if (!AbLegacyServerFixture.IsServerAvailable())
|
||||||
Skip = "AB Legacy PCCC simulator not reachable. Start the Docker container " +
|
Skip = "AB Legacy PCCC endpoint not wire-trustworthy. Either no simulator is " +
|
||||||
"(docker compose -f Docker/docker-compose.yml --profile slc500 up -d) " +
|
"running, or the Docker ab_server is up but AB_LEGACY_TRUST_WIRE is not " +
|
||||||
"or set AB_LEGACY_ENDPOINT.";
|
"set (ab_server's PCCC dispatcher is a known upstream gap). Set " +
|
||||||
|
"AB_LEGACY_TRUST_WIRE=1 when pointing AB_LEGACY_ENDPOINT at real hardware " +
|
||||||
|
"or a RSEmulate 500 golden-box.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,13 @@ families stop the current service + start another.
|
|||||||
- Override with `AB_LEGACY_ENDPOINT=host:port` to point at a real SLC /
|
- Override with `AB_LEGACY_ENDPOINT=host:port` to point at a real SLC /
|
||||||
MicroLogix / PLC-5 PLC on its native port.
|
MicroLogix / PLC-5 PLC on its native port.
|
||||||
|
|
||||||
|
## Env vars
|
||||||
|
|
||||||
|
| Var | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `AB_LEGACY_ENDPOINT` | `localhost:44818` | `host:port` of the PCCC endpoint. |
|
||||||
|
| `AB_LEGACY_TRUST_WIRE` | *unset* | Opt-in promise that the endpoint is a real PLC or RSEmulate 500 golden-box (not ab_server). Required for integration tests to actually run; without it the tests skip with an upstream-gap message even when TCP reaches a listener. See the **Known limitations** section below. |
|
||||||
|
|
||||||
## Run the integration tests
|
## Run the integration tests
|
||||||
|
|
||||||
In a separate shell with a container up:
|
In a separate shell with a container up:
|
||||||
@@ -56,9 +63,20 @@ cd C:\Users\dohertj2\Desktop\lmxopcua
|
|||||||
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests
|
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests
|
||||||
```
|
```
|
||||||
|
|
||||||
`AbLegacyServerFixture` TCP-probes `localhost:44818` at collection init +
|
Against the Docker ab_server the suite **skips** with a pointer to the
|
||||||
records a skip reason when unreachable. Tests use `[AbLegacyFact]` /
|
upstream gap (see **Known limitations**). Against real SLC / MicroLogix /
|
||||||
`[AbLegacyTheory]` which check the same probe.
|
PLC-5 hardware or a RSEmulate 500 box:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:AB_LEGACY_ENDPOINT = "10.0.1.50:44818"
|
||||||
|
$env:AB_LEGACY_TRUST_WIRE = "1"
|
||||||
|
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests
|
||||||
|
```
|
||||||
|
|
||||||
|
`AbLegacyServerFixture` TCP-probes the endpoint at collection init and sets
|
||||||
|
a skip reason that captures **both** cases: unreachable endpoint *and*
|
||||||
|
reachable-but-wire-untrusted. Tests use `[AbLegacyFact]` / `[AbLegacyTheory]`
|
||||||
|
which check the same gate.
|
||||||
|
|
||||||
## What each family seeds
|
## What each family seeds
|
||||||
|
|
||||||
@@ -79,40 +97,41 @@ implies type:
|
|||||||
|
|
||||||
## Known limitations
|
## Known limitations
|
||||||
|
|
||||||
### ab_server PCCC read/write round-trip (verified 2026-04-20)
|
### ab_server PCCC dispatcher (confirmed upstream gap, verified 2026-04-21)
|
||||||
|
|
||||||
**Scaffold is in place; wire-level round-trip does NOT currently pass
|
**ab_server accepts TCP at `:44818` but its PCCC dispatcher is not
|
||||||
against `ab_server --plc=SLC500`.** With the SLC500 compose profile up,
|
functional.** Running with `--plc=SLC500 --debug=5` shows no request
|
||||||
TCP 44818 accepts connections and libplctag negotiates the session,
|
logs when libplctag issues a read, and every read surfaces as
|
||||||
but the three smoke tests in `AbLegacyReadSmokeTests.cs` all fail at
|
`BadCommunicationError` (libplctag status `0x80050000`). This matches
|
||||||
read/write with `BadCommunicationError` (libplctag status `0x80050000`).
|
the libplctag docs' description of PCCC support as less-mature than
|
||||||
Possible root causes:
|
CIP in the bundled `ab_server` tool.
|
||||||
|
|
||||||
- ab_server's PCCC server-side opcode coverage may be narrower than
|
**Fixture behavior.** To avoid a loud row of failing tests on the
|
||||||
libplctag's PCCC client expects — the tool is primarily a CIP
|
integration host every time someone `docker compose up`s the SLC500
|
||||||
server; PCCC was added later + is noted in libplctag docs as less
|
profile, `AbLegacyServerFixture` gates on a second env var
|
||||||
mature.
|
`AB_LEGACY_TRUST_WIRE`. The matrix:
|
||||||
- libplctag's PCCC-over-CIP encapsulation may assume a real SLC 5/05
|
|
||||||
EtherNet/IP NIC's framing that ab_server doesn't emit.
|
|
||||||
|
|
||||||
The scaffold ships **as-is** because:
|
| Endpoint reachable? | `AB_LEGACY_TRUST_WIRE` set? | Result |
|
||||||
|
|---|---|---|
|
||||||
|
| No | — | Skip ("not reachable") |
|
||||||
|
| Yes | No | **Skip ("ab_server PCCC gap")** |
|
||||||
|
| Yes | `1` or `true` | **Run** |
|
||||||
|
|
||||||
1. The Docker infrastructure + fixture pattern works cleanly (probe
|
The test bodies themselves are correct for real hardware — point
|
||||||
passes, container lifecycle is clean, tests skip when absent).
|
`AB_LEGACY_ENDPOINT` at a real SLC 5/05 / MicroLogix 1100/1400 /
|
||||||
2. The test classes target the correct shape for what the AB Legacy
|
PLC-5, set `AB_LEGACY_TRUST_WIRE=1`, and the smoke tests round-trip
|
||||||
driver would do against real hardware.
|
cleanly.
|
||||||
3. Pointing `AB_LEGACY_ENDPOINT` at a real SLC 5/05 / MicroLogix
|
|
||||||
1100 / 1400 should make the tests pass outright — the failure
|
|
||||||
mode is ab_server-specific, not driver-specific.
|
|
||||||
|
|
||||||
Resolution paths (pick one):
|
Resolution paths (pick one):
|
||||||
|
|
||||||
1. **File an ab_server bug** in `libplctag/libplctag` to expand PCCC
|
1. **File an ab_server bug** in `libplctag/libplctag` to expand PCCC
|
||||||
server-side coverage.
|
server-side coverage.
|
||||||
2. **Golden-box tier** via Rockwell RSEmulate 500 — closer to real
|
2. **Golden-box tier** via Rockwell RSEmulate 500 — closer to real
|
||||||
firmware, but license-gated + RSLinx-dependent.
|
firmware, but license-gated + RSLinx-dependent. Set
|
||||||
|
`AB_LEGACY_TRUST_WIRE=1` when the endpoint points at an Emulate
|
||||||
|
box.
|
||||||
3. **Lab rig** — used SLC 5/05 / MicroLogix 1100 on a dedicated
|
3. **Lab rig** — used SLC 5/05 / MicroLogix 1100 on a dedicated
|
||||||
network; the authoritative path.
|
network (task #222); the authoritative path.
|
||||||
|
|
||||||
### Other known gaps (unchanged from ab_server)
|
### Other known gaps (unchanged from ab_server)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class SnapshotFormatterTests
|
||||||
|
{
|
||||||
|
private static readonly DateTime FixedTime =
|
||||||
|
new(2026, 4, 21, 12, 34, 56, 789, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Format_includes_tag_value_status_and_both_timestamps()
|
||||||
|
{
|
||||||
|
var snap = new DataValueSnapshot(42, 0u, FixedTime, FixedTime);
|
||||||
|
var output = SnapshotFormatter.Format("N7:0", snap);
|
||||||
|
|
||||||
|
output.ShouldContain("Tag: N7:0");
|
||||||
|
output.ShouldContain("Value: 42");
|
||||||
|
output.ShouldContain("Status: 0x00000000 (Good)");
|
||||||
|
output.ShouldContain("Source Time: 2026-04-21T12:34:56.789Z");
|
||||||
|
output.ShouldContain("Server Time: 2026-04-21T12:34:56.789Z");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0x00000000u, "Good")]
|
||||||
|
[InlineData(0x80000000u, "Bad")]
|
||||||
|
[InlineData(0x80050000u, "BadCommunicationError")]
|
||||||
|
[InlineData(0x80060000u, "BadTimeout")]
|
||||||
|
[InlineData(0x80340000u, "BadNodeIdUnknown")]
|
||||||
|
[InlineData(0x40000000u, "Uncertain")]
|
||||||
|
public void FormatStatus_names_well_known_status_codes(uint status, string expectedName)
|
||||||
|
{
|
||||||
|
SnapshotFormatter.FormatStatus(status).ShouldContain(expectedName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormatStatus_unknown_codes_fall_back_to_hex_only()
|
||||||
|
{
|
||||||
|
// 0xDEADBEEF isn't in the shortlist — just render the hex form, no name.
|
||||||
|
SnapshotFormatter.FormatStatus(0xDEADBEEFu).ShouldBe("0xDEADBEEF");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormatValue_renders_null_as_placeholder()
|
||||||
|
{
|
||||||
|
var snap = new DataValueSnapshot(null, 0x80050000u, null, FixedTime);
|
||||||
|
var output = SnapshotFormatter.Format("Orphan", snap);
|
||||||
|
output.ShouldContain("Value: <null>");
|
||||||
|
output.ShouldContain("Source Time: -"); // null timestamp → dash
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormatValue_formats_booleans_lowercase()
|
||||||
|
{
|
||||||
|
var snap = new DataValueSnapshot(true, 0u, FixedTime, FixedTime);
|
||||||
|
SnapshotFormatter.Format("Coil", snap).ShouldContain("Value: true");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormatValue_formats_floats_invariant_culture()
|
||||||
|
{
|
||||||
|
// Guards against non-invariant decimal separators (e.g. comma on PL locales)
|
||||||
|
// that would break cross-platform log diffs.
|
||||||
|
var snap = new DataValueSnapshot(3.14f, 0u, FixedTime, FixedTime);
|
||||||
|
SnapshotFormatter.Format("F8:0", snap).ShouldContain("3.14");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormatValue_quotes_strings()
|
||||||
|
{
|
||||||
|
var snap = new DataValueSnapshot("hello", 0u, FixedTime, FixedTime);
|
||||||
|
SnapshotFormatter.Format("Msg", snap).ShouldContain("\"hello\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormatWrite_shows_status_with_tag_name()
|
||||||
|
{
|
||||||
|
var result = new WriteResult(0u);
|
||||||
|
SnapshotFormatter.FormatWrite("Scratch", result)
|
||||||
|
.ShouldBe("Write Scratch: 0x00000000 (Good)");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormatTable_aligns_columns_and_includes_header_separator()
|
||||||
|
{
|
||||||
|
var names = new[] { "A", "LongerTag" };
|
||||||
|
var snaps = new[]
|
||||||
|
{
|
||||||
|
new DataValueSnapshot(1, 0u, FixedTime, FixedTime),
|
||||||
|
new DataValueSnapshot(2, 0u, FixedTime, FixedTime),
|
||||||
|
};
|
||||||
|
var table = SnapshotFormatter.FormatTable(names, snaps);
|
||||||
|
|
||||||
|
table.ShouldContain("TAG");
|
||||||
|
table.ShouldContain("VALUE");
|
||||||
|
table.ShouldContain("STATUS");
|
||||||
|
table.ShouldContain("SOURCE TIME");
|
||||||
|
table.ShouldContain("---"); // separator row
|
||||||
|
table.ShouldContain("LongerTag");
|
||||||
|
table.ShouldContain("0x00000000");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormatTable_rejects_mismatched_lengths()
|
||||||
|
{
|
||||||
|
Should.Throw<ArgumentException>(() => SnapshotFormatter.FormatTable(
|
||||||
|
new[] { "A", "B" },
|
||||||
|
new[] { new DataValueSnapshot(1, 0u, FixedTime, FixedTime) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormatTimestamp_normalises_local_kind_to_utc()
|
||||||
|
{
|
||||||
|
// Unspecified / Local times must land on UTC in the output — otherwise a CI box in
|
||||||
|
// UTC+X would emit diffs against dev-laptop runs.
|
||||||
|
var local = new DateTime(2026, 4, 21, 8, 0, 0, DateTimeKind.Local);
|
||||||
|
var formatted = SnapshotFormatter.FormatTimestamp(local);
|
||||||
|
formatted.ShouldEndWith("Z");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||||
|
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Cli.Common\ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Task #220 — covers the DriverConfig JSON contract that
|
||||||
|
/// <see cref="FocasDriverFactoryExtensions.CreateInstance"/> parses when the bootstrap
|
||||||
|
/// pipeline (task #248) materialises FOCAS DriverInstance rows. Pure unit tests, no pipe
|
||||||
|
/// or CNC required.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class FocasDriverFactoryExtensionsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Register_adds_FOCAS_entry_to_registry()
|
||||||
|
{
|
||||||
|
var registry = new DriverFactoryRegistry();
|
||||||
|
FocasDriverFactoryExtensions.Register(registry);
|
||||||
|
registry.TryGet("FOCAS").ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Register_is_case_insensitive_via_registry()
|
||||||
|
{
|
||||||
|
var registry = new DriverFactoryRegistry();
|
||||||
|
FocasDriverFactoryExtensions.Register(registry);
|
||||||
|
registry.TryGet("focas").ShouldNotBeNull();
|
||||||
|
registry.TryGet("Focas").ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateInstance_with_ipc_backend_and_valid_config_returns_FocasDriver()
|
||||||
|
{
|
||||||
|
const string json = """
|
||||||
|
{
|
||||||
|
"Backend": "ipc",
|
||||||
|
"PipeName": "OtOpcUaFocasHost",
|
||||||
|
"SharedSecret": "secret-for-test",
|
||||||
|
"ConnectTimeoutMs": 5000,
|
||||||
|
"Series": "Thirty_i",
|
||||||
|
"TimeoutMs": 3000,
|
||||||
|
"Devices": [
|
||||||
|
{ "HostAddress": "focas://10.0.0.5:8193", "DeviceName": "Lathe1" }
|
||||||
|
],
|
||||||
|
"Tags": [
|
||||||
|
{ "Name": "Override", "DeviceHostAddress": "focas://10.0.0.5:8193",
|
||||||
|
"Address": "R100", "DataType": "Int32", "Writable": true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var driver = FocasDriverFactoryExtensions.CreateInstance("focas-0", json);
|
||||||
|
|
||||||
|
driver.ShouldNotBeNull();
|
||||||
|
driver.DriverInstanceId.ShouldBe("focas-0");
|
||||||
|
driver.DriverType.ShouldBe("FOCAS");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateInstance_defaults_Backend_to_ipc_when_unspecified()
|
||||||
|
{
|
||||||
|
// No "Backend" key → defaults to ipc → requires PipeName + SharedSecret.
|
||||||
|
const string json = """
|
||||||
|
{ "PipeName": "p", "SharedSecret": "s" }
|
||||||
|
""";
|
||||||
|
var driver = FocasDriverFactoryExtensions.CreateInstance("focas-default", json);
|
||||||
|
driver.DriverType.ShouldBe("FOCAS");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateInstance_ipc_backend_missing_PipeName_throws()
|
||||||
|
{
|
||||||
|
const string json = """{ "Backend": "ipc", "SharedSecret": "s" }""";
|
||||||
|
Should.Throw<InvalidOperationException>(
|
||||||
|
() => FocasDriverFactoryExtensions.CreateInstance("focas-missing-pipe", json))
|
||||||
|
.Message.ShouldContain("PipeName");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateInstance_ipc_backend_missing_SharedSecret_throws()
|
||||||
|
{
|
||||||
|
const string json = """{ "Backend": "ipc", "PipeName": "p" }""";
|
||||||
|
Should.Throw<InvalidOperationException>(
|
||||||
|
() => FocasDriverFactoryExtensions.CreateInstance("focas-missing-secret", json))
|
||||||
|
.Message.ShouldContain("SharedSecret");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateInstance_fwlib_backend_does_not_require_pipe_fields()
|
||||||
|
{
|
||||||
|
// Direct in-process Fwlib32 path. No pipe config needed; driver connects the DLL
|
||||||
|
// natively on first use.
|
||||||
|
const string json = """{ "Backend": "fwlib" }""";
|
||||||
|
var driver = FocasDriverFactoryExtensions.CreateInstance("focas-fwlib", json);
|
||||||
|
driver.DriverInstanceId.ShouldBe("focas-fwlib");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateInstance_unimplemented_backend_yields_driver_that_fails_fast_on_use()
|
||||||
|
{
|
||||||
|
// Useful for staging DriverInstance rows in the config DB before the Host is
|
||||||
|
// actually deployed — the server boots but reads/writes surface clear errors.
|
||||||
|
const string json = """{ "Backend": "unimplemented" }""";
|
||||||
|
var driver = FocasDriverFactoryExtensions.CreateInstance("focas-unimpl", json);
|
||||||
|
driver.DriverInstanceId.ShouldBe("focas-unimpl");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateInstance_unknown_backend_throws_with_expected_list()
|
||||||
|
{
|
||||||
|
const string json = """{ "Backend": "gibberish", "PipeName": "p", "SharedSecret": "s" }""";
|
||||||
|
Should.Throw<InvalidOperationException>(
|
||||||
|
() => FocasDriverFactoryExtensions.CreateInstance("focas-bad-backend", json))
|
||||||
|
.Message.ShouldContain("gibberish");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateInstance_rejects_unknown_Series()
|
||||||
|
{
|
||||||
|
const string json = """
|
||||||
|
{ "Backend": "fwlib", "Series": "NotARealSeries" }
|
||||||
|
""";
|
||||||
|
Should.Throw<InvalidOperationException>(
|
||||||
|
() => FocasDriverFactoryExtensions.CreateInstance("focas-bad-series", json))
|
||||||
|
.Message.ShouldContain("NotARealSeries");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateInstance_rejects_tag_with_missing_DataType()
|
||||||
|
{
|
||||||
|
const string json = """
|
||||||
|
{
|
||||||
|
"Backend": "fwlib",
|
||||||
|
"Devices": [{ "HostAddress": "focas://1.1.1.1:8193" }],
|
||||||
|
"Tags": [{ "Name": "Broken", "DeviceHostAddress": "focas://1.1.1.1:8193", "Address": "R1" }]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
Should.Throw<InvalidOperationException>(
|
||||||
|
() => FocasDriverFactoryExtensions.CreateInstance("focas-bad-tag", json))
|
||||||
|
.Message.ShouldContain("DataType");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateInstance_null_or_whitespace_args_rejected()
|
||||||
|
{
|
||||||
|
Should.Throw<ArgumentException>(
|
||||||
|
() => FocasDriverFactoryExtensions.CreateInstance("", "{}"));
|
||||||
|
Should.Throw<ArgumentException>(
|
||||||
|
() => FocasDriverFactoryExtensions.CreateInstance("id", ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Register_twice_throws()
|
||||||
|
{
|
||||||
|
var registry = new DriverFactoryRegistry();
|
||||||
|
FocasDriverFactoryExtensions.Register(registry);
|
||||||
|
Should.Throw<InvalidOperationException>(
|
||||||
|
() => FocasDriverFactoryExtensions.Register(registry));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Commands;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class ReadCommandTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(ModbusRegion.HoldingRegisters, 100, ModbusDataType.UInt16, "HR[100]:UInt16")]
|
||||||
|
[InlineData(ModbusRegion.Coils, 0, ModbusDataType.Bool, "Coil[0]:Bool")]
|
||||||
|
[InlineData(ModbusRegion.DiscreteInputs, 42, ModbusDataType.Bool, "DI[42]:Bool")]
|
||||||
|
[InlineData(ModbusRegion.InputRegisters, 5, ModbusDataType.Int16, "IR[5]:Int16")]
|
||||||
|
[InlineData(ModbusRegion.HoldingRegisters, 200, ModbusDataType.Float32, "HR[200]:Float32")]
|
||||||
|
public void SynthesiseTagName_produces_stable_region_prefix_plus_address_plus_type(
|
||||||
|
ModbusRegion region, ushort address, ModbusDataType type, string expected)
|
||||||
|
{
|
||||||
|
ReadCommand.SynthesiseTagName(region, address, type).ShouldBe(expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Commands;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Covers the <c>--value</c> string → CLR type parser inside
|
||||||
|
/// <see cref="WriteCommand.ParseValue"/>. This is the piece that guards against
|
||||||
|
/// locale surprises (e.g. comma-as-decimal-separator on PL locales), so all numeric
|
||||||
|
/// paths assert the invariant-culture path.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class WriteCommandParseValueTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("true", true)]
|
||||||
|
[InlineData("false", false)]
|
||||||
|
[InlineData("1", true)]
|
||||||
|
[InlineData("0", false)]
|
||||||
|
[InlineData("YES", true)]
|
||||||
|
[InlineData("No", false)]
|
||||||
|
[InlineData("on", true)]
|
||||||
|
[InlineData("off", false)]
|
||||||
|
public void ParseValue_Bool_accepts_common_aliases(string raw, bool expected)
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue(raw, ModbusDataType.Bool).ShouldBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Bool_rejects_unknown_strings()
|
||||||
|
{
|
||||||
|
Should.Throw<CliFx.Exceptions.CommandException>(
|
||||||
|
() => WriteCommand.ParseValue("maybe", ModbusDataType.Bool));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Int16_parses_positive_and_negative()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("-32768", ModbusDataType.Int16).ShouldBe((short)-32768);
|
||||||
|
WriteCommand.ParseValue("32767", ModbusDataType.Int16).ShouldBe((short)32767);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_UInt16_and_Bcd16_both_yield_ushort()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("65535", ModbusDataType.UInt16).ShouldBeOfType<ushort>();
|
||||||
|
WriteCommand.ParseValue("65535", ModbusDataType.Bcd16).ShouldBeOfType<ushort>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Float32_uses_invariant_culture_period_as_decimal_separator()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("3.14", ModbusDataType.Float32).ShouldBe(3.14f);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Float64_handles_larger_precision()
|
||||||
|
{
|
||||||
|
var result = WriteCommand.ParseValue("2.718281828", ModbusDataType.Float64);
|
||||||
|
result.ShouldBeOfType<double>();
|
||||||
|
((double)result).ShouldBe(2.718281828d, 0.0000001d);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_String_returns_raw_string_unmodified()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("hello world", ModbusDataType.String).ShouldBe("hello world");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_BitInRegister_accepts_bool_aliases()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("true", ModbusDataType.BitInRegister).ShouldBe(true);
|
||||||
|
WriteCommand.ParseValue("0", ModbusDataType.BitInRegister).ShouldBe(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Int32_parses_negative_max()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("-2147483648", ModbusDataType.Int32).ShouldBe(int.MinValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_rejects_non_numeric_for_numeric_types()
|
||||||
|
{
|
||||||
|
Should.Throw<FormatException>(
|
||||||
|
() => WriteCommand.ParseValue("not-a-number", ModbusDataType.Int32));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||||
|
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli\ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Commands;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Covers <see cref="WriteCommand.ParseValue"/> across every S7 atomic type.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class WriteCommandParseValueTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("true", true)]
|
||||||
|
[InlineData("0", false)]
|
||||||
|
[InlineData("yes", true)]
|
||||||
|
[InlineData("OFF", false)]
|
||||||
|
public void ParseValue_Bool_accepts_common_aliases(string raw, bool expected)
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue(raw, S7DataType.Bool).ShouldBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Bool_rejects_garbage()
|
||||||
|
{
|
||||||
|
Should.Throw<CliFx.Exceptions.CommandException>(
|
||||||
|
() => WriteCommand.ParseValue("maybe", S7DataType.Bool));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Byte_ranges()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("0", S7DataType.Byte).ShouldBe((byte)0);
|
||||||
|
WriteCommand.ParseValue("255", S7DataType.Byte).ShouldBe((byte)255);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Int16_signed_range()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("-32768", S7DataType.Int16).ShouldBe((short)-32768);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_UInt16_unsigned_max()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("65535", S7DataType.UInt16).ShouldBe((ushort)65535);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Int32_parses_negative()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("-2147483648", S7DataType.Int32).ShouldBe(int.MinValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_UInt32_parses_max()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("4294967295", S7DataType.UInt32).ShouldBe(uint.MaxValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Int64_parses_min()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("-9223372036854775808", S7DataType.Int64).ShouldBe(long.MinValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_UInt64_parses_max()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("18446744073709551615", S7DataType.UInt64).ShouldBe(ulong.MaxValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Float32_invariant_culture()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("3.14", S7DataType.Float32).ShouldBe(3.14f);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Float64_higher_precision()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("2.718281828", S7DataType.Float64).ShouldBeOfType<double>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_String_passthrough()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("hallo siemens", S7DataType.String).ShouldBe("hallo siemens");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_DateTime_parses_roundtrip_form()
|
||||||
|
{
|
||||||
|
var result = WriteCommand.ParseValue("2026-04-21T12:34:56Z", S7DataType.DateTime);
|
||||||
|
result.ShouldBeOfType<DateTime>();
|
||||||
|
((DateTime)result).Year.ShouldBe(2026);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_non_numeric_for_numeric_types_throws()
|
||||||
|
{
|
||||||
|
Should.Throw<FormatException>(
|
||||||
|
() => WriteCommand.ParseValue("xyz", S7DataType.Int16));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("DB1.DBW0", S7DataType.Int16, "DB1.DBW0:Int16")]
|
||||||
|
[InlineData("M0.0", S7DataType.Bool, "M0.0:Bool")]
|
||||||
|
[InlineData("IW4", S7DataType.UInt16, "IW4:UInt16")]
|
||||||
|
[InlineData("QD8", S7DataType.UInt32, "QD8:UInt32")]
|
||||||
|
[InlineData("DB10.STRING[0]", S7DataType.String, "DB10.STRING[0]:String")]
|
||||||
|
public void SynthesiseTagName_preserves_S7_address_verbatim(
|
||||||
|
string address, S7DataType type, string expected)
|
||||||
|
{
|
||||||
|
ReadCommand.SynthesiseTagName(address, type).ShouldBe(expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||||
|
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.S7.Cli\ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Commands;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Covers <see cref="WriteCommand.ParseValue"/> for the IEC 61131-3 atomic types
|
||||||
|
/// TwinCAT exposes. Wider matrix than AB CIP because IEC adds WSTRING + the four
|
||||||
|
/// TIME/DATE variants that all marshal as UDINT on the wire.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class WriteCommandParseValueTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("true", true)]
|
||||||
|
[InlineData("0", false)]
|
||||||
|
[InlineData("on", true)]
|
||||||
|
[InlineData("NO", false)]
|
||||||
|
public void ParseValue_Bool_accepts_common_aliases(string raw, bool expected)
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue(raw, TwinCATDataType.Bool).ShouldBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Bool_rejects_garbage()
|
||||||
|
{
|
||||||
|
Should.Throw<CliFx.Exceptions.CommandException>(
|
||||||
|
() => WriteCommand.ParseValue("maybe", TwinCATDataType.Bool));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_SInt_signed_byte()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("-128", TwinCATDataType.SInt).ShouldBe((sbyte)-128);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_USInt_unsigned_byte()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("255", TwinCATDataType.USInt).ShouldBe((byte)255);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Int_signed_16bit()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("-32768", TwinCATDataType.Int).ShouldBe((short)-32768);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_UInt_unsigned_16bit()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("65535", TwinCATDataType.UInt).ShouldBe((ushort)65535);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_DInt_int32_bounds()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("-2147483648", TwinCATDataType.DInt).ShouldBe(int.MinValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_UDInt_uint32_max()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("4294967295", TwinCATDataType.UDInt).ShouldBe(uint.MaxValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_LInt_int64_min()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("-9223372036854775808", TwinCATDataType.LInt).ShouldBe(long.MinValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_ULInt_uint64_max()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("18446744073709551615", TwinCATDataType.ULInt).ShouldBe(ulong.MaxValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Real_invariant_culture()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("3.14", TwinCATDataType.Real).ShouldBe(3.14f);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_LReal_higher_precision()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("2.718281828", TwinCATDataType.LReal).ShouldBeOfType<double>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_String_passthrough()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("hallo beckhoff", TwinCATDataType.String).ShouldBe("hallo beckhoff");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_WString_passthrough()
|
||||||
|
{
|
||||||
|
// CLI layer doesn't distinguish UTF-8 input; the driver handles the WSTRING
|
||||||
|
// encoding on the wire.
|
||||||
|
WriteCommand.ParseValue("überstall", TwinCATDataType.WString).ShouldBe("überstall");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(TwinCATDataType.Time)]
|
||||||
|
[InlineData(TwinCATDataType.Date)]
|
||||||
|
[InlineData(TwinCATDataType.DateTime)]
|
||||||
|
[InlineData(TwinCATDataType.TimeOfDay)]
|
||||||
|
public void ParseValue_IEC_date_time_variants_land_on_uint32(TwinCATDataType type)
|
||||||
|
{
|
||||||
|
// IEC 61131-3 TIME / DATE / DT / TOD all marshal as UDINT on the wire; the CLI
|
||||||
|
// accepts a numeric raw value and lets the caller handle the encoding.
|
||||||
|
WriteCommand.ParseValue("1234567", type).ShouldBeOfType<uint>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Structure_refused()
|
||||||
|
{
|
||||||
|
Should.Throw<CliFx.Exceptions.CommandException>(
|
||||||
|
() => WriteCommand.ParseValue("42", TwinCATDataType.Structure));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_non_numeric_for_numeric_types_throws()
|
||||||
|
{
|
||||||
|
Should.Throw<FormatException>(
|
||||||
|
() => WriteCommand.ParseValue("xyz", TwinCATDataType.DInt));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("MAIN.bStart", TwinCATDataType.Bool, "MAIN.bStart:Bool")]
|
||||||
|
[InlineData("GVL.Counter", TwinCATDataType.DInt, "GVL.Counter:DInt")]
|
||||||
|
[InlineData("Motor1.Status.Running", TwinCATDataType.Bool, "Motor1.Status.Running:Bool")]
|
||||||
|
[InlineData("Recipe[3]", TwinCATDataType.Real, "Recipe[3]:Real")]
|
||||||
|
public void SynthesiseTagName_preserves_symbolic_path_verbatim(
|
||||||
|
string symbol, TwinCATDataType type, string expected)
|
||||||
|
{
|
||||||
|
ReadCommand.SynthesiseTagName(symbol, type).ShouldBe(expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||||
|
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Opc.Ua;
|
||||||
|
using Opc.Ua.Client;
|
||||||
|
using Opc.Ua.Configuration;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Task #219 — end-to-end server integration coverage for the <see cref="IAlarmSource"/>
|
||||||
|
/// dispatch path. Boots the full OPC UA stack + a fake <see cref="IAlarmSource"/> driver,
|
||||||
|
/// opens a client session, raises a driver-side transition, and asserts it propagates
|
||||||
|
/// through <c>GenericDriverNodeManager</c>'s alarm forwarder into
|
||||||
|
/// <c>DriverNodeManager.ConditionSink</c>, updates the server-side
|
||||||
|
/// <c>AlarmConditionState</c> child attributes (Severity / Message / ActiveState), and
|
||||||
|
/// flows out to an OPC UA subscription on the Server object's EventNotifier.
|
||||||
|
///
|
||||||
|
/// Companion to <see cref="HistoryReadIntegrationTests"/> which covers the
|
||||||
|
/// <see cref="IHistoryProvider"/> dispatch path; together they close the server-side
|
||||||
|
/// integration gap for optional driver capabilities (plan decision #62).
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
public sealed class AlarmSubscribeIntegrationTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private static readonly int Port = 48700 + Random.Shared.Next(0, 99);
|
||||||
|
private readonly string _endpoint = $"opc.tcp://localhost:{Port}/OtOpcUaAlarmTest";
|
||||||
|
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-alarm-test-{Guid.NewGuid():N}");
|
||||||
|
|
||||||
|
private DriverHost _driverHost = null!;
|
||||||
|
private OpcUaApplicationHost _server = null!;
|
||||||
|
private AlarmDriver _driver = null!;
|
||||||
|
|
||||||
|
public async ValueTask InitializeAsync()
|
||||||
|
{
|
||||||
|
_driverHost = new DriverHost();
|
||||||
|
_driver = new AlarmDriver();
|
||||||
|
await _driverHost.RegisterAsync(_driver, "{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var options = new OpcUaServerOptions
|
||||||
|
{
|
||||||
|
EndpointUrl = _endpoint,
|
||||||
|
ApplicationName = "OtOpcUaAlarmTest",
|
||||||
|
ApplicationUri = "urn:OtOpcUa:Server:AlarmTest",
|
||||||
|
PkiStoreRoot = _pkiRoot,
|
||||||
|
AutoAcceptUntrustedClientCertificates = true,
|
||||||
|
HealthEndpointsEnabled = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
_server = new OpcUaApplicationHost(options, _driverHost, new DenyAllUserAuthenticator(),
|
||||||
|
NullLoggerFactory.Instance, NullLogger<OpcUaApplicationHost>.Instance);
|
||||||
|
await _server.StartAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await _server.DisposeAsync();
|
||||||
|
await _driverHost.DisposeAsync();
|
||||||
|
try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Driver_alarm_transition_updates_server_side_AlarmConditionState_node()
|
||||||
|
{
|
||||||
|
using var session = await OpenSessionAsync();
|
||||||
|
var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:alarm-driver");
|
||||||
|
|
||||||
|
_driver.RaiseAlarm(new AlarmEventArgs(
|
||||||
|
SubscriptionHandle: new FakeHandle("sub"),
|
||||||
|
SourceNodeId: "Tank.HiHi",
|
||||||
|
ConditionId: "cond-1",
|
||||||
|
AlarmType: "Active",
|
||||||
|
Message: "Level exceeded upper-upper",
|
||||||
|
Severity: AlarmSeverity.High,
|
||||||
|
SourceTimestampUtc: DateTime.UtcNow));
|
||||||
|
|
||||||
|
// The alarm-condition node's identifier is the driver full-reference + ".Condition"
|
||||||
|
// (DriverNodeManager.VariableHandle.MarkAsAlarmCondition). Server-side state changes
|
||||||
|
// are applied synchronously under DriverNodeManager.Lock inside ConditionSink.OnTransition,
|
||||||
|
// so by the time RaiseAlarm returns the node state has been flushed.
|
||||||
|
var conditionNodeId = new NodeId("Tank.HiHi.Condition", nsIndex);
|
||||||
|
|
||||||
|
// Browse the condition node for the well-known Part-9 child variables. The stack
|
||||||
|
// materializes Severity / Message / ActiveState / AckedState as children below the
|
||||||
|
// AlarmConditionState; their NodeIds are allocated by the stack so we discover them
|
||||||
|
// by BrowseName rather than guessing.
|
||||||
|
var browseDescriptions = new BrowseDescriptionCollection
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
NodeId = conditionNodeId,
|
||||||
|
BrowseDirection = BrowseDirection.Forward,
|
||||||
|
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
|
||||||
|
IncludeSubtypes = true,
|
||||||
|
NodeClassMask = 0,
|
||||||
|
ResultMask = (uint)BrowseResultMask.All,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
session.Browse(null, null, 0, browseDescriptions, out var browseResults, out _);
|
||||||
|
var children = browseResults[0].References
|
||||||
|
.ToDictionary(r => r.BrowseName.Name,
|
||||||
|
r => ExpandedNodeId.ToNodeId(r.NodeId, session.NamespaceUris),
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
|
||||||
|
children.ShouldContainKey("Severity");
|
||||||
|
children.ShouldContainKey("Message");
|
||||||
|
children.ShouldContainKey("ActiveState");
|
||||||
|
|
||||||
|
// Severity / Message / ActiveState.Id reflect the driver-fired transition — verifies
|
||||||
|
// the forwarder → ConditionSink.OnTransition → alarm.ClearChangeMasks pipeline
|
||||||
|
// landed the new values in addressable child nodes. DriverNodeManager's
|
||||||
|
// AssignSymbolicDescendantIds keeps each child reachable under the node manager's
|
||||||
|
// namespace so Read resolves against the predefined-node dictionary.
|
||||||
|
var severity = session.ReadValue(children["Severity"]);
|
||||||
|
var message = session.ReadValue(children["Message"]);
|
||||||
|
severity.Value.ShouldBe((ushort)700); // AlarmSeverity.High → 700 (MapSeverity)
|
||||||
|
((LocalizedText)message.Value).Text.ShouldBe("Level exceeded upper-upper");
|
||||||
|
|
||||||
|
// ActiveState exposes its boolean Id as a HasProperty child.
|
||||||
|
var activeBrowse = new BrowseDescriptionCollection
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
NodeId = children["ActiveState"],
|
||||||
|
BrowseDirection = BrowseDirection.Forward,
|
||||||
|
ReferenceTypeId = ReferenceTypeIds.HasProperty,
|
||||||
|
IncludeSubtypes = true,
|
||||||
|
ResultMask = (uint)BrowseResultMask.All,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
session.Browse(null, null, 0, activeBrowse, out var activeChildren, out _);
|
||||||
|
var idRef = activeChildren[0].References.Single(r => r.BrowseName.Name == "Id");
|
||||||
|
var activeId = session.ReadValue(ExpandedNodeId.ToNodeId(idRef.NodeId, session.NamespaceUris));
|
||||||
|
activeId.Value.ShouldBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Driver_alarm_event_flows_to_client_subscription_on_Server_EventNotifier()
|
||||||
|
{
|
||||||
|
// AddRootNotifier registers the AlarmConditionState as a Server-object notifier
|
||||||
|
// source, so a subscription with an EventFilter on Server receives the
|
||||||
|
// ReportEvent calls ConditionSink emits per-transition.
|
||||||
|
using var session = await OpenSessionAsync();
|
||||||
|
|
||||||
|
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
|
||||||
|
session.AddSubscription(subscription);
|
||||||
|
await subscription.CreateAsync();
|
||||||
|
|
||||||
|
var received = new List<EventFieldList>();
|
||||||
|
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
var filter = new EventFilter();
|
||||||
|
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.EventId);
|
||||||
|
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.SourceName);
|
||||||
|
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Message);
|
||||||
|
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Severity);
|
||||||
|
filter.WhereClause = new ContentFilter();
|
||||||
|
filter.WhereClause.Push(FilterOperator.OfType,
|
||||||
|
new LiteralOperand { Value = new Variant(ObjectTypeIds.AlarmConditionType) });
|
||||||
|
|
||||||
|
var item = new MonitoredItem(subscription.DefaultItem)
|
||||||
|
{
|
||||||
|
StartNodeId = ObjectIds.Server,
|
||||||
|
AttributeId = Attributes.EventNotifier,
|
||||||
|
NodeClass = NodeClass.Object,
|
||||||
|
SamplingInterval = 0,
|
||||||
|
QueueSize = 100,
|
||||||
|
Filter = filter,
|
||||||
|
};
|
||||||
|
item.Notification += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.NotificationValue is EventFieldList fields)
|
||||||
|
{
|
||||||
|
lock (received) { received.Add(fields); gate.TrySetResult(); }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
subscription.AddItem(item);
|
||||||
|
await subscription.ApplyChangesAsync();
|
||||||
|
|
||||||
|
// Give the publish loop a tick to establish before firing.
|
||||||
|
await Task.Delay(200);
|
||||||
|
|
||||||
|
_driver.RaiseAlarm(new AlarmEventArgs(
|
||||||
|
new FakeHandle("sub"), "Tank.HiHi", "cond-x", "Active",
|
||||||
|
"High-high tripped", AlarmSeverity.Critical, DateTime.UtcNow));
|
||||||
|
|
||||||
|
var delivered = await Task.WhenAny(gate.Task, Task.Delay(TimeSpan.FromSeconds(10)));
|
||||||
|
delivered.ShouldBe(gate.Task, "alarm event must arrive at the client within 10s");
|
||||||
|
|
||||||
|
EventFieldList first;
|
||||||
|
lock (received) first = received[0];
|
||||||
|
// Filter field order: 0=EventId, 1=SourceName, 2=Message, 3=Severity.
|
||||||
|
((LocalizedText)first.EventFields[2].Value).Text.ShouldBe("High-high tripped");
|
||||||
|
first.EventFields[3].Value.ShouldBe((ushort)900); // Critical → 900
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Each_IsAlarm_variable_registers_its_own_condition_node_in_the_driver_namespace()
|
||||||
|
{
|
||||||
|
// Tag-scoped alarm wiring: DiscoverAsync declares two IsAlarm variables and calls
|
||||||
|
// MarkAsAlarmCondition on each. The server-side DriverNodeManager wraps each call in
|
||||||
|
// a CapturingHandle that creates a sibling AlarmConditionState + registers a sink
|
||||||
|
// under the driver full-reference. Browse should show both condition nodes with
|
||||||
|
// distinct NodeIds using the FullReference + ".Condition" convention.
|
||||||
|
using var session = await OpenSessionAsync();
|
||||||
|
var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:alarm-driver");
|
||||||
|
|
||||||
|
_driver.RaiseAlarm(new AlarmEventArgs(
|
||||||
|
new FakeHandle("sub"), "Tank.HiHi", "c", "Active", "first", AlarmSeverity.High,
|
||||||
|
DateTime.UtcNow));
|
||||||
|
|
||||||
|
var attrs = new ReadValueIdCollection
|
||||||
|
{
|
||||||
|
new() { NodeId = new NodeId("Tank.HiHi.Condition", nsIndex), AttributeId = Attributes.DisplayName },
|
||||||
|
new() { NodeId = new NodeId("Heater.OverTemp.Condition", nsIndex), AttributeId = Attributes.DisplayName },
|
||||||
|
};
|
||||||
|
session.Read(null, 0, TimestampsToReturn.Neither, attrs, out var results, out _);
|
||||||
|
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||||
|
results[1].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||||
|
((LocalizedText)results[0].Value).Text.ShouldBe("Tank.HiHi");
|
||||||
|
((LocalizedText)results[1].Value).Text.ShouldBe("Heater.OverTemp");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ISession> OpenSessionAsync()
|
||||||
|
{
|
||||||
|
var cfg = new ApplicationConfiguration
|
||||||
|
{
|
||||||
|
ApplicationName = "OtOpcUaAlarmTestClient",
|
||||||
|
ApplicationUri = "urn:OtOpcUa:AlarmTestClient",
|
||||||
|
ApplicationType = ApplicationType.Client,
|
||||||
|
SecurityConfiguration = new SecurityConfiguration
|
||||||
|
{
|
||||||
|
ApplicationCertificate = new CertificateIdentifier
|
||||||
|
{
|
||||||
|
StoreType = CertificateStoreType.Directory,
|
||||||
|
StorePath = Path.Combine(_pkiRoot, "client-own"),
|
||||||
|
SubjectName = "CN=OtOpcUaAlarmTestClient",
|
||||||
|
},
|
||||||
|
TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-issuers") },
|
||||||
|
TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-trusted") },
|
||||||
|
RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-rejected") },
|
||||||
|
AutoAcceptUntrustedCertificates = true,
|
||||||
|
AddAppCertToTrustedStore = true,
|
||||||
|
},
|
||||||
|
TransportConfigurations = new TransportConfigurationCollection(),
|
||||||
|
TransportQuotas = new TransportQuotas { OperationTimeout = 15000 },
|
||||||
|
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 },
|
||||||
|
};
|
||||||
|
await cfg.Validate(ApplicationType.Client);
|
||||||
|
cfg.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||||
|
|
||||||
|
var instance = new ApplicationInstance { ApplicationConfiguration = cfg, ApplicationType = ApplicationType.Client };
|
||||||
|
await instance.CheckApplicationInstanceCertificate(true, CertificateFactory.DefaultKeySize);
|
||||||
|
|
||||||
|
var selected = CoreClientUtils.SelectEndpoint(cfg, _endpoint, useSecurity: false);
|
||||||
|
var endpointConfig = EndpointConfiguration.Create(cfg);
|
||||||
|
var configuredEndpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
|
||||||
|
|
||||||
|
return await Session.Create(cfg, configuredEndpoint, false, "OtOpcUaAlarmTestClientSession", 60000,
|
||||||
|
new UserIdentity(new AnonymousIdentityToken()), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stub <see cref="IAlarmSource"/> driver. <see cref="DiscoverAsync"/> emits two alarm-
|
||||||
|
/// bearing variables (so tag-scoped fan-out can be asserted); <see cref="RaiseAlarm"/>
|
||||||
|
/// fires <see cref="OnAlarmEvent"/> exactly like a real driver would.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class AlarmDriver : IDriver, ITagDiscovery, IAlarmSource
|
||||||
|
{
|
||||||
|
public string DriverInstanceId => "alarm-driver";
|
||||||
|
public string DriverType => "AlarmStub";
|
||||||
|
|
||||||
|
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
|
||||||
|
|
||||||
|
public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||||
|
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||||
|
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
||||||
|
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
||||||
|
public long GetMemoryFootprint() => 0;
|
||||||
|
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var tank = builder.Folder("Tank", "Tank");
|
||||||
|
var hiHi = tank.Variable("HiHi", "HiHi", new DriverAttributeInfo(
|
||||||
|
"Tank.HiHi", DriverDataType.Boolean, false, null,
|
||||||
|
SecurityClassification.FreeAccess, false, IsAlarm: true));
|
||||||
|
hiHi.MarkAsAlarmCondition(new AlarmConditionInfo(
|
||||||
|
"Tank.HiHi", AlarmSeverity.High, "High-high alarm"));
|
||||||
|
|
||||||
|
var heater = builder.Folder("Heater", "Heater");
|
||||||
|
var ot = heater.Variable("OverTemp", "OverTemp", new DriverAttributeInfo(
|
||||||
|
"Heater.OverTemp", DriverDataType.Boolean, false, null,
|
||||||
|
SecurityClassification.FreeAccess, false, IsAlarm: true));
|
||||||
|
ot.MarkAsAlarmCondition(new AlarmConditionInfo(
|
||||||
|
"Heater.OverTemp", AlarmSeverity.Critical, "Over-temperature"));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RaiseAlarm(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args);
|
||||||
|
|
||||||
|
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||||||
|
IReadOnlyList<string> _, CancellationToken __)
|
||||||
|
=> Task.FromResult<IAlarmSubscriptionHandle>(new FakeHandle("sub"));
|
||||||
|
|
||||||
|
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle _, CancellationToken __)
|
||||||
|
=> Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task AcknowledgeAsync(
|
||||||
|
IReadOnlyList<AlarmAcknowledgeRequest> _, CancellationToken __)
|
||||||
|
=> Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeHandle(string diagnosticId) : IAlarmSubscriptionHandle
|
||||||
|
{
|
||||||
|
public string DiagnosticId { get; } = diagnosticId;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user