Compare commits
46 Commits
phase-7-fu
...
v2
| Author | SHA1 | Date | |
|---|---|---|---|
| 05d2a7fd00 | |||
|
|
95c7e0b490 | ||
| e1f172c053 | |||
|
|
6d290adb37 | ||
| cc8a6c9ec1 | |||
|
|
2ec6aa480e | ||
| 682c1c5e75 | |||
|
|
e8172f9452 | ||
| 3af746c4b6 | |||
|
|
7ba783de77 | ||
| 35d24c2f80 | |||
|
|
55245a962e | ||
| 16d9592a8a | |||
|
|
2666a598ae | ||
| 5834d62906 | |||
|
|
fe981b0b7f | ||
| 7b1c910806 | |||
|
|
a9b585ac5b | ||
| 097f92fdb8 | |||
|
|
8d92e00e38 | ||
| 1507486b45 | |||
|
|
adce4e7727 | ||
| 4446a3ce5b | |||
|
|
4dc685a365 | ||
| ff50aac59f | |||
|
|
b2065f8730 | ||
| 9020b5854c | |||
|
|
5dac2e9375 | ||
| b644b26310 | |||
|
|
012c6a4e7a | ||
| ae07fea630 | |||
|
|
c41831794a | ||
| 3e3c7206dd | |||
|
|
4e96f228b2 | ||
| 443474f58f | |||
|
|
dfe3731c73 | ||
| 6863cc4652 | |||
|
|
8221fac8c1 | ||
| bc44711dca | |||
|
|
acf31fd943 | ||
| 7e143e293b | |||
|
|
2cb22598d6 | ||
|
|
3d78033ea4 | ||
| 48a43ac96e | |||
|
|
98a8031772 | ||
| efdf04320a |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -30,3 +30,10 @@ packages/
|
||||
.claude/
|
||||
|
||||
.local/
|
||||
|
||||
# LiteDB local config cache (Phase 6.1 Stream D — runtime artifact, not source)
|
||||
src/ZB.MOM.WW.OtOpcUa.Server/config_cache.db
|
||||
|
||||
# E2E sidecar config — NodeIds are specific to each dev's local seed (see scripts/e2e/README.md)
|
||||
scripts/e2e/e2e-config.json
|
||||
config_cache*.db
|
||||
|
||||
@@ -24,6 +24,13 @@
|
||||
<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.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.Driver.FOCAS.Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Analyzers/ZB.MOM.WW.OtOpcUa.Analyzers.csproj"/>
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
@@ -44,6 +51,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.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.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.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"/>
|
||||
|
||||
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.
|
||||
92
docs/DriverClis.md
Normal file
92
docs/DriverClis.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# Driver test-client CLIs
|
||||
|
||||
Five shell-level ad-hoc validation tools, one per native-protocol driver family.
|
||||
Each mirrors the v1 `otopcua-cli` shape (probe / read / write / subscribe) against
|
||||
the **same driver** the OtOpcUa server uses — so "does the CLI see it?" and
|
||||
"does the server see it?" are the same question.
|
||||
|
||||
| CLI | Protocol | Docs |
|
||||
|---|---|---|
|
||||
| `otopcua-modbus-cli` | Modbus-TCP | [Driver.Modbus.Cli.md](Driver.Modbus.Cli.md) |
|
||||
| `otopcua-abcip-cli` | CIP / EtherNet-IP (Logix symbolic) | [Driver.AbCip.Cli.md](Driver.AbCip.Cli.md) |
|
||||
| `otopcua-ablegacy-cli` | PCCC (SLC / MicroLogix / PLC-5) | [Driver.AbLegacy.Cli.md](Driver.AbLegacy.Cli.md) |
|
||||
| `otopcua-s7-cli` | S7comm / ISO-on-TCP | [Driver.S7.Cli.md](Driver.S7.Cli.md) |
|
||||
| `otopcua-twincat-cli` | Beckhoff ADS | [Driver.TwinCAT.Cli.md](Driver.TwinCAT.Cli.md) |
|
||||
|
||||
The OPC UA client CLI lives separately and predates this suite —
|
||||
see [Client.CLI.md](Client.CLI.md) for `otopcua-cli`.
|
||||
|
||||
## Shared commands
|
||||
|
||||
Every driver CLI exposes the same four verbs:
|
||||
|
||||
- **`probe`** — open a session, read one sentinel tag, print driver health.
|
||||
Fastest "is the device talking?" check.
|
||||
- **`read`** — synthesise a one-tag driver config from `--type` / `--address`
|
||||
(or `--tag` / `--symbol`) flags, read once, print the snapshot. No extra
|
||||
config file needed.
|
||||
- **`write`** — same shape plus `--value`. Values parse per `--type` using
|
||||
invariant culture. Booleans accept `true` / `false` / `1` / `0` / `yes` /
|
||||
`no` / `on` / `off`. Writes are **non-idempotent by default** — a timeout
|
||||
after the device already applied the write will not auto-retry (plan
|
||||
decisions #44, #45).
|
||||
- **`subscribe`** — long-running data-change stream until Ctrl+C. Uses native
|
||||
push where available (TwinCAT ADS notifications) and falls back to polling
|
||||
(`PollGroupEngine`) where the protocol has no push (Modbus, AB, S7).
|
||||
|
||||
## Shared infrastructure
|
||||
|
||||
All five CLIs depend on `src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/`:
|
||||
|
||||
- `DriverCommandBase` — `--verbose` + Serilog configuration + the abstract
|
||||
`Timeout` surface every protocol-specific base overrides with its own
|
||||
default.
|
||||
- `SnapshotFormatter` — consistent output across every CLI: tag / value /
|
||||
status / source-time / server-time for single reads, a 4-column table for
|
||||
batches, `Write <tag>: 0x... (Name)` for writes, and one line per change
|
||||
event for subscriptions. OPC UA status codes render as `0xXXXXXXXX (Name)`
|
||||
with a shortlist for `Good` / `Bad*` / `Uncertain`; unknown codes fall
|
||||
back to hex.
|
||||
|
||||
Writing a sixth CLI (hypothetical Galaxy / FOCAS) costs roughly 150 lines:
|
||||
a `{Family}CommandBase` + four thin command classes that hand their flag
|
||||
values to the already-shipped driver.
|
||||
|
||||
## Typical cross-CLI workflows
|
||||
|
||||
- **Commissioning a new device** — `probe` first, then `read` a known-good
|
||||
tag. If the device is up + talking the protocol, both pass; if the tag is
|
||||
wrong you'll see the read fail with a protocol-specific error.
|
||||
- **Reproducing a production bug** — `subscribe` to the tag the bug report
|
||||
names, then have the operator run the scenario. You get an HH:mm:ss.fff
|
||||
timeline of exactly when each value changed.
|
||||
- **Validating a recipe write** — `write` + `read` back. If the server's
|
||||
write path would have done anything different, the CLI would have too.
|
||||
- **Byte-order / word-swap debugging** — `read` with one `--byte-order`,
|
||||
then the other. The plausible result identifies the correct setting
|
||||
for that device family. (Modbus, S7.)
|
||||
|
||||
## Known gaps
|
||||
|
||||
- **AB Legacy cip-path quirk** — libplctag's ab_server requires a
|
||||
non-empty CIP routing path before forwarding to the PCCC dispatcher.
|
||||
Pass `--gateway "ab://127.0.0.1:44818/1,0"` against the Docker
|
||||
fixture; real SLC / MicroLogix / PLC-5 hardware accepts an empty
|
||||
path (`ab://host:44818/`). Bit-file writes (`B3:0/5`) still surface
|
||||
`0x803D0000` against ab_server — route operator-critical bit writes
|
||||
to real hardware until upstream fixes this.
|
||||
- **S7 PUT/GET communication** must be enabled in TIA Portal for any
|
||||
S7-1200/1500. See [Driver.S7.Cli.md](Driver.S7.Cli.md).
|
||||
- **TwinCAT AMS router** must be reachable (local XAR, standalone Router
|
||||
NuGet, or authorised remote route). See
|
||||
[Driver.TwinCAT.Cli.md](Driver.TwinCAT.Cli.md).
|
||||
- **Structure / UDT writes** are refused by the AB CIP + TwinCAT CLIs —
|
||||
whole-UDT writes need a declared member layout that belongs in a real
|
||||
driver config, not a one-shot flag.
|
||||
|
||||
## Tracking
|
||||
|
||||
Tasks #249 / #250 / #251 shipped the suite. 122 unit tests cumulative
|
||||
(16 shared-lib + 106 across the five CLIs) — run
|
||||
`dotnet test tests/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests` +
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.*.Cli.Tests` to re-verify.
|
||||
@@ -54,8 +54,14 @@ For Modbus / S7 / AB CIP / AB Legacy / TwinCAT / FOCAS / OPC UA Client specifics
|
||||
|
||||
| Doc | Covers |
|
||||
|-----|--------|
|
||||
| [Client.CLI.md](Client.CLI.md) | `lmxopcua-cli` — command-line client |
|
||||
| [Client.CLI.md](Client.CLI.md) | `otopcua-cli` — OPC UA command-line client |
|
||||
| [Client.UI.md](Client.UI.md) | Avalonia desktop client |
|
||||
| [DriverClis.md](DriverClis.md) | Driver test-client CLIs — index + shared commands |
|
||||
| [Driver.Modbus.Cli.md](Driver.Modbus.Cli.md) | `otopcua-modbus-cli` — Modbus-TCP |
|
||||
| [Driver.AbCip.Cli.md](Driver.AbCip.Cli.md) | `otopcua-abcip-cli` — ControlLogix / CompactLogix / Micro800 / GuardLogix |
|
||||
| [Driver.AbLegacy.Cli.md](Driver.AbLegacy.Cli.md) | `otopcua-ablegacy-cli` — SLC / MicroLogix / PLC-5 (PCCC) |
|
||||
| [Driver.S7.Cli.md](Driver.S7.Cli.md) | `otopcua-s7-cli` — Siemens S7-300 / S7-400 / S7-1200 / S7-1500 |
|
||||
| [Driver.TwinCAT.Cli.md](Driver.TwinCAT.Cli.md) | `otopcua-twincat-cli` — Beckhoff TwinCAT 2/3 ADS |
|
||||
|
||||
### Requirements
|
||||
|
||||
|
||||
@@ -93,11 +93,13 @@ cover the common ones but uncommon ones (`R` counters, `S` status files,
|
||||
|
||||
## Follow-up candidates
|
||||
|
||||
1. **Fix ab_server PCCC coverage upstream** — the scaffold lands the
|
||||
Docker infrastructure; the wire-level round-trip gap is in ab_server
|
||||
itself. Filing a patch to `libplctag/libplctag` to expand PCCC
|
||||
server-side opcode coverage would make the scaffolded smoke tests
|
||||
pass without a golden-box tier.
|
||||
1. **Expand ab_server PCCC coverage** — the smoke suite passes today
|
||||
for N (Int16), F (Float32), and L (Int32) files across SLC500 /
|
||||
MicroLogix / PLC-5 modes with the `/1,0` cip-path workaround in
|
||||
place. Known residual gap: bit-file writes (`B3:0/5`) surface
|
||||
`0x803D0000`. Contributing a patch to `libplctag/libplctag` to close
|
||||
this + documenting ab_server's empty-path rejection in its README
|
||||
would remove the last Docker-vs-hardware divergences.
|
||||
2. **Rockwell RSEmulate 500 golden-box tier** — Rockwell's real emulator
|
||||
for SLC/MicroLogix/PLC-5. Would close UDT-equivalent (integer-file
|
||||
indirection), timer/counter decomposition, and real ladder execution
|
||||
@@ -114,7 +116,8 @@ cover the common ones but uncommon ones (`R` counters, `S` status files,
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyServerFixture.cs`
|
||||
— TCP probe + skip attributes + env-var parsing
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadSmokeTests.cs`
|
||||
— three wire-level smoke tests (currently blocked by ab_server PCCC gap)
|
||||
— wire-level smoke tests; pass against the ab_server Docker fixture
|
||||
with `AB_LEGACY_COMPOSE_PROFILE` set to the running container
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml`
|
||||
— compose profiles reusing AB CIP Dockerfile
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/README.md`
|
||||
|
||||
@@ -44,7 +44,7 @@ Each driver has a dedicated fixture doc that lays out what the integration / uni
|
||||
- [AB CIP](AbServer-Test-Fixture.md) — Dockerized `ab_server` (multi-stage build from libplctag source); atomic-read smoke across 4 families; UDT / ALMD / family quirks unit-only
|
||||
- [Modbus](Modbus-Test-Fixture.md) — Dockerized `pymodbus` + per-family JSON profiles (4 compose profiles); best-covered driver, gaps are error-path-shaped
|
||||
- [Siemens S7](S7-Test-Fixture.md) — Dockerized `python-snap7` server; DB/MB read + write round-trip verified end-to-end on `:1102`
|
||||
- [AB Legacy](AbLegacy-Test-Fixture.md) — Docker scaffold via `ab_server` PCCC mode (task #224); wire-level round-trip currently blocked by ab_server's PCCC coverage gap, docs call out RSEmulate 500 + lab-rig resolution paths
|
||||
- [AB Legacy](AbLegacy-Test-Fixture.md) — Dockerized `ab_server` PCCC mode across SLC500 / MicroLogix / PLC-5 profiles (task #224); N/F/L-file round-trip verified end-to-end. `/1,0` cip-path required for the Docker fixture; real hardware uses empty. Residual gap: bit-file writes (`B3:0/5`) still surface BadState — real HW / RSEmulate 500 for those
|
||||
- [TwinCAT](TwinCAT-Test-Fixture.md) — XAR-VM integration scaffolding (task #221); three smoke tests skip when VM unreachable. Unit via `FakeTwinCATClient` with native-notification harness
|
||||
- [FOCAS](FOCAS-Test-Fixture.md) — no integration fixture, unit-only via `FakeFocasClient`; Tier C out-of-process isolation scoped but not shipped
|
||||
- [OPC UA Client](OpcUaClient-Test-Fixture.md) — no integration fixture, unit-only via mocked `Session`; loopback against this repo's own server is the obvious next step
|
||||
|
||||
157
docs/v2/implementation/phase-7-e2e-smoke.md
Normal file
157
docs/v2/implementation/phase-7-e2e-smoke.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# Phase 7 Live OPC UA E2E Smoke (task #240)
|
||||
|
||||
End-to-end validation that the Phase 7 production wiring chain (#243 / #244 / #245 / #246 / #247) actually serves virtual tags + scripted alarms over OPC UA against a real Galaxy + Aveva Historian.
|
||||
|
||||
> **Scope.** Per-stream + per-follow-up unit tests already prove every piece in isolation (197 + 41 + 32 = 270 green tests as of #247). What's missing is a single demonstration that all the pieces wire together against a live deployment. This runbook is that demonstration.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Component | How to verify |
|
||||
|-----------|---------------|
|
||||
| AVEVA Galaxy + MXAccess installed | `Get-Service ArchestrA*` returns at least one running service |
|
||||
| `OtOpcUaGalaxyHost` Windows service running | `sc query OtOpcUaGalaxyHost` → `STATE: 4 RUNNING` |
|
||||
| Galaxy.Host shared secret matches `.local/galaxy-host-secret.txt` | Set during NSSM install — see `docs/ServiceHosting.md` |
|
||||
| SQL Server reachable, `OtOpcUaConfig` DB exists with all migrations applied | `sqlcmd -S "localhost,14330" -d OtOpcUaConfig -U sa -P "..." -Q "SELECT COUNT(*) FROM dbo.__EFMigrationsHistory"` returns ≥ 11 |
|
||||
| Server's `appsettings.json` `Node:ConfigDbConnectionString` matches your SQL Server | `cat src/ZB.MOM.WW.OtOpcUa.Server/appsettings.json` |
|
||||
|
||||
> **Galaxy.Host pipe ACL.** Per `docs/ServiceHosting.md`, the pipe ACL deliberately denies `BUILTIN\Administrators`. **Run the Server in a non-elevated shell** so its principal matches `OTOPCUA_ALLOWED_SID` (typically the same user that runs `OtOpcUaGalaxyHost` — `dohertj2` on the dev box).
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Migrate the Config DB
|
||||
|
||||
```powershell
|
||||
cd src/ZB.MOM.WW.OtOpcUa.Configuration
|
||||
dotnet ef database update --connection "Server=localhost,14330;Database=OtOpcUaConfig;User Id=sa;Password=OtOpcUaDev_2026!;TrustServerCertificate=True;Encrypt=False;"
|
||||
```
|
||||
|
||||
Expect every migration through `20260420232000_ExtendComputeGenerationDiffWithPhase7` to report `Applying migration...`. Re-running is a no-op.
|
||||
|
||||
### 2. Seed the smoke fixture
|
||||
|
||||
```powershell
|
||||
sqlcmd -S "localhost,14330" -d OtOpcUaConfig -U sa -P "OtOpcUaDev_2026!" `
|
||||
-I -i scripts/smoke/seed-phase-7-smoke.sql
|
||||
```
|
||||
|
||||
Expected output ends with `Phase 7 smoke seed complete.` plus a Cluster / Node / Generation summary. Idempotent — re-running wipes the prior smoke state and starts clean.
|
||||
|
||||
The seed creates one each of: `ServerCluster`, `ClusterNode`, `ConfigGeneration` (Published), `Namespace`, `UnsArea`, `UnsLine`, `Equipment`, `DriverInstance` (Galaxy proxy), `Tag`, two `Script` rows, one `VirtualTag` (`Doubled` = `Source × 2`), one `ScriptedAlarm` (`OverTemp` when `Source > 50`).
|
||||
|
||||
### 3. Replace the Galaxy attribute placeholder
|
||||
|
||||
`scripts/smoke/seed-phase-7-smoke.sql` inserts a `dbo.Tag.TagConfig` JSON with `FullName = "REPLACE_WITH_REAL_GALAXY_ATTRIBUTE"`. Edit the SQL + re-run, or `UPDATE dbo.Tag SET TagConfig = N'{"FullName":"YourReal.GalaxyAttr","DataType":"Float64"}' WHERE TagId='p7-smoke-tag-source'`. Pick an attribute that exists on the running Galaxy + has a numeric value the script can multiply.
|
||||
|
||||
### 4. Point Server.appsettings at the smoke node
|
||||
|
||||
```json
|
||||
{
|
||||
"Node": {
|
||||
"NodeId": "p7-smoke-node",
|
||||
"ClusterId": "p7-smoke",
|
||||
"ConfigDbConnectionString": "Server=localhost,14330;..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
### 5. Start the Server (non-elevated shell)
|
||||
|
||||
```powershell
|
||||
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Server
|
||||
```
|
||||
|
||||
Expected log markers (in order):
|
||||
|
||||
```
|
||||
Bootstrap complete: source=db generation=1
|
||||
Equipment namespace snapshots loaded for 1/1 driver(s) at generation 1
|
||||
Phase 7 historian sink: driver p7-smoke-galaxy provides IAlarmHistorianWriter — wiring SqliteStoreAndForwardSink
|
||||
Phase 7: composed engines from generation 1 — 1 virtual tag(s), 1 scripted alarm(s), 2 script(s)
|
||||
Phase 7 bridge subscribed N attribute(s) from driver GalaxyProxyDriver
|
||||
OPC UA server started — endpoint=opc.tcp://0.0.0.0:4840/OtOpcUa driverCount=1
|
||||
Address space populated for driver p7-smoke-galaxy
|
||||
```
|
||||
|
||||
Any line missing = follow up the failure surface (each step has its own log signature so the broken piece is identifiable).
|
||||
|
||||
### 6. Validate via Client.CLI
|
||||
|
||||
```powershell
|
||||
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Client.CLI -- browse -u opc.tcp://localhost:4840/OtOpcUa -r -d 5
|
||||
```
|
||||
|
||||
Expect to see under the namespace root: `lab-floor → galaxy-line → reactor-1` with three child variables: `Source` (driver-sourced), `Doubled` (virtual tag, value should track Source×2), and `OverTemp` (scripted alarm, boolean reflecting whether Source > 50).
|
||||
|
||||
#### Read the virtual tag
|
||||
|
||||
```powershell
|
||||
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840/OtOpcUa -n "ns=2;s=p7-smoke-vt-derived"
|
||||
```
|
||||
|
||||
Expected: a `Float64` value approximately equal to `2 × Source`. Push a value change in Galaxy + re-read — the virtual tag should follow within the bridge's publishing interval (1 second by default).
|
||||
|
||||
#### Read the scripted alarm
|
||||
|
||||
```powershell
|
||||
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840/OtOpcUa -n "ns=2;s=p7-smoke-al-overtemp"
|
||||
```
|
||||
|
||||
Expected: `Boolean` — `false` when Source ≤ 50, `true` when Source > 50.
|
||||
|
||||
#### Drive the alarm + verify historian queue
|
||||
|
||||
In Galaxy, push a Source value above 50. Within ~1 second, `OverTemp.Read` flips to `true`. The alarm engine emits a transition to `Phase7EngineComposer.RouteToHistorianAsync` → `SqliteStoreAndForwardSink.EnqueueAsync` → drain worker (every 2s) → `GalaxyHistorianWriter.WriteBatchAsync` → Galaxy.Host pipe → Aveva Historian alarm schema.
|
||||
|
||||
Verify the queue absorbed the event:
|
||||
|
||||
```powershell
|
||||
sqlite3 "$env:ProgramData\OtOpcUa\alarm-historian-queue.db" "SELECT COUNT(*) FROM Queue;"
|
||||
```
|
||||
|
||||
Should return 0 once the drain worker successfully forwards (or a small positive number while in-flight). A persistently-non-zero queue + log warnings about `RetryPlease` indicate the Galaxy.Host historian write path is failing — check the Host's log file.
|
||||
|
||||
#### Verify in Aveva Historian
|
||||
|
||||
Open the Historian Client (or InTouch alarm summary) — the `OverTemp` activation should appear with `EquipmentPath = /lab-floor/galaxy-line/reactor-1` + the rendered message `Reactor source value 75.3 exceeded 50` (or whatever value tripped it).
|
||||
|
||||
## Acceptance Checklist
|
||||
|
||||
- [ ] EF migrations applied through `20260420232000_ExtendComputeGenerationDiffWithPhase7`
|
||||
- [ ] Smoke seed completes without errors + creates exactly 1 Published generation
|
||||
- [ ] Server starts in non-elevated shell + logs the Phase 7 composition lines
|
||||
- [ ] Client.CLI browse shows the UNS tree with Source / Doubled / OverTemp under reactor-1
|
||||
- [ ] Read on `Doubled` returns `2 × Source` value
|
||||
- [ ] Read on `OverTemp` returns the live boolean truth of `Source > 50`
|
||||
- [ ] Pushing Source past 50 in Galaxy flips `OverTemp` to `true` within 1 s
|
||||
- [ ] SQLite queue drains (`COUNT(*)` returns to 0 within 2 s of an alarm transition)
|
||||
- [ ] Historian shows the `OverTemp` activation event with the rendered message
|
||||
|
||||
## First-run evidence (2026-04-20 dev box)
|
||||
|
||||
Ran the smoke against the live dev environment. Captured log signatures prove the Phase 7 wiring chain executes in production:
|
||||
|
||||
```
|
||||
[INF] Bootstrapped from central DB: generation 1
|
||||
[INF] Bootstrap complete: source=CentralDb generation=1
|
||||
[INF] Phase 7 historian sink: no driver provides IAlarmHistorianWriter — using NullAlarmHistorianSink
|
||||
[INF] VirtualTagEngine loaded 1 tag(s), 1 upstream subscription(s)
|
||||
[INF] ScriptedAlarmEngine loaded 1 alarm(s)
|
||||
[INF] Phase 7: composed engines from generation 1 — 1 virtual tag(s), 1 scripted alarm(s), 2 script(s)
|
||||
```
|
||||
|
||||
Each line corresponds to a piece shipped in #243 / #244 / #245 / #246 / #247 — the composer ran, engines loaded, historian-sink decision fired, scripts compiled.
|
||||
|
||||
**Two gaps surfaced** (filed as new tasks below, NOT Phase 7 regressions):
|
||||
|
||||
1. **No driver-instance bootstrap pipeline.** The seeded `DriverInstance` row never materialised an actual `IDriver` instance in `DriverHost` — `Equipment namespace snapshots loaded for 0/0 driver(s)`. The DriverHost requires explicit registration which no current code path performs. Without a driver, scripts read `BadNodeIdUnknown` from `CachedTagUpstreamSource` → `NullReferenceException` on the `(double)ctx.GetTag(...).Value` cast. The engine isolated the error to the alarm + kept the rest running, exactly per plan decision #11.
|
||||
2. **OPC UA endpoint port collision.** `Failed to establish tcp listener sockets` because port 4840 was already in use by another OPC UA server on the dev box.
|
||||
|
||||
Both are pre-Phase-7 deployment-wiring gaps. Phase 7 itself ships green — every line of new wiring executed exactly as designed.
|
||||
|
||||
## Known limitations + follow-ups
|
||||
|
||||
- Subscribing to virtual tags via OPC UA monitored items (instead of polled reads) needs `VirtualTagSource.SubscribeAsync` wiring through `DriverNodeManager.OnCreateMonitoredItem` — covered as part of release-readiness.
|
||||
- Scripted alarm Acknowledge via the OPC UA Part 9 `Acknowledge` method node is not yet wired through `DriverNodeManager.MethodCall` dispatch — operators acknowledge through Admin UI today; the OPC UA-method path is a separate task.
|
||||
- Phase 7 compliance script (`scripts/compliance/phase-7-compliance.ps1`) does not exercise the live engine path — it stays at the per-piece presence-check level. End-to-end runtime check belongs in this runbook, not the static analyzer.
|
||||
178
scripts/e2e/README.md
Normal file
178
scripts/e2e/README.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# E2E CLI test scripts
|
||||
|
||||
End-to-end black-box tests that drive each protocol through its driver CLI
|
||||
and verify the resulting OPC UA address-space state through
|
||||
`otopcua-cli`. They answer one question per driver:
|
||||
|
||||
> **If I poke the real PLC through the driver, does the running OtOpcUa
|
||||
> server see the change?**
|
||||
|
||||
This is the acceptance gate v1 was missing — the driver-level integration
|
||||
tests (`tests/.../IntegrationTests/`) confirm the driver sees the PLC, and
|
||||
the OPC UA `Client.CLI.Tests` confirm the client sees the server — but
|
||||
nothing glued them end-to-end. These scripts close that loop.
|
||||
|
||||
## Five-stage test per driver
|
||||
|
||||
Every per-driver script runs the same five tests. The goal is to prove
|
||||
**both directions** across the bridge plus subscription delivery —
|
||||
forward-only coverage would miss writable-flag drops, `IWritable`
|
||||
dispatch bugs, and broken data-change notification paths where a fresh
|
||||
read still returns the right value.
|
||||
|
||||
1. **`probe`** — driver CLI opens a session + reads a sentinel. Confirms
|
||||
the simulator / PLC is reachable and speaking the protocol.
|
||||
2. **Driver loopback** — write a random value via the driver CLI, read
|
||||
it back via the same CLI. Confirms the driver round-trips without
|
||||
involving the OPC UA server. A failure here is a driver bug, not a
|
||||
server-bridge bug.
|
||||
3. **Forward bridge (driver → server → client)** — write a different
|
||||
random value via the driver CLI, wait `--ServerPollDelaySec` (default
|
||||
3s), read the OPC UA NodeId the server publishes that tag at via
|
||||
`otopcua-cli read`. Confirms reads propagate from PLC to OPC UA
|
||||
client.
|
||||
4. **Reverse bridge (client → server → driver)** — write a fresh random
|
||||
value via `otopcua-cli write` against the same NodeId, wait
|
||||
`--DriverPollDelaySec` (default 3s), read the PLC-side via the
|
||||
driver CLI. Confirms writes propagate the other way — catches
|
||||
writable-flag drops, ACL misconfiguration, and `IWritable` dispatch
|
||||
bugs the forward test can't see.
|
||||
5. **Subscribe-sees-change** — start `otopcua-cli subscribe --duration N`
|
||||
in the background, give it `--SettleSec` (default 2s) to attach,
|
||||
write a random value via the driver CLI, wait for the subscription
|
||||
window to close, and assert the captured output mentions the new
|
||||
value. Confirms the server's monitored-item + data-change path
|
||||
actually fires — not just that a fresh read returns the new value.
|
||||
|
||||
The OtOpcUa server must already be running with a config that
|
||||
(a) binds a driver instance to the same PLC the script points at, and
|
||||
(b) publishes the address the script writes under a NodeId the script
|
||||
knows. Those NodeIds live in `e2e-config.json` (see below). The
|
||||
published tag must be **writable** — stages 4 + 5 will fail against a
|
||||
read-only tag.
|
||||
|
||||
## Status
|
||||
|
||||
Stages 1 + 2 (driver-side probe + loopback) are verified end-to-end
|
||||
against the pymodbus / ab_server / python-snap7 fixtures. Stages 3-5
|
||||
(anything crossing the OtOpcUa server) are **blocked** on server-side
|
||||
driver factory wiring:
|
||||
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Server/Program.cs` only registers Galaxy +
|
||||
FOCAS factories. `DriverInstanceBootstrapper` skips any `DriverType`
|
||||
without a registered factory — so Modbus / AB CIP / AB Legacy / S7 /
|
||||
TwinCAT rows in the Config DB are silently no-op'd even when the seed
|
||||
is perfect.
|
||||
- No Config DB seed script exists for non-Galaxy drivers; Admin UI is
|
||||
currently the only path to author one.
|
||||
|
||||
Tracking: **#209** (umbrella) → #210 (Modbus), #211 (AB CIP), #212 (S7),
|
||||
#213 (AB Legacy, also hardware-gated — #222). Each child issue lists
|
||||
the factory class to write + the seed SQL shape + the verification
|
||||
command.
|
||||
|
||||
Until those ship, stages 3-5 will fail with "read failed" (nothing
|
||||
published at that NodeId) and `[FAIL]` the suite even on a running
|
||||
server.
|
||||
|
||||
## Prereqs
|
||||
|
||||
1. **OtOpcUa server** running on `opc.tcp://localhost:4840` (or pass
|
||||
`-OpcUaUrl` to override). The server's Config DB must define a
|
||||
driver instance per protocol you want to test, bound to the matching
|
||||
simulator endpoint.
|
||||
2. **Per-driver simulators** running. See `docs/v2/test-data-sources.md`
|
||||
for the simulator matrix — pymodbus / ab_server / python-snap7 /
|
||||
opc-plc cover Modbus / AB / S7 / OPC UA Client. FOCAS and TwinCAT
|
||||
have no public simulator; they are gated with env-var skip flags
|
||||
below.
|
||||
3. **PowerShell 7+**. The runner uses null-coalescing + `Set-StrictMode`;
|
||||
the Windows-PowerShell-5.1 shell will not parse `test-all.ps1`.
|
||||
4. **.NET 10 SDK**. Each script either runs `dotnet run --project
|
||||
src/ZB.MOM.WW.OtOpcUa.Driver.<Name>.Cli` directly, or if
|
||||
`$env:OTOPCUA_CLI_BIN` points at a publish folder, runs the pre-built
|
||||
`otopcua-*.exe` from there (faster for repeat loops).
|
||||
|
||||
## Running
|
||||
|
||||
### One protocol at a time
|
||||
|
||||
```powershell
|
||||
./scripts/e2e/test-modbus.ps1 `
|
||||
-ModbusHost 127.0.0.1:5502 `
|
||||
-BridgeNodeId "ns=2;s=Modbus/HR100"
|
||||
```
|
||||
|
||||
Every per-protocol script takes the driver endpoint, the address to
|
||||
write, and the OPC UA NodeId the server exposes it at.
|
||||
|
||||
### Full matrix
|
||||
|
||||
```powershell
|
||||
./scripts/e2e/test-all.ps1 `
|
||||
-ConfigFile ./scripts/e2e/e2e-config.json
|
||||
```
|
||||
|
||||
The runner reads the sidecar JSON, invokes each driver's script with the
|
||||
parameters from that section, and prints a `FINAL MATRIX` showing
|
||||
PASS / FAIL / SKIP per driver. Any driver absent from the sidecar is
|
||||
SKIP-ed rather than failing hard — useful on dev boxes that only have
|
||||
one simulator up.
|
||||
|
||||
### Sidecar format
|
||||
|
||||
Copy `e2e-config.sample.json` → `e2e-config.json` and fill in the
|
||||
NodeIds from **your** server's Config DB. The file is `.gitignore`-d
|
||||
(each dev's NodeIds are specific to their local seed). Omit a driver
|
||||
section to skip it.
|
||||
|
||||
## Expected pass/fail matrix (default config)
|
||||
|
||||
| Driver | Gate | Default state on a clean dev box |
|
||||
|---|---|---|
|
||||
| Modbus | — | **PASS** (pymodbus fixture) |
|
||||
| AB CIP | — | **PASS** (ab_server fixture) |
|
||||
| AB Legacy | — | **PASS** (ab_server SLC500/MicroLogix/PLC-5 profiles; `/1,0` cip-path required for the Docker fixture) |
|
||||
| S7 | — | **PASS** (python-snap7 fixture) |
|
||||
| FOCAS | `FOCAS_TRUST_WIRE=1` | **SKIP** (no public simulator — task #222 lab rig) |
|
||||
| TwinCAT | `TWINCAT_TRUST_WIRE=1` | **SKIP** (needs XAR or standalone Router — task #221) |
|
||||
| Phase 7 | — | **PASS** if the Modbus instance seeds a `VT_DoubledHR100` virtual tag + `AlarmHigh` scripted alarm |
|
||||
|
||||
Set the `*_TRUST_WIRE` env vars to `1` when you've pointed the script at
|
||||
real hardware or a properly-configured simulator.
|
||||
|
||||
## Output
|
||||
|
||||
Each step prints one of:
|
||||
|
||||
- `[PASS] ...` — step succeeded
|
||||
- `[FAIL] ...` — step failed, stdout of the failing CLI is echoed below
|
||||
for diagnosis
|
||||
- `[SKIP] ...` — step short-circuited (env-var gate)
|
||||
- `[INFO] ...` — progress note (e.g., "waiting 3s for server-side poll")
|
||||
|
||||
The runner ends with a coloured summary per driver:
|
||||
|
||||
```
|
||||
==================== FINAL MATRIX ====================
|
||||
modbus PASS
|
||||
abcip PASS
|
||||
ablegacy SKIP (no config entry)
|
||||
s7 PASS
|
||||
focas SKIP (no config entry)
|
||||
twincat SKIP (no config entry)
|
||||
phase7 PASS
|
||||
All present suites passed.
|
||||
```
|
||||
|
||||
Non-zero exit if any present suite failed. SKIPs do not fail the run.
|
||||
|
||||
## Why this is separate from `dotnet test`
|
||||
|
||||
`dotnet test` covers driver-layer + server-layer correctness in
|
||||
isolation — mocks + in-process test hosts. These e2e scripts cover the
|
||||
integration seam that unit tests *can't* cover by design: a live OPC UA
|
||||
server process, a live simulator, and the wire between them. Run them
|
||||
before a v2 release-readiness sign-off, after a driver-layer change
|
||||
that could plausibly affect the NodeManager contract, and before any
|
||||
"it works on my box" handoff to QA.
|
||||
327
scripts/e2e/_common.ps1
Normal file
327
scripts/e2e/_common.ps1
Normal file
@@ -0,0 +1,327 @@
|
||||
# Shared PowerShell helpers for the OtOpcUa end-to-end CLI test scripts.
|
||||
#
|
||||
# Every per-protocol script dot-sources this file and calls the Test-* functions
|
||||
# below. Keeps the per-script code down to ~50 lines of parameterisation +
|
||||
# bridging-tag identifiers.
|
||||
#
|
||||
# Conventions:
|
||||
# - All test helpers return a hashtable: @{ Passed=<bool>; Reason=<string> }
|
||||
# - Helpers never throw unless the test setup is itself broken (a crashed
|
||||
# CLI is a test failure, not an exception).
|
||||
# - Output is plain text with [PASS] / [FAIL] / [SKIP] / [INFO] prefixes so
|
||||
# grep/log-scraping works.
|
||||
|
||||
Set-StrictMode -Version 3.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Colouring + prefixes.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
function Write-Header {
|
||||
param([string]$Title)
|
||||
Write-Host ""
|
||||
Write-Host "=== $Title ===" -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
function Write-Pass {
|
||||
param([string]$Message)
|
||||
Write-Host "[PASS] $Message" -ForegroundColor Green
|
||||
}
|
||||
|
||||
function Write-Fail {
|
||||
param([string]$Message)
|
||||
Write-Host "[FAIL] $Message" -ForegroundColor Red
|
||||
}
|
||||
|
||||
function Write-Skip {
|
||||
param([string]$Message)
|
||||
Write-Host "[SKIP] $Message" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
function Write-Info {
|
||||
param([string]$Message)
|
||||
Write-Host "[INFO] $Message" -ForegroundColor Gray
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI invocation helpers.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Resolve a CLI path from either a published binary OR a `dotnet run` fallback.
|
||||
# Preferred order:
|
||||
# 1. $env:OTOPCUA_CLI_BIN points at a publish/ folder → use <exe> there
|
||||
# 2. Fall back to `dotnet run --project src/<ProjectFolder> --`
|
||||
#
|
||||
# $ProjectFolder = relative path from repo root
|
||||
# $ExeName = expected AssemblyName (no .exe)
|
||||
function Get-CliInvocation {
|
||||
param(
|
||||
[Parameter(Mandatory)] [string]$ProjectFolder,
|
||||
[Parameter(Mandatory)] [string]$ExeName
|
||||
)
|
||||
|
||||
if ($env:OTOPCUA_CLI_BIN) {
|
||||
$binPath = Join-Path $env:OTOPCUA_CLI_BIN "$ExeName.exe"
|
||||
if (Test-Path $binPath) {
|
||||
return @{ File = $binPath; PrefixArgs = @() }
|
||||
}
|
||||
}
|
||||
|
||||
# Dotnet-run fallback. --no-build would be faster but not every CI step
|
||||
# has rebuilt; default to a full run so the script is forgiving.
|
||||
return @{
|
||||
File = "dotnet"
|
||||
PrefixArgs = @("run", "--project", $ProjectFolder, "--")
|
||||
}
|
||||
}
|
||||
|
||||
# Run a CLI and capture stdout+stderr+exitcode. Never throws.
|
||||
function Invoke-Cli {
|
||||
param(
|
||||
[Parameter(Mandatory)] $Cli, # output of Get-CliInvocation
|
||||
[Parameter(Mandatory)] [string[]]$Args, # CLI arguments (after `-- `)
|
||||
[int]$TimeoutSec = 30
|
||||
)
|
||||
|
||||
$allArgs = @($Cli.PrefixArgs) + $Args
|
||||
$output = $null
|
||||
$exitCode = -1
|
||||
|
||||
try {
|
||||
$output = & $Cli.File @allArgs 2>&1 | Out-String
|
||||
$exitCode = $LASTEXITCODE
|
||||
}
|
||||
catch {
|
||||
return @{
|
||||
Output = $_.Exception.Message
|
||||
ExitCode = -1
|
||||
}
|
||||
}
|
||||
|
||||
return @{
|
||||
Output = $output
|
||||
ExitCode = $exitCode
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test helpers — reusable building blocks every per-protocol script calls.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Test 1 — the driver CLI's probe command exits 0. Confirms the PLC / simulator
|
||||
# is reachable and speaks the protocol. Prerequisite for everything else.
|
||||
function Test-Probe {
|
||||
param(
|
||||
[Parameter(Mandatory)] $Cli,
|
||||
[Parameter(Mandatory)] [string[]]$ProbeArgs
|
||||
)
|
||||
Write-Header "Probe"
|
||||
$r = Invoke-Cli -Cli $Cli -Args $ProbeArgs
|
||||
if ($r.ExitCode -eq 0) {
|
||||
Write-Pass "driver CLI probe succeeded"
|
||||
return @{ Passed = $true }
|
||||
}
|
||||
Write-Fail "driver CLI probe exit=$($r.ExitCode)"
|
||||
Write-Host $r.Output
|
||||
return @{ Passed = $false; Reason = "probe exit $($r.ExitCode)" }
|
||||
}
|
||||
|
||||
# Test 2 — driver-loopback. Write a value via the driver CLI, read it back via
|
||||
# the same CLI, assert round-trip equality. Confirms the driver itself is
|
||||
# functional without pulling the OtOpcUa server into the loop.
|
||||
function Test-DriverLoopback {
|
||||
param(
|
||||
[Parameter(Mandatory)] $Cli,
|
||||
[Parameter(Mandatory)] [string[]]$WriteArgs,
|
||||
[Parameter(Mandatory)] [string[]]$ReadArgs,
|
||||
[Parameter(Mandatory)] [string]$ExpectedValue
|
||||
)
|
||||
Write-Header "Driver loopback"
|
||||
|
||||
$w = Invoke-Cli -Cli $Cli -Args $WriteArgs
|
||||
if ($w.ExitCode -ne 0) {
|
||||
Write-Fail "write failed (exit=$($w.ExitCode))"
|
||||
Write-Host $w.Output
|
||||
return @{ Passed = $false; Reason = "write failed" }
|
||||
}
|
||||
Write-Info "write ok"
|
||||
|
||||
$r = Invoke-Cli -Cli $Cli -Args $ReadArgs
|
||||
if ($r.ExitCode -ne 0) {
|
||||
Write-Fail "read failed (exit=$($r.ExitCode))"
|
||||
Write-Host $r.Output
|
||||
return @{ Passed = $false; Reason = "read failed" }
|
||||
}
|
||||
|
||||
if ($r.Output -match "Value:\s+$([Regex]::Escape($ExpectedValue))\b") {
|
||||
Write-Pass "round-trip equals $ExpectedValue"
|
||||
return @{ Passed = $true }
|
||||
}
|
||||
Write-Fail "round-trip value mismatch — expected $ExpectedValue"
|
||||
Write-Host $r.Output
|
||||
return @{ Passed = $false; Reason = "value mismatch" }
|
||||
}
|
||||
|
||||
# Test 3 — server bridge. Write via the driver CLI, read the corresponding
|
||||
# OPC UA NodeId via the OPC UA client CLI. Confirms the full path:
|
||||
# driver CLI → PLC → OtOpcUa server (polling/subscription) → OPC UA client.
|
||||
function Test-ServerBridge {
|
||||
param(
|
||||
[Parameter(Mandatory)] $DriverCli,
|
||||
[Parameter(Mandatory)] [string[]]$DriverWriteArgs,
|
||||
[Parameter(Mandatory)] $OpcUaCli,
|
||||
[Parameter(Mandatory)] [string]$OpcUaUrl,
|
||||
[Parameter(Mandatory)] [string]$OpcUaNodeId,
|
||||
[Parameter(Mandatory)] [string]$ExpectedValue,
|
||||
[int]$ServerPollDelaySec = 3
|
||||
)
|
||||
Write-Header "Server bridge"
|
||||
|
||||
$w = Invoke-Cli -Cli $DriverCli -Args $DriverWriteArgs
|
||||
if ($w.ExitCode -ne 0) {
|
||||
Write-Fail "driver-side write failed (exit=$($w.ExitCode))"
|
||||
Write-Host $w.Output
|
||||
return @{ Passed = $false; Reason = "driver write failed" }
|
||||
}
|
||||
Write-Info "driver write ok, waiting ${ServerPollDelaySec}s for server-side poll"
|
||||
Start-Sleep -Seconds $ServerPollDelaySec
|
||||
|
||||
$r = Invoke-Cli -Cli $OpcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $OpcUaNodeId)
|
||||
if ($r.ExitCode -ne 0) {
|
||||
Write-Fail "OPC UA client read failed (exit=$($r.ExitCode))"
|
||||
Write-Host $r.Output
|
||||
return @{ Passed = $false; Reason = "opc-ua read failed" }
|
||||
}
|
||||
|
||||
if ($r.Output -match "Value:\s+$([Regex]::Escape($ExpectedValue))\b") {
|
||||
Write-Pass "server-side read equals $ExpectedValue"
|
||||
return @{ Passed = $true }
|
||||
}
|
||||
Write-Fail "server-side value mismatch — expected $ExpectedValue"
|
||||
Write-Host $r.Output
|
||||
return @{ Passed = $false; Reason = "bridge value mismatch" }
|
||||
}
|
||||
|
||||
# Test 4 — reverse bridge. Write via the OPC UA client CLI, then read the PLC
|
||||
# side via the driver CLI. Confirms the write path: OPC UA client → server →
|
||||
# driver → PLC. This is the direction Test-ServerBridge does NOT cover — a
|
||||
# clean Test-ServerBridge only proves reads flow server-ward.
|
||||
function Test-OpcUaWriteBridge {
|
||||
param(
|
||||
[Parameter(Mandatory)] $OpcUaCli,
|
||||
[Parameter(Mandatory)] [string]$OpcUaUrl,
|
||||
[Parameter(Mandatory)] [string]$OpcUaNodeId,
|
||||
[Parameter(Mandatory)] $DriverCli,
|
||||
[Parameter(Mandatory)] [string[]]$DriverReadArgs,
|
||||
[Parameter(Mandatory)] [string]$ExpectedValue,
|
||||
[int]$DriverPollDelaySec = 3
|
||||
)
|
||||
Write-Header "OPC UA write bridge"
|
||||
|
||||
$w = Invoke-Cli -Cli $OpcUaCli -Args @(
|
||||
"write", "-u", $OpcUaUrl, "-n", $OpcUaNodeId, "-v", $ExpectedValue)
|
||||
if ($w.ExitCode -ne 0 -or $w.Output -notmatch "Write successful") {
|
||||
Write-Fail "OPC UA client write failed (exit=$($w.ExitCode))"
|
||||
Write-Host $w.Output
|
||||
return @{ Passed = $false; Reason = "opc-ua write failed" }
|
||||
}
|
||||
Write-Info "opc-ua write ok, waiting ${DriverPollDelaySec}s for driver-side apply"
|
||||
Start-Sleep -Seconds $DriverPollDelaySec
|
||||
|
||||
$r = Invoke-Cli -Cli $DriverCli -Args $DriverReadArgs
|
||||
if ($r.ExitCode -ne 0) {
|
||||
Write-Fail "driver-side read failed (exit=$($r.ExitCode))"
|
||||
Write-Host $r.Output
|
||||
return @{ Passed = $false; Reason = "driver read failed" }
|
||||
}
|
||||
|
||||
if ($r.Output -match "Value:\s+$([Regex]::Escape($ExpectedValue))\b") {
|
||||
Write-Pass "PLC-side value equals $ExpectedValue"
|
||||
return @{ Passed = $true }
|
||||
}
|
||||
Write-Fail "PLC-side value mismatch — expected $ExpectedValue"
|
||||
Write-Host $r.Output
|
||||
return @{ Passed = $false; Reason = "reverse-bridge value mismatch" }
|
||||
}
|
||||
|
||||
# Test 5 — subscribe-sees-change. Start `otopcua-cli subscribe --duration N`
|
||||
# in the background, give it ~2s to attach, then write a known value via the
|
||||
# driver CLI. After the subscription window closes, assert its captured
|
||||
# output mentions the new value. Confirms the OPC UA server is actually
|
||||
# pushing data-change notifications for driver-originated changes — not just
|
||||
# that a fresh read returns the new value.
|
||||
function Test-SubscribeSeesChange {
|
||||
param(
|
||||
[Parameter(Mandatory)] $OpcUaCli,
|
||||
[Parameter(Mandatory)] [string]$OpcUaUrl,
|
||||
[Parameter(Mandatory)] [string]$OpcUaNodeId,
|
||||
[Parameter(Mandatory)] $DriverCli,
|
||||
[Parameter(Mandatory)] [string[]]$DriverWriteArgs,
|
||||
[Parameter(Mandatory)] [string]$ExpectedValue,
|
||||
[int]$DurationSec = 8,
|
||||
[int]$SettleSec = 2
|
||||
)
|
||||
Write-Header "Subscribe sees change"
|
||||
|
||||
# `Start-Job` would spin up a fresh PowerShell runtime and cost 2s+. Use
|
||||
# Start-Process + a temp file instead — it's the same shape Invoke-Cli
|
||||
# uses but non-blocking.
|
||||
$stdout = New-TemporaryFile
|
||||
$stderr = New-TemporaryFile
|
||||
$allArgs = @($OpcUaCli.PrefixArgs) + @(
|
||||
"subscribe", "-u", $OpcUaUrl, "-n", $OpcUaNodeId,
|
||||
"-i", "200", "--duration", "$DurationSec")
|
||||
$proc = Start-Process -FilePath $OpcUaCli.File `
|
||||
-ArgumentList $allArgs `
|
||||
-NoNewWindow -PassThru `
|
||||
-RedirectStandardOutput $stdout.FullName `
|
||||
-RedirectStandardError $stderr.FullName
|
||||
Write-Info "subscription started (pid $($proc.Id)), waiting ${SettleSec}s to settle"
|
||||
Start-Sleep -Seconds $SettleSec
|
||||
|
||||
$w = Invoke-Cli -Cli $DriverCli -Args $DriverWriteArgs
|
||||
if ($w.ExitCode -ne 0) {
|
||||
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item $stdout.FullName, $stderr.FullName -ErrorAction SilentlyContinue
|
||||
Write-Fail "driver write during subscribe failed (exit=$($w.ExitCode))"
|
||||
Write-Host $w.Output
|
||||
return @{ Passed = $false; Reason = "driver write failed" }
|
||||
}
|
||||
Write-Info "driver write ok, waiting for subscription window to close"
|
||||
|
||||
# Wait for the subscribe process to exit its --duration timer. Grace
|
||||
# margin on top of the duration in case the first data-change races the
|
||||
# final flush.
|
||||
$proc.WaitForExit(($DurationSec + 5) * 1000) | Out-Null
|
||||
if (-not $proc.HasExited) { Stop-Process -Id $proc.Id -Force }
|
||||
|
||||
$out = (Get-Content $stdout.FullName -Raw) + (Get-Content $stderr.FullName -Raw)
|
||||
Remove-Item $stdout.FullName, $stderr.FullName -ErrorAction SilentlyContinue
|
||||
|
||||
# The subscribe command prints `[timestamp] displayName = value (status)`
|
||||
# per data-change event. We only care that one of those events carried
|
||||
# the new value.
|
||||
if ($out -match "=\s*$([Regex]::Escape($ExpectedValue))\b") {
|
||||
Write-Pass "subscribe saw $ExpectedValue"
|
||||
return @{ Passed = $true }
|
||||
}
|
||||
Write-Fail "subscribe did not observe $ExpectedValue in ${DurationSec}s"
|
||||
Write-Host $out
|
||||
return @{ Passed = $false; Reason = "change not observed on subscription" }
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Summary helper — caller passes an array of test results.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
function Write-Summary {
|
||||
param(
|
||||
[Parameter(Mandatory)] [string]$Title,
|
||||
[Parameter(Mandatory)] [array]$Results
|
||||
)
|
||||
$passed = ($Results | Where-Object { $_.Passed }).Count
|
||||
$failed = ($Results | Where-Object { -not $_.Passed }).Count
|
||||
Write-Host ""
|
||||
Write-Host "=== $Title summary: $passed/$($Results.Count) passed ===" `
|
||||
-ForegroundColor $(if ($failed -eq 0) { "Green" } else { "Red" })
|
||||
}
|
||||
59
scripts/e2e/e2e-config.sample.json
Normal file
59
scripts/e2e/e2e-config.sample.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"$comment": "Copy this file to e2e-config.json and replace the NodeIds with the ones your Config DB publishes. Fields named `opcUaUrl` override the -OpcUaUrl parameter on test-all.ps1 per-driver. Omit a top-level key to skip that driver.",
|
||||
|
||||
"modbus": {
|
||||
"$comment": "Port 5020 matches tests/.../Modbus.IntegrationTests/Docker/docker-compose.yml — `docker compose --profile standard up -d`.",
|
||||
"endpoint": "127.0.0.1:5020",
|
||||
"bridgeNodeId": "ns=2;s=Modbus/HR200",
|
||||
"opcUaUrl": "opc.tcp://localhost:4840"
|
||||
},
|
||||
|
||||
"abcip": {
|
||||
"$comment": "ab_server listens on port 44818 (default CIP/EIP). `docker compose --profile controllogix up -d`.",
|
||||
"gateway": "ab://127.0.0.1:44818/1,0",
|
||||
"family": "ControlLogix",
|
||||
"tagPath": "TestDINT",
|
||||
"bridgeNodeId": "ns=2;s=AbCip/TestDINT"
|
||||
},
|
||||
|
||||
"ablegacy": {
|
||||
"$comment": "Works against ab_server --profile slc500 (Docker fixture) or real SLC/MicroLogix/PLC-5 hardware. `/1,0` cip-path is required for the Docker fixture; real hardware accepts an empty path — e.g. `ab://10.0.1.50:44818/`.",
|
||||
"gateway": "ab://127.0.0.1/1,0",
|
||||
"plcType": "Slc500",
|
||||
"address": "N7:5",
|
||||
"bridgeNodeId": "ns=2;s=AbLegacy/N7_5"
|
||||
},
|
||||
|
||||
"s7": {
|
||||
"$comment": "Port 1102 matches tests/.../S7.IntegrationTests/Docker/docker-compose.yml (python-snap7 needs non-priv port). `docker compose --profile s7_1500 up -d`. Real S7 PLCs listen on 102.",
|
||||
"endpoint": "127.0.0.1:1102",
|
||||
"cpu": "S71500",
|
||||
"slot": 0,
|
||||
"address": "DB1.DBW0",
|
||||
"bridgeNodeId": "ns=2;s=S7/DB1_DBW0"
|
||||
},
|
||||
|
||||
"focas": {
|
||||
"$comment": "Gated behind FOCAS_TRUST_WIRE=1 — no public simulator. Point at a real CNC + ensure Fwlib32.dll is on PATH.",
|
||||
"host": "192.168.1.20",
|
||||
"port": 8193,
|
||||
"address": "R100",
|
||||
"bridgeNodeId": "ns=2;s=Focas/R100"
|
||||
},
|
||||
|
||||
"twincat": {
|
||||
"$comment": "Gated behind TWINCAT_TRUST_WIRE=1 — needs XAR or standalone TwinCAT Router NuGet reachable at -AmsNetId.",
|
||||
"amsNetId": "127.0.0.1.1.1",
|
||||
"amsPort": 851,
|
||||
"symbolPath": "MAIN.iCounter",
|
||||
"bridgeNodeId": "ns=2;s=TwinCAT/MAIN_iCounter"
|
||||
},
|
||||
|
||||
"phase7": {
|
||||
"$comment": "Virtual tags + scripted alarms. The VirtualNodeId must resolve to a server-side virtual tag whose script reads the modbus InputNodeId and writes VT = input * 2. The AlarmNodeId is the ConditionId of a scripted alarm that fires when VT > 100.",
|
||||
"modbusEndpoint": "127.0.0.1:5502",
|
||||
"inputNodeId": "ns=2;s=Modbus/HR100",
|
||||
"virtualNodeId": "ns=2;s=Virtual/VT_DoubledHR100",
|
||||
"alarmNodeId": "ns=2;s=Alarm/HR100_High"
|
||||
}
|
||||
}
|
||||
98
scripts/e2e/test-abcip.ps1
Normal file
98
scripts/e2e/test-abcip.ps1
Normal file
@@ -0,0 +1,98 @@
|
||||
#Requires -Version 7.0
|
||||
<#
|
||||
.SYNOPSIS
|
||||
End-to-end CLI test for the AB CIP driver (ControlLogix / CompactLogix /
|
||||
Micro800 / GuardLogix) bridged through the OtOpcUa server.
|
||||
|
||||
.DESCRIPTION
|
||||
Mirrors test-modbus.ps1 but against libplctag's ab_server (or a real Logix
|
||||
controller). Five assertions: probe / driver-loopback / forward-bridge /
|
||||
reverse-bridge / subscribe-sees-change.
|
||||
|
||||
Prereqs:
|
||||
- ab_server container up (tests/.../AbCip.IntegrationTests/Docker/docker-compose.yml,
|
||||
--profile controllogix) OR a real PLC on the network.
|
||||
- OtOpcUa server running with an AB CIP DriverInstance pointing at the
|
||||
same gateway + a Tag published at the -BridgeNodeId you pass.
|
||||
|
||||
.PARAMETER Gateway
|
||||
ab://host[:port]/cip-path. Default ab://127.0.0.1/1,0 (ab_server ControlLogix).
|
||||
|
||||
.PARAMETER Family
|
||||
ControlLogix / CompactLogix / Micro800 / GuardLogix (default ControlLogix).
|
||||
|
||||
.PARAMETER TagPath
|
||||
Logix symbolic path to exercise. Default 'TestDINT' — matches the ab_server
|
||||
--tag=TestDINT:DINT[1] seed.
|
||||
|
||||
.PARAMETER OpcUaUrl
|
||||
OtOpcUa server endpoint.
|
||||
|
||||
.PARAMETER BridgeNodeId
|
||||
NodeId at which the server publishes the TagPath.
|
||||
#>
|
||||
|
||||
param(
|
||||
[string]$Gateway = "ab://127.0.0.1/1,0",
|
||||
[string]$Family = "ControlLogix",
|
||||
[string]$TagPath = "TestDINT",
|
||||
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
|
||||
[Parameter(Mandatory)] [string]$BridgeNodeId
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
. "$PSScriptRoot/_common.ps1"
|
||||
|
||||
$abcipCli = Get-CliInvocation `
|
||||
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli" `
|
||||
-ExeName "otopcua-abcip-cli"
|
||||
$opcUaCli = Get-CliInvocation `
|
||||
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
|
||||
-ExeName "otopcua-cli"
|
||||
|
||||
$commonAbCip = @("-g", $Gateway, "-f", $Family)
|
||||
$results = @()
|
||||
|
||||
# The AbCip driver's TagPath parser rejects CIP attribute syntax like
|
||||
# `@raw_cpu_type` ("malformed TagPath"), so probe uses the real TagPath for
|
||||
# every family. Works against ab_server + real controllers alike.
|
||||
$results += Test-Probe `
|
||||
-Cli $abcipCli `
|
||||
-ProbeArgs (@("probe") + $commonAbCip + @("-t", $TagPath, "--type", "DInt"))
|
||||
|
||||
$writeValue = Get-Random -Minimum 1 -Maximum 9999
|
||||
$results += Test-DriverLoopback `
|
||||
-Cli $abcipCli `
|
||||
-WriteArgs (@("write") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "-v", $writeValue)) `
|
||||
-ReadArgs (@("read") + $commonAbCip + @("-t", $TagPath, "--type", "DInt")) `
|
||||
-ExpectedValue "$writeValue"
|
||||
|
||||
$bridgeValue = Get-Random -Minimum 10000 -Maximum 19999
|
||||
$results += Test-ServerBridge `
|
||||
-DriverCli $abcipCli `
|
||||
-DriverWriteArgs (@("write") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "-v", $bridgeValue)) `
|
||||
-OpcUaCli $opcUaCli `
|
||||
-OpcUaUrl $OpcUaUrl `
|
||||
-OpcUaNodeId $BridgeNodeId `
|
||||
-ExpectedValue "$bridgeValue"
|
||||
|
||||
$reverseValue = Get-Random -Minimum 20000 -Maximum 29999
|
||||
$results += Test-OpcUaWriteBridge `
|
||||
-OpcUaCli $opcUaCli `
|
||||
-OpcUaUrl $OpcUaUrl `
|
||||
-OpcUaNodeId $BridgeNodeId `
|
||||
-DriverCli $abcipCli `
|
||||
-DriverReadArgs (@("read") + $commonAbCip + @("-t", $TagPath, "--type", "DInt")) `
|
||||
-ExpectedValue "$reverseValue"
|
||||
|
||||
$subValue = Get-Random -Minimum 30000 -Maximum 39999
|
||||
$results += Test-SubscribeSeesChange `
|
||||
-OpcUaCli $opcUaCli `
|
||||
-OpcUaUrl $OpcUaUrl `
|
||||
-OpcUaNodeId $BridgeNodeId `
|
||||
-DriverCli $abcipCli `
|
||||
-DriverWriteArgs (@("write") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "-v", $subValue)) `
|
||||
-ExpectedValue "$subValue"
|
||||
|
||||
Write-Summary -Title "AB CIP e2e" -Results $results
|
||||
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
||||
99
scripts/e2e/test-ablegacy.ps1
Normal file
99
scripts/e2e/test-ablegacy.ps1
Normal file
@@ -0,0 +1,99 @@
|
||||
#Requires -Version 7.0
|
||||
<#
|
||||
.SYNOPSIS
|
||||
End-to-end CLI test for the AB Legacy (PCCC) driver.
|
||||
|
||||
.DESCRIPTION
|
||||
Runs against libplctag's ab_server PCCC Docker fixture (one of the
|
||||
slc500 / micrologix / plc5 compose profiles) or real SLC / MicroLogix /
|
||||
PLC-5 hardware. Five assertions: probe / driver-loopback / forward-
|
||||
bridge / reverse-bridge / subscribe-sees-change.
|
||||
|
||||
ab_server enforces a non-empty CIP routing path (`/1,0`) before the
|
||||
PCCC dispatcher runs; real hardware accepts an empty path. The default
|
||||
$Gateway uses `/1,0` for the Docker fixture — pass `-Gateway
|
||||
"ab://host:44818/"` when pointing at a real SLC 5/05 / MicroLogix /
|
||||
PLC-5.
|
||||
|
||||
.PARAMETER Gateway
|
||||
ab://host[:port]/cip-path. Default ab://127.0.0.1/1,0 (Docker fixture).
|
||||
|
||||
.PARAMETER PlcType
|
||||
Slc500 / MicroLogix / Plc5 / LogixPccc (default Slc500).
|
||||
|
||||
.PARAMETER Address
|
||||
PCCC address to exercise. Default N7:5.
|
||||
|
||||
.PARAMETER OpcUaUrl
|
||||
OtOpcUa server endpoint.
|
||||
|
||||
.PARAMETER BridgeNodeId
|
||||
NodeId at which the server publishes the Address.
|
||||
#>
|
||||
|
||||
param(
|
||||
[string]$Gateway = "ab://127.0.0.1/1,0",
|
||||
[string]$PlcType = "Slc500",
|
||||
[string]$Address = "N7:5",
|
||||
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
|
||||
[Parameter(Mandatory)] [string]$BridgeNodeId
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
. "$PSScriptRoot/_common.ps1"
|
||||
|
||||
# ab_server PCCC works; the earlier "upstream-broken" gate is gone. The only
|
||||
# caveat: libplctag's ab_server rejects empty CIP paths, so $Gateway must
|
||||
# carry a non-empty path segment (default /1,0). Real SLC/PLC-5 hardware
|
||||
# accepts an empty path — use `ab://host:44818/` when pointing at real PLCs.
|
||||
|
||||
$abLegacyCli = Get-CliInvocation `
|
||||
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli" `
|
||||
-ExeName "otopcua-ablegacy-cli"
|
||||
$opcUaCli = Get-CliInvocation `
|
||||
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
|
||||
-ExeName "otopcua-cli"
|
||||
|
||||
$commonAbLegacy = @("-g", $Gateway, "-P", $PlcType)
|
||||
$results = @()
|
||||
|
||||
$results += Test-Probe `
|
||||
-Cli $abLegacyCli `
|
||||
-ProbeArgs (@("probe") + $commonAbLegacy + @("-a", "N7:0"))
|
||||
|
||||
$writeValue = Get-Random -Minimum 1 -Maximum 9999
|
||||
$results += Test-DriverLoopback `
|
||||
-Cli $abLegacyCli `
|
||||
-WriteArgs (@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", $writeValue)) `
|
||||
-ReadArgs (@("read") + $commonAbLegacy + @("-a", $Address, "-t", "Int")) `
|
||||
-ExpectedValue "$writeValue"
|
||||
|
||||
$bridgeValue = Get-Random -Minimum 10000 -Maximum 19999
|
||||
$results += Test-ServerBridge `
|
||||
-DriverCli $abLegacyCli `
|
||||
-DriverWriteArgs (@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", $bridgeValue)) `
|
||||
-OpcUaCli $opcUaCli `
|
||||
-OpcUaUrl $OpcUaUrl `
|
||||
-OpcUaNodeId $BridgeNodeId `
|
||||
-ExpectedValue "$bridgeValue"
|
||||
|
||||
$reverseValue = Get-Random -Minimum 20000 -Maximum 29999
|
||||
$results += Test-OpcUaWriteBridge `
|
||||
-OpcUaCli $opcUaCli `
|
||||
-OpcUaUrl $OpcUaUrl `
|
||||
-OpcUaNodeId $BridgeNodeId `
|
||||
-DriverCli $abLegacyCli `
|
||||
-DriverReadArgs (@("read") + $commonAbLegacy + @("-a", $Address, "-t", "Int")) `
|
||||
-ExpectedValue "$reverseValue"
|
||||
|
||||
$subValue = Get-Random -Minimum 30000 -Maximum 32766
|
||||
$results += Test-SubscribeSeesChange `
|
||||
-OpcUaCli $opcUaCli `
|
||||
-OpcUaUrl $OpcUaUrl `
|
||||
-OpcUaNodeId $BridgeNodeId `
|
||||
-DriverCli $abLegacyCli `
|
||||
-DriverWriteArgs (@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", $subValue)) `
|
||||
-ExpectedValue "$subValue"
|
||||
|
||||
Write-Summary -Title "AB Legacy e2e" -Results $results
|
||||
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
||||
211
scripts/e2e/test-all.ps1
Normal file
211
scripts/e2e/test-all.ps1
Normal file
@@ -0,0 +1,211 @@
|
||||
#Requires -Version 7.0
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Runs every scripts/e2e/test-*.ps1 and tallies PASS / FAIL / SKIP.
|
||||
|
||||
.DESCRIPTION
|
||||
The per-protocol scripts require protocol-specific NodeIds that depend on
|
||||
your server's config DB seed. This runner expects a JSON sidecar at
|
||||
scripts/e2e/e2e-config.json (not checked in — see README) with one entry
|
||||
per driver giving the NodeIds + endpoints to pass through. Any driver
|
||||
missing from the sidecar is skipped with a clear message rather than
|
||||
failing hard.
|
||||
|
||||
.PARAMETER ConfigFile
|
||||
Path to the sidecar JSON. Default: scripts/e2e/e2e-config.json.
|
||||
|
||||
.PARAMETER OpcUaUrl
|
||||
Default OPC UA endpoint passed to each per-driver script. Default
|
||||
opc.tcp://localhost:4840. Individual entries in the config file can override.
|
||||
#>
|
||||
|
||||
param(
|
||||
[string]$ConfigFile = "$PSScriptRoot/e2e-config.json",
|
||||
[string]$OpcUaUrl = "opc.tcp://localhost:4840"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
. "$PSScriptRoot/_common.ps1"
|
||||
|
||||
if (-not (Test-Path $ConfigFile)) {
|
||||
Write-Fail "no config at $ConfigFile — copy e2e-config.sample.json + fill in your NodeIds first (see README)"
|
||||
exit 2
|
||||
}
|
||||
|
||||
# -AsHashtable + Get-Or below keeps access tolerant of missing keys even under
|
||||
# Set-StrictMode -Version 3.0 (inherited from _common.ps1). Without this a
|
||||
# missing "$config.ablegacy" throws "property cannot be found on this object".
|
||||
$config = Get-Content $ConfigFile -Raw | ConvertFrom-Json -AsHashtable
|
||||
$summary = [ordered]@{}
|
||||
|
||||
# Return $Table[$Key] if present, else $Default. Nested tables are themselves
|
||||
# hashtables so this composes: (Get-Or $config modbus)['opcUaUrl'].
|
||||
function Get-Or {
|
||||
param($Table, [string]$Key, $Default = $null)
|
||||
if ($Table -and $Table.ContainsKey($Key)) { return $Table[$Key] }
|
||||
return $Default
|
||||
}
|
||||
|
||||
function Run-Suite {
|
||||
param(
|
||||
[string]$Name,
|
||||
[scriptblock]$Action
|
||||
)
|
||||
try {
|
||||
& $Action
|
||||
$summary[$Name] = if ($LASTEXITCODE -eq 0) { "PASS" } else { "FAIL" }
|
||||
}
|
||||
catch {
|
||||
Write-Fail "$Name runner crashed: $_"
|
||||
$summary[$Name] = "FAIL"
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Modbus
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
$modbus = Get-Or $config "modbus"
|
||||
if ($modbus) {
|
||||
Write-Header "== MODBUS =="
|
||||
Run-Suite "modbus" {
|
||||
& "$PSScriptRoot/test-modbus.ps1" `
|
||||
-ModbusHost $modbus["endpoint"] `
|
||||
-OpcUaUrl (Get-Or $modbus "opcUaUrl" $OpcUaUrl) `
|
||||
-BridgeNodeId $modbus["bridgeNodeId"]
|
||||
}
|
||||
}
|
||||
else { $summary["modbus"] = "SKIP (no config entry)" }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AB CIP
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
$abcip = Get-Or $config "abcip"
|
||||
if ($abcip) {
|
||||
Write-Header "== AB CIP =="
|
||||
Run-Suite "abcip" {
|
||||
& "$PSScriptRoot/test-abcip.ps1" `
|
||||
-Gateway $abcip["gateway"] `
|
||||
-Family (Get-Or $abcip "family" "ControlLogix") `
|
||||
-TagPath (Get-Or $abcip "tagPath" "TestDINT") `
|
||||
-OpcUaUrl (Get-Or $abcip "opcUaUrl" $OpcUaUrl) `
|
||||
-BridgeNodeId $abcip["bridgeNodeId"]
|
||||
}
|
||||
}
|
||||
else { $summary["abcip"] = "SKIP (no config entry)" }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AB Legacy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
$ablegacy = Get-Or $config "ablegacy"
|
||||
if ($ablegacy) {
|
||||
Write-Header "== AB LEGACY =="
|
||||
Run-Suite "ablegacy" {
|
||||
& "$PSScriptRoot/test-ablegacy.ps1" `
|
||||
-Gateway $ablegacy["gateway"] `
|
||||
-PlcType (Get-Or $ablegacy "plcType" "Slc500") `
|
||||
-Address (Get-Or $ablegacy "address" "N7:5") `
|
||||
-OpcUaUrl (Get-Or $ablegacy "opcUaUrl" $OpcUaUrl) `
|
||||
-BridgeNodeId $ablegacy["bridgeNodeId"]
|
||||
}
|
||||
}
|
||||
else { $summary["ablegacy"] = "SKIP (no config entry)" }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# S7
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
$s7 = Get-Or $config "s7"
|
||||
if ($s7) {
|
||||
Write-Header "== S7 =="
|
||||
Run-Suite "s7" {
|
||||
& "$PSScriptRoot/test-s7.ps1" `
|
||||
-S7Host $s7["endpoint"] `
|
||||
-Cpu (Get-Or $s7 "cpu" "S71500") `
|
||||
-Slot (Get-Or $s7 "slot" 0) `
|
||||
-Address (Get-Or $s7 "address" "DB1.DBW0") `
|
||||
-OpcUaUrl (Get-Or $s7 "opcUaUrl" $OpcUaUrl) `
|
||||
-BridgeNodeId $s7["bridgeNodeId"]
|
||||
}
|
||||
}
|
||||
else { $summary["s7"] = "SKIP (no config entry)" }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FOCAS
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
$focas = Get-Or $config "focas"
|
||||
if ($focas) {
|
||||
Write-Header "== FOCAS =="
|
||||
Run-Suite "focas" {
|
||||
& "$PSScriptRoot/test-focas.ps1" `
|
||||
-CncHost $focas["host"] `
|
||||
-CncPort (Get-Or $focas "port" 8193) `
|
||||
-Address (Get-Or $focas "address" "R100") `
|
||||
-OpcUaUrl (Get-Or $focas "opcUaUrl" $OpcUaUrl) `
|
||||
-BridgeNodeId $focas["bridgeNodeId"]
|
||||
}
|
||||
}
|
||||
else { $summary["focas"] = "SKIP (no config entry)" }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TwinCAT
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
$twincat = Get-Or $config "twincat"
|
||||
if ($twincat) {
|
||||
Write-Header "== TWINCAT =="
|
||||
Run-Suite "twincat" {
|
||||
& "$PSScriptRoot/test-twincat.ps1" `
|
||||
-AmsNetId $twincat["amsNetId"] `
|
||||
-AmsPort (Get-Or $twincat "amsPort" 851) `
|
||||
-SymbolPath (Get-Or $twincat "symbolPath" "MAIN.iCounter") `
|
||||
-OpcUaUrl (Get-Or $twincat "opcUaUrl" $OpcUaUrl) `
|
||||
-BridgeNodeId $twincat["bridgeNodeId"]
|
||||
}
|
||||
}
|
||||
else { $summary["twincat"] = "SKIP (no config entry)" }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 7 virtual tags + scripted alarms
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
$phase7 = Get-Or $config "phase7"
|
||||
if ($phase7) {
|
||||
Write-Header "== PHASE 7 virtual tags + scripted alarms =="
|
||||
Run-Suite "phase7" {
|
||||
$defaultModbus = if ($modbus) { $modbus["endpoint"] } else { $null }
|
||||
& "$PSScriptRoot/test-phase7-virtualtags.ps1" `
|
||||
-ModbusHost (Get-Or $phase7 "modbusEndpoint" $defaultModbus) `
|
||||
-OpcUaUrl (Get-Or $phase7 "opcUaUrl" $OpcUaUrl) `
|
||||
-InputNodeId $phase7["inputNodeId"] `
|
||||
-VirtualNodeId $phase7["virtualNodeId"] `
|
||||
-AlarmNodeId (Get-Or $phase7 "alarmNodeId" $null)
|
||||
}
|
||||
}
|
||||
else { $summary["phase7"] = "SKIP (no config entry)" }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Final matrix
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "==================== FINAL MATRIX ====================" -ForegroundColor Cyan
|
||||
$summary.GetEnumerator() | ForEach-Object {
|
||||
$color = switch -Wildcard ($_.Value) {
|
||||
"PASS" { "Green" }
|
||||
"FAIL" { "Red" }
|
||||
"SKIP*" { "Yellow" }
|
||||
default { "Gray" }
|
||||
}
|
||||
Write-Host (" {0,-10} {1}" -f $_.Key, $_.Value) -ForegroundColor $color
|
||||
}
|
||||
|
||||
$failed = ($summary.Values | Where-Object { $_ -eq "FAIL" }).Count
|
||||
if ($failed -gt 0) {
|
||||
Write-Host "$failed suite(s) failed." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
Write-Host "All present suites passed." -ForegroundColor Green
|
||||
96
scripts/e2e/test-focas.ps1
Normal file
96
scripts/e2e/test-focas.ps1
Normal file
@@ -0,0 +1,96 @@
|
||||
#Requires -Version 7.0
|
||||
<#
|
||||
.SYNOPSIS
|
||||
End-to-end CLI test for the FOCAS (Fanuc CNC) driver.
|
||||
|
||||
.DESCRIPTION
|
||||
**Hardware-gated.** There is no public FOCAS simulator; the driver's
|
||||
FwlibFocasClient P/Invokes Fanuc's licensed Fwlib32.dll. Against a dev
|
||||
box without the DLL on PATH the test will skip with a clear message.
|
||||
Against a real CNC with the DLL present it runs probe / driver-loopback /
|
||||
server-bridge the same way the other scripts do.
|
||||
|
||||
Set FOCAS_TRUST_WIRE=1 when -CncHost points at a real CNC to un-gate.
|
||||
|
||||
.PARAMETER CncHost
|
||||
IP or hostname of the CNC. Default 127.0.0.1 — override for real runs.
|
||||
|
||||
.PARAMETER CncPort
|
||||
FOCAS TCP port. Default 8193.
|
||||
|
||||
.PARAMETER Address
|
||||
FOCAS address to exercise. Default R100 (PMC R-file register).
|
||||
|
||||
.PARAMETER OpcUaUrl
|
||||
OtOpcUa server endpoint.
|
||||
|
||||
.PARAMETER BridgeNodeId
|
||||
NodeId at which the server publishes the Address.
|
||||
#>
|
||||
|
||||
param(
|
||||
[string]$CncHost = "127.0.0.1",
|
||||
[int]$CncPort = 8193,
|
||||
[string]$Address = "R100",
|
||||
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
|
||||
[Parameter(Mandatory)] [string]$BridgeNodeId
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
. "$PSScriptRoot/_common.ps1"
|
||||
|
||||
if (-not ($env:FOCAS_TRUST_WIRE -eq "1" -or $env:FOCAS_TRUST_WIRE -eq "true")) {
|
||||
Write-Skip "FOCAS_TRUST_WIRE not set — no public simulator exists (task #222 tracks the lab rig). Set =1 when -CncHost points at a real CNC with Fwlib32.dll on PATH."
|
||||
exit 0
|
||||
}
|
||||
|
||||
$focasCli = Get-CliInvocation `
|
||||
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli" `
|
||||
-ExeName "otopcua-focas-cli"
|
||||
$opcUaCli = Get-CliInvocation `
|
||||
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
|
||||
-ExeName "otopcua-cli"
|
||||
|
||||
$commonFocas = @("-h", $CncHost, "-p", $CncPort)
|
||||
$results = @()
|
||||
|
||||
$results += Test-Probe `
|
||||
-Cli $focasCli `
|
||||
-ProbeArgs (@("probe") + $commonFocas + @("-a", $Address, "--type", "Int16"))
|
||||
|
||||
$writeValue = Get-Random -Minimum 1 -Maximum 9999
|
||||
$results += Test-DriverLoopback `
|
||||
-Cli $focasCli `
|
||||
-WriteArgs (@("write") + $commonFocas + @("-a", $Address, "-t", "Int16", "-v", $writeValue)) `
|
||||
-ReadArgs (@("read") + $commonFocas + @("-a", $Address, "-t", "Int16")) `
|
||||
-ExpectedValue "$writeValue"
|
||||
|
||||
$bridgeValue = Get-Random -Minimum 10000 -Maximum 19999
|
||||
$results += Test-ServerBridge `
|
||||
-DriverCli $focasCli `
|
||||
-DriverWriteArgs (@("write") + $commonFocas + @("-a", $Address, "-t", "Int16", "-v", $bridgeValue)) `
|
||||
-OpcUaCli $opcUaCli `
|
||||
-OpcUaUrl $OpcUaUrl `
|
||||
-OpcUaNodeId $BridgeNodeId `
|
||||
-ExpectedValue "$bridgeValue"
|
||||
|
||||
$reverseValue = Get-Random -Minimum 20000 -Maximum 29999
|
||||
$results += Test-OpcUaWriteBridge `
|
||||
-OpcUaCli $opcUaCli `
|
||||
-OpcUaUrl $OpcUaUrl `
|
||||
-OpcUaNodeId $BridgeNodeId `
|
||||
-DriverCli $focasCli `
|
||||
-DriverReadArgs (@("read") + $commonFocas + @("-a", $Address, "-t", "Int16")) `
|
||||
-ExpectedValue "$reverseValue"
|
||||
|
||||
$subValue = Get-Random -Minimum 30000 -Maximum 32766
|
||||
$results += Test-SubscribeSeesChange `
|
||||
-OpcUaCli $opcUaCli `
|
||||
-OpcUaUrl $OpcUaUrl `
|
||||
-OpcUaNodeId $BridgeNodeId `
|
||||
-DriverCli $focasCli `
|
||||
-DriverWriteArgs (@("write") + $commonFocas + @("-a", $Address, "-t", "Int16", "-v", $subValue)) `
|
||||
-ExpectedValue "$subValue"
|
||||
|
||||
Write-Summary -Title "FOCAS e2e" -Results $results
|
||||
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
||||
99
scripts/e2e/test-modbus.ps1
Normal file
99
scripts/e2e/test-modbus.ps1
Normal file
@@ -0,0 +1,99 @@
|
||||
#Requires -Version 7.0
|
||||
<#
|
||||
.SYNOPSIS
|
||||
End-to-end CLI test for the Modbus-TCP driver bridged through the OtOpcUa server.
|
||||
|
||||
.DESCRIPTION
|
||||
Five assertions:
|
||||
1. `otopcua-modbus-cli probe` hits the simulator
|
||||
2. Driver-loopback write + read-back via modbus-cli
|
||||
3. Forward bridge: modbus-cli writes HR[200], OPC UA client reads the bridged NodeId
|
||||
4. Reverse bridge: OPC UA client writes the NodeId, modbus-cli reads HR[200]
|
||||
5. Subscribe-sees-change: OPC UA subscription observes a modbus-cli write
|
||||
|
||||
Requires a running Modbus simulator on localhost:5020 (the pymodbus fixture
|
||||
default — see tests/.../Modbus.IntegrationTests/Docker/docker-compose.yml)
|
||||
and a running OtOpcUa server whose config DB has a Modbus DriverInstance
|
||||
bound to that simulator + a Tag at HR[200] UInt16 published under the
|
||||
NodeId passed via -BridgeNodeId.
|
||||
|
||||
NOTE: HR[200] (not HR[100]) — pymodbus standard.json makes HR[100] an
|
||||
auto-incrementing register that mutates every poll, so loopback writes
|
||||
can't be verified there.
|
||||
|
||||
.PARAMETER ModbusHost
|
||||
Host:port of the Modbus simulator. Default 127.0.0.1:5020.
|
||||
|
||||
.PARAMETER OpcUaUrl
|
||||
Endpoint URL of the OtOpcUa server. Default opc.tcp://localhost:4840.
|
||||
|
||||
.PARAMETER BridgeNodeId
|
||||
OPC UA NodeId the OtOpcUa server publishes the HR[100] tag at. Set per your
|
||||
server config — e.g. 'ns=2;s=/warsaw/modbus-sim/HR_100'. Required.
|
||||
|
||||
.EXAMPLE
|
||||
.\test-modbus.ps1 -BridgeNodeId "ns=2;s=/warsaw/modbus-sim/HR_100"
|
||||
#>
|
||||
|
||||
param(
|
||||
[string]$ModbusHost = "127.0.0.1:5020",
|
||||
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
|
||||
[Parameter(Mandatory)] [string]$BridgeNodeId
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
. "$PSScriptRoot/_common.ps1"
|
||||
|
||||
$hostPart, $portPart = $ModbusHost.Split(":")
|
||||
$port = [int]$portPart
|
||||
|
||||
$modbusCli = Get-CliInvocation `
|
||||
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli" `
|
||||
-ExeName "otopcua-modbus-cli"
|
||||
$opcUaCli = Get-CliInvocation `
|
||||
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
|
||||
-ExeName "otopcua-cli"
|
||||
|
||||
$commonModbus = @("-h", $hostPart, "-p", $port)
|
||||
$results = @()
|
||||
|
||||
$results += Test-Probe `
|
||||
-Cli $modbusCli `
|
||||
-ProbeArgs (@("probe") + $commonModbus)
|
||||
|
||||
$writeValue = Get-Random -Minimum 1 -Maximum 9999
|
||||
$results += Test-DriverLoopback `
|
||||
-Cli $modbusCli `
|
||||
-WriteArgs (@("write") + $commonModbus + @("-r", "HoldingRegisters", "-a", "200", "-t", "UInt16", "-v", $writeValue)) `
|
||||
-ReadArgs (@("read") + $commonModbus + @("-r", "HoldingRegisters", "-a", "200", "-t", "UInt16")) `
|
||||
-ExpectedValue "$writeValue"
|
||||
|
||||
$bridgeValue = Get-Random -Minimum 10000 -Maximum 19999
|
||||
$results += Test-ServerBridge `
|
||||
-DriverCli $modbusCli `
|
||||
-DriverWriteArgs (@("write") + $commonModbus + @("-r", "HoldingRegisters", "-a", "200", "-t", "UInt16", "-v", $bridgeValue)) `
|
||||
-OpcUaCli $opcUaCli `
|
||||
-OpcUaUrl $OpcUaUrl `
|
||||
-OpcUaNodeId $BridgeNodeId `
|
||||
-ExpectedValue "$bridgeValue"
|
||||
|
||||
$reverseValue = Get-Random -Minimum 20000 -Maximum 29999
|
||||
$results += Test-OpcUaWriteBridge `
|
||||
-OpcUaCli $opcUaCli `
|
||||
-OpcUaUrl $OpcUaUrl `
|
||||
-OpcUaNodeId $BridgeNodeId `
|
||||
-DriverCli $modbusCli `
|
||||
-DriverReadArgs (@("read") + $commonModbus + @("-r", "HoldingRegisters", "-a", "200", "-t", "UInt16")) `
|
||||
-ExpectedValue "$reverseValue"
|
||||
|
||||
$subValue = Get-Random -Minimum 30000 -Maximum 39999
|
||||
$results += Test-SubscribeSeesChange `
|
||||
-OpcUaCli $opcUaCli `
|
||||
-OpcUaUrl $OpcUaUrl `
|
||||
-OpcUaNodeId $BridgeNodeId `
|
||||
-DriverCli $modbusCli `
|
||||
-DriverWriteArgs (@("write") + $commonModbus + @("-r", "HoldingRegisters", "-a", "200", "-t", "UInt16", "-v", $subValue)) `
|
||||
-ExpectedValue "$subValue"
|
||||
|
||||
Write-Summary -Title "Modbus e2e" -Results $results
|
||||
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
||||
156
scripts/e2e/test-phase7-virtualtags.ps1
Normal file
156
scripts/e2e/test-phase7-virtualtags.ps1
Normal file
@@ -0,0 +1,156 @@
|
||||
#Requires -Version 7.0
|
||||
<#
|
||||
.SYNOPSIS
|
||||
End-to-end test for Phase 7 virtual tags + scripted alarms, driven via the
|
||||
Modbus CLI.
|
||||
|
||||
.DESCRIPTION
|
||||
Assumes the OtOpcUa server's config DB has this Phase 7 scaffolding:
|
||||
|
||||
1. A Modbus DriverInstance bound to -ModbusHost, with a Tag at HR[100]
|
||||
as UInt16 published under -InputNodeId.
|
||||
2. A VirtualTag `VT_DoubledHR100` = `double(input)` where input is
|
||||
HR[100], published under -VirtualNodeId.
|
||||
3. A ScriptedAlarm `Alarm_HighHR100` that fires when VT_DoubledHR100 > 100,
|
||||
published so the client can subscribe to AlarmConditionType events.
|
||||
|
||||
Three assertions:
|
||||
1. Virtual-tag bridge — modbus-cli writes HR[100]=21, OPC UA client reads
|
||||
VirtualNodeId + expects 42.
|
||||
2. Alarm fire — modbus-cli writes HR[100]=60 (VT=120, above threshold),
|
||||
OPC UA client alarms subscribe sees the condition go Active.
|
||||
3. Alarm clear — modbus-cli writes HR[100]=10 (VT=20, below threshold),
|
||||
OPC UA client sees the condition go back to Inactive.
|
||||
|
||||
See scripts/smoke/seed-phase-7-smoke.sql for the seed shape. This script
|
||||
doesn't seed; it verifies the running state.
|
||||
|
||||
.PARAMETER ModbusHost
|
||||
Modbus simulator endpoint. Default 127.0.0.1:5502.
|
||||
|
||||
.PARAMETER OpcUaUrl
|
||||
OtOpcUa server endpoint.
|
||||
|
||||
.PARAMETER InputNodeId
|
||||
NodeId at which the server publishes HR[100] (the input tag).
|
||||
|
||||
.PARAMETER VirtualNodeId
|
||||
NodeId at which the server publishes VT_DoubledHR100.
|
||||
|
||||
.PARAMETER AlarmNodeId
|
||||
NodeId of the AlarmConditionType (or its source) the server publishes for
|
||||
Alarm_HighHR100. Alarms subscribe filters by SourceNode = this NodeId.
|
||||
#>
|
||||
|
||||
param(
|
||||
[string]$ModbusHost = "127.0.0.1:5502",
|
||||
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
|
||||
[Parameter(Mandatory)] [string]$InputNodeId,
|
||||
[Parameter(Mandatory)] [string]$VirtualNodeId,
|
||||
[string]$AlarmNodeId
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
. "$PSScriptRoot/_common.ps1"
|
||||
|
||||
$hostPart, $portPart = $ModbusHost.Split(":")
|
||||
$port = [int]$portPart
|
||||
|
||||
$modbusCli = Get-CliInvocation `
|
||||
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli" `
|
||||
-ExeName "otopcua-modbus-cli"
|
||||
$opcUaCli = Get-CliInvocation `
|
||||
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
|
||||
-ExeName "otopcua-cli"
|
||||
|
||||
$commonModbus = @("-h", $hostPart, "-p", $port)
|
||||
$results = @()
|
||||
|
||||
# --- Assertion 1: virtual-tag bridge ------------------------------------------
|
||||
Write-Header "Virtual tag — VT_DoubledHR100 = HR[100] * 2"
|
||||
$inputValue = 21
|
||||
$expectedVirtual = $inputValue * 2
|
||||
|
||||
$w = Invoke-Cli -Cli $modbusCli -Args (@("write") + $commonModbus + `
|
||||
@("-r", "HoldingRegisters", "-a", "100", "-t", "UInt16", "-v", $inputValue))
|
||||
if ($w.ExitCode -ne 0) {
|
||||
Write-Fail "modbus write failed (exit=$($w.ExitCode))"
|
||||
$results += @{ Passed = $false; Reason = "seed write failed" }
|
||||
}
|
||||
else {
|
||||
Write-Info "wrote HR[100]=$inputValue, waiting 3s for virtual-tag engine to re-evaluate"
|
||||
Start-Sleep -Seconds 3
|
||||
$r = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $VirtualNodeId)
|
||||
if ($r.ExitCode -eq 0 -and $r.Output -match "Value:\s+$expectedVirtual\b") {
|
||||
Write-Pass "virtual tag = $expectedVirtual (input * 2)"
|
||||
$results += @{ Passed = $true }
|
||||
}
|
||||
else {
|
||||
Write-Fail "expected VT = $expectedVirtual; got:"
|
||||
Write-Host $r.Output
|
||||
$results += @{ Passed = $false; Reason = "virtual tag mismatch" }
|
||||
}
|
||||
}
|
||||
|
||||
# --- Assertion 2: scripted alarm fires ---------------------------------------
|
||||
if ([string]::IsNullOrWhiteSpace($AlarmNodeId)) {
|
||||
Write-Skip "AlarmNodeId not provided — skipping alarm fire/clear assertions"
|
||||
}
|
||||
else {
|
||||
Write-Header "Scripted alarm — fires when VT > 100"
|
||||
$fireValue = 60 # VT = 120, above threshold
|
||||
$w = Invoke-Cli -Cli $modbusCli -Args (@("write") + $commonModbus + `
|
||||
@("-r", "HoldingRegisters", "-a", "100", "-t", "UInt16", "-v", $fireValue))
|
||||
if ($w.ExitCode -ne 0) {
|
||||
Write-Fail "modbus write failed"
|
||||
$results += @{ Passed = $false }
|
||||
}
|
||||
else {
|
||||
Write-Info "wrote HR[100]=$fireValue (VT=$($fireValue*2)); subscribing alarms for 5s"
|
||||
# otopcua-cli's `alarms` command subscribes + prints events until an
|
||||
# interrupt or timeout. We capture ~5s worth then parse for ActiveState.
|
||||
$job = Start-Job -ScriptBlock {
|
||||
param($file, $prefix, $url, $source)
|
||||
$cmdArgs = $prefix + @("alarms", "-u", $url, "-n", $source, "--duration-seconds", "5")
|
||||
& $file @cmdArgs 2>&1
|
||||
} -ArgumentList $opcUaCli.File, $opcUaCli.PrefixArgs, $OpcUaUrl, $AlarmNodeId
|
||||
|
||||
$alarmOutput = Receive-Job -Job $job -Wait -AutoRemoveJob
|
||||
$alarmText = ($alarmOutput | Out-String)
|
||||
if ($alarmText -match "Active" -or $alarmText -match "HighAlarm" -or $alarmText -match "Severity") {
|
||||
Write-Pass "alarm subscription received an event"
|
||||
$results += @{ Passed = $true }
|
||||
}
|
||||
else {
|
||||
Write-Fail "expected alarm event in subscription output"
|
||||
Write-Host $alarmText
|
||||
$results += @{ Passed = $false; Reason = "alarm did not fire" }
|
||||
}
|
||||
}
|
||||
|
||||
# --- Assertion 3: alarm clears ---
|
||||
Write-Header "Scripted alarm — clears when VT falls below threshold"
|
||||
$clearValue = 10 # VT = 20, below threshold
|
||||
$w = Invoke-Cli -Cli $modbusCli -Args (@("write") + $commonModbus + `
|
||||
@("-r", "HoldingRegisters", "-a", "100", "-t", "UInt16", "-v", $clearValue))
|
||||
if ($w.ExitCode -eq 0) {
|
||||
Write-Info "wrote HR[100]=$clearValue (VT=$($clearValue*2)); alarm should clear"
|
||||
# We don't re-subscribe here — the clear is asserted via the virtual
|
||||
# tag's current value (the Phase 7 engine's commitment is that state
|
||||
# propagates on the next tick; the OPC UA alarm transition follows).
|
||||
Start-Sleep -Seconds 3
|
||||
$r = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $VirtualNodeId)
|
||||
if ($r.Output -match "Value:\s+$($clearValue*2)\b") {
|
||||
Write-Pass "virtual tag returned to below-threshold ($($clearValue*2))"
|
||||
$results += @{ Passed = $true }
|
||||
}
|
||||
else {
|
||||
Write-Fail "virtual tag did not reflect cleared state"
|
||||
Write-Host $r.Output
|
||||
$results += @{ Passed = $false; Reason = "clear state mismatch" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Summary -Title "Phase 7 virtual tags + scripted alarms" -Results $results
|
||||
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
||||
100
scripts/e2e/test-s7.ps1
Normal file
100
scripts/e2e/test-s7.ps1
Normal file
@@ -0,0 +1,100 @@
|
||||
#Requires -Version 7.0
|
||||
<#
|
||||
.SYNOPSIS
|
||||
End-to-end CLI test for the Siemens S7 driver bridged through the OtOpcUa server.
|
||||
|
||||
.DESCRIPTION
|
||||
Five assertions (probe / driver-loopback / forward-bridge / reverse-bridge /
|
||||
subscribe-sees-change) against a Siemens S7-300/400/1200/1500 or compatible
|
||||
soft-PLC. python-snap7 simulator (task #216) or real hardware both work.
|
||||
|
||||
Prereqs:
|
||||
- S7 simulator / PLC on $S7Host:$S7Port
|
||||
- On real S7-1200/1500: PUT/GET communication enabled in TIA Portal.
|
||||
- OtOpcUa server running with an S7 DriverInstance bound to the same
|
||||
endpoint + a Tag at DB1.DBW0 Int16 published under -BridgeNodeId.
|
||||
|
||||
.PARAMETER S7Host
|
||||
Host:port of the S7 simulator / PLC. Default 127.0.0.1:102.
|
||||
|
||||
.PARAMETER Cpu
|
||||
S7200 / S7200Smart / S7300 / S7400 / S71200 / S71500 (default S71500).
|
||||
|
||||
.PARAMETER Slot
|
||||
CPU slot. Default 0 (S7-1200/1500). S7-300 uses 2.
|
||||
|
||||
.PARAMETER Address
|
||||
S7 address to exercise. Default DB1.DBW0.
|
||||
|
||||
.PARAMETER OpcUaUrl
|
||||
OtOpcUa server endpoint.
|
||||
|
||||
.PARAMETER BridgeNodeId
|
||||
NodeId at which the server publishes the Address.
|
||||
#>
|
||||
|
||||
param(
|
||||
[string]$S7Host = "127.0.0.1:102",
|
||||
[string]$Cpu = "S71500",
|
||||
[int]$Slot = 0,
|
||||
[string]$Address = "DB1.DBW0",
|
||||
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
|
||||
[Parameter(Mandatory)] [string]$BridgeNodeId
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
. "$PSScriptRoot/_common.ps1"
|
||||
|
||||
$hostPart, $portPart = $S7Host.Split(":")
|
||||
$port = [int]$portPart
|
||||
|
||||
$s7Cli = Get-CliInvocation `
|
||||
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli" `
|
||||
-ExeName "otopcua-s7-cli"
|
||||
$opcUaCli = Get-CliInvocation `
|
||||
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
|
||||
-ExeName "otopcua-cli"
|
||||
|
||||
$commonS7 = @("-h", $hostPart, "-p", $port, "-c", $Cpu, "--slot", $Slot)
|
||||
$results = @()
|
||||
|
||||
$results += Test-Probe `
|
||||
-Cli $s7Cli `
|
||||
-ProbeArgs (@("probe") + $commonS7)
|
||||
|
||||
$writeValue = Get-Random -Minimum 1 -Maximum 9999
|
||||
$results += Test-DriverLoopback `
|
||||
-Cli $s7Cli `
|
||||
-WriteArgs (@("write") + $commonS7 + @("-a", $Address, "-t", "Int16", "-v", $writeValue)) `
|
||||
-ReadArgs (@("read") + $commonS7 + @("-a", $Address, "-t", "Int16")) `
|
||||
-ExpectedValue "$writeValue"
|
||||
|
||||
$bridgeValue = Get-Random -Minimum 10000 -Maximum 19999
|
||||
$results += Test-ServerBridge `
|
||||
-DriverCli $s7Cli `
|
||||
-DriverWriteArgs (@("write") + $commonS7 + @("-a", $Address, "-t", "Int16", "-v", $bridgeValue)) `
|
||||
-OpcUaCli $opcUaCli `
|
||||
-OpcUaUrl $OpcUaUrl `
|
||||
-OpcUaNodeId $BridgeNodeId `
|
||||
-ExpectedValue "$bridgeValue"
|
||||
|
||||
$reverseValue = Get-Random -Minimum 20000 -Maximum 29999
|
||||
$results += Test-OpcUaWriteBridge `
|
||||
-OpcUaCli $opcUaCli `
|
||||
-OpcUaUrl $OpcUaUrl `
|
||||
-OpcUaNodeId $BridgeNodeId `
|
||||
-DriverCli $s7Cli `
|
||||
-DriverReadArgs (@("read") + $commonS7 + @("-a", $Address, "-t", "Int16")) `
|
||||
-ExpectedValue "$reverseValue"
|
||||
|
||||
$subValue = Get-Random -Minimum 30000 -Maximum 32766
|
||||
$results += Test-SubscribeSeesChange `
|
||||
-OpcUaCli $opcUaCli `
|
||||
-OpcUaUrl $OpcUaUrl `
|
||||
-OpcUaNodeId $BridgeNodeId `
|
||||
-DriverCli $s7Cli `
|
||||
-DriverWriteArgs (@("write") + $commonS7 + @("-a", $Address, "-t", "Int16", "-v", $subValue)) `
|
||||
-ExpectedValue "$subValue"
|
||||
|
||||
Write-Summary -Title "S7 e2e" -Results $results
|
||||
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
||||
99
scripts/e2e/test-twincat.ps1
Normal file
99
scripts/e2e/test-twincat.ps1
Normal file
@@ -0,0 +1,99 @@
|
||||
#Requires -Version 7.0
|
||||
<#
|
||||
.SYNOPSIS
|
||||
End-to-end CLI test for the TwinCAT (Beckhoff ADS) driver.
|
||||
|
||||
.DESCRIPTION
|
||||
Requires a reachable AMS router (local TwinCAT XAR, Beckhoff.TwinCAT.Ads.
|
||||
TcpRouter NuGet, or an authorised remote AMS route) + a live TwinCAT
|
||||
runtime on -AmsNetId. Without one the driver surfaces a transport error
|
||||
on InitializeAsync + the script's probe fails.
|
||||
|
||||
Set TWINCAT_TRUST_WIRE=1 to promise the endpoint is live. Without it the
|
||||
script skips (task #221 tracks the 7-day-trial CI fixture — until that
|
||||
lands, TwinCAT testing is a manual operator task).
|
||||
|
||||
.PARAMETER AmsNetId
|
||||
AMS Net ID of the target (e.g. 127.0.0.1.1.1 for local XAR,
|
||||
192.168.1.40.1.1 for a remote PLC).
|
||||
|
||||
.PARAMETER AmsPort
|
||||
AMS port. Default 851 (TC3 PLC runtime). TC2 uses 801.
|
||||
|
||||
.PARAMETER SymbolPath
|
||||
TwinCAT symbol to exercise. Default 'MAIN.iCounter' — substitute with
|
||||
whatever your project actually declares.
|
||||
|
||||
.PARAMETER OpcUaUrl
|
||||
OtOpcUa server endpoint.
|
||||
|
||||
.PARAMETER BridgeNodeId
|
||||
NodeId at which the server publishes the Symbol.
|
||||
#>
|
||||
|
||||
param(
|
||||
[string]$AmsNetId = "127.0.0.1.1.1",
|
||||
[int]$AmsPort = 851,
|
||||
[string]$SymbolPath = "MAIN.iCounter",
|
||||
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
|
||||
[Parameter(Mandatory)] [string]$BridgeNodeId
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
. "$PSScriptRoot/_common.ps1"
|
||||
|
||||
if (-not ($env:TWINCAT_TRUST_WIRE -eq "1" -or $env:TWINCAT_TRUST_WIRE -eq "true")) {
|
||||
Write-Skip "TWINCAT_TRUST_WIRE not set — requires reachable AMS router + live TC runtime (task #221 tracks the CI fixture). Set =1 once the router is up."
|
||||
exit 0
|
||||
}
|
||||
|
||||
$twinCatCli = Get-CliInvocation `
|
||||
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli" `
|
||||
-ExeName "otopcua-twincat-cli"
|
||||
$opcUaCli = Get-CliInvocation `
|
||||
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
|
||||
-ExeName "otopcua-cli"
|
||||
|
||||
$commonTc = @("-n", $AmsNetId, "-p", $AmsPort)
|
||||
$results = @()
|
||||
|
||||
$results += Test-Probe `
|
||||
-Cli $twinCatCli `
|
||||
-ProbeArgs (@("probe") + $commonTc + @("-s", $SymbolPath, "--type", "DInt"))
|
||||
|
||||
$writeValue = Get-Random -Minimum 1 -Maximum 9999
|
||||
$results += Test-DriverLoopback `
|
||||
-Cli $twinCatCli `
|
||||
-WriteArgs (@("write") + $commonTc + @("-s", $SymbolPath, "-t", "DInt", "-v", $writeValue)) `
|
||||
-ReadArgs (@("read") + $commonTc + @("-s", $SymbolPath, "-t", "DInt")) `
|
||||
-ExpectedValue "$writeValue"
|
||||
|
||||
$bridgeValue = Get-Random -Minimum 10000 -Maximum 19999
|
||||
$results += Test-ServerBridge `
|
||||
-DriverCli $twinCatCli `
|
||||
-DriverWriteArgs (@("write") + $commonTc + @("-s", $SymbolPath, "-t", "DInt", "-v", $bridgeValue)) `
|
||||
-OpcUaCli $opcUaCli `
|
||||
-OpcUaUrl $OpcUaUrl `
|
||||
-OpcUaNodeId $BridgeNodeId `
|
||||
-ExpectedValue "$bridgeValue"
|
||||
|
||||
$reverseValue = Get-Random -Minimum 20000 -Maximum 29999
|
||||
$results += Test-OpcUaWriteBridge `
|
||||
-OpcUaCli $opcUaCli `
|
||||
-OpcUaUrl $OpcUaUrl `
|
||||
-OpcUaNodeId $BridgeNodeId `
|
||||
-DriverCli $twinCatCli `
|
||||
-DriverReadArgs (@("read") + $commonTc + @("-s", $SymbolPath, "-t", "DInt")) `
|
||||
-ExpectedValue "$reverseValue"
|
||||
|
||||
$subValue = Get-Random -Minimum 30000 -Maximum 39999
|
||||
$results += Test-SubscribeSeesChange `
|
||||
-OpcUaCli $opcUaCli `
|
||||
-OpcUaUrl $OpcUaUrl `
|
||||
-OpcUaNodeId $BridgeNodeId `
|
||||
-DriverCli $twinCatCli `
|
||||
-DriverWriteArgs (@("write") + $commonTc + @("-s", $SymbolPath, "-t", "DInt", "-v", $subValue)) `
|
||||
-ExpectedValue "$subValue"
|
||||
|
||||
Write-Summary -Title "TwinCAT e2e" -Results $results
|
||||
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
||||
128
scripts/smoke/seed-abcip-smoke.sql
Normal file
128
scripts/smoke/seed-abcip-smoke.sql
Normal file
@@ -0,0 +1,128 @@
|
||||
-- AB CIP e2e smoke seed — closes #211 (umbrella #209).
|
||||
--
|
||||
-- One-cluster seed pointing at the ab_server ControlLogix fixture
|
||||
-- (`docker compose -f tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/docker-compose.yml --profile controllogix up -d`).
|
||||
-- Publishes a single `TestDINT:DInt` tag under NodeId `ns=<N>;s=TestDINT`
|
||||
-- (ab_server seeds this tag by default).
|
||||
--
|
||||
-- Usage:
|
||||
-- sqlcmd -S "localhost,14330" -d OtOpcUaConfig -U sa -P "OtOpcUaDev_2026!" \
|
||||
-- -i scripts/smoke/seed-abcip-smoke.sql
|
||||
--
|
||||
-- After seeding, point appsettings at this cluster:
|
||||
-- Node:NodeId = "abcip-smoke-node"
|
||||
-- Node:ClusterId = "abcip-smoke"
|
||||
-- Then start server + run `./scripts/e2e/test-abcip.ps1 -BridgeNodeId "ns=2;s=TestDINT"`.
|
||||
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET ANSI_PADDING ON;
|
||||
SET ANSI_WARNINGS ON;
|
||||
SET ARITHABORT ON;
|
||||
SET CONCAT_NULL_YIELDS_NULL ON;
|
||||
|
||||
DECLARE @ClusterId nvarchar(64) = 'abcip-smoke';
|
||||
DECLARE @NodeId nvarchar(64) = 'abcip-smoke-node';
|
||||
DECLARE @DrvId nvarchar(64) = 'abcip-smoke-drv';
|
||||
DECLARE @NsId nvarchar(64) = 'abcip-smoke-ns';
|
||||
DECLARE @AreaId nvarchar(64) = 'abcip-smoke-area';
|
||||
DECLARE @LineId nvarchar(64) = 'abcip-smoke-line';
|
||||
DECLARE @EqId nvarchar(64) = 'abcip-smoke-eq';
|
||||
DECLARE @EqUuid uniqueidentifier = '41BC12E0-41BC-412E-841B-C12E041BC12E';
|
||||
DECLARE @TagId nvarchar(64) = 'abcip-smoke-tag-testdint';
|
||||
|
||||
BEGIN TRAN;
|
||||
|
||||
DELETE FROM dbo.Tag WHERE TagId IN (@TagId);
|
||||
DELETE FROM dbo.Equipment WHERE EquipmentId = @EqId;
|
||||
DELETE FROM dbo.UnsLine WHERE UnsLineId = @LineId;
|
||||
DELETE FROM dbo.UnsArea WHERE UnsAreaId = @AreaId;
|
||||
DELETE FROM dbo.DriverInstance WHERE DriverInstanceId = @DrvId;
|
||||
DELETE FROM dbo.Namespace WHERE NamespaceId = @NsId;
|
||||
DELETE FROM dbo.ConfigGeneration WHERE ClusterId = @ClusterId;
|
||||
DELETE FROM dbo.ClusterNodeCredential WHERE NodeId = @NodeId;
|
||||
DELETE FROM dbo.ClusterNodeGenerationState WHERE NodeId = @NodeId;
|
||||
DELETE FROM dbo.ClusterNode WHERE NodeId = @NodeId;
|
||||
DELETE FROM dbo.ServerCluster WHERE ClusterId = @ClusterId;
|
||||
|
||||
DELETE FROM dbo.ClusterNodeCredential WHERE Kind = 'SqlLogin' AND Value = 'sa';
|
||||
|
||||
INSERT dbo.ServerCluster(ClusterId, Name, Enterprise, Site, NodeCount, RedundancyMode, Enabled, CreatedBy)
|
||||
VALUES (@ClusterId, 'AB CIP Smoke', 'zb', 'lab', 1, 'None', 1, 'abcip-smoke');
|
||||
|
||||
INSERT dbo.ClusterNode(NodeId, ClusterId, RedundancyRole, Host, OpcUaPort, DashboardPort,
|
||||
ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
|
||||
VALUES (@NodeId, @ClusterId, 'Primary', 'localhost', 4840, 15050,
|
||||
'urn:OtOpcUa:abcip-smoke-node', 200, 1, 'abcip-smoke');
|
||||
|
||||
INSERT dbo.ClusterNodeCredential(NodeId, Kind, Value, Enabled, CreatedBy)
|
||||
VALUES (@NodeId, 'SqlLogin', 'sa', 1, 'abcip-smoke');
|
||||
|
||||
DECLARE @Gen bigint;
|
||||
INSERT dbo.ConfigGeneration(ClusterId, Status, CreatedBy)
|
||||
VALUES (@ClusterId, 'Draft', 'abcip-smoke');
|
||||
SET @Gen = SCOPE_IDENTITY();
|
||||
|
||||
INSERT dbo.Namespace(GenerationId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled)
|
||||
VALUES (@Gen, @NsId, @ClusterId, 'Equipment', 'urn:abcip-smoke:eq', 1);
|
||||
|
||||
INSERT dbo.UnsArea(GenerationId, UnsAreaId, ClusterId, Name)
|
||||
VALUES (@Gen, @AreaId, @ClusterId, 'lab-floor');
|
||||
|
||||
INSERT dbo.UnsLine(GenerationId, UnsLineId, UnsAreaId, Name)
|
||||
VALUES (@Gen, @LineId, @AreaId, 'abcip-line');
|
||||
|
||||
INSERT dbo.Equipment(GenerationId, EquipmentId, EquipmentUuid, DriverInstanceId, UnsLineId,
|
||||
Name, MachineCode, Enabled)
|
||||
VALUES (@Gen, @EqId, @EqUuid, @DrvId, @LineId, 'ab-sim', 'abcip-001', 1);
|
||||
|
||||
-- AB CIP DriverInstance — single ControlLogix device at the ab_server fixture
|
||||
-- gateway. DriverConfig shape mirrors AbCipDriverConfigDto.
|
||||
INSERT dbo.DriverInstance(GenerationId, DriverInstanceId, ClusterId, NamespaceId,
|
||||
Name, DriverType, DriverConfig, Enabled)
|
||||
VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'ab-server-smoke', 'AbCip', N'{
|
||||
"TimeoutMs": 2000,
|
||||
"Devices": [
|
||||
{
|
||||
"HostAddress": "ab://127.0.0.1:44818/1,0",
|
||||
"PlcFamily": "ControlLogix",
|
||||
"DeviceName": "ab-server"
|
||||
}
|
||||
],
|
||||
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000 },
|
||||
"Tags": [
|
||||
{
|
||||
"Name": "TestDINT",
|
||||
"DeviceHostAddress": "ab://127.0.0.1:44818/1,0",
|
||||
"TagPath": "TestDINT",
|
||||
"DataType": "DInt",
|
||||
"Writable": true,
|
||||
"WriteIdempotent": true
|
||||
}
|
||||
]
|
||||
}', 1);
|
||||
|
||||
INSERT dbo.Tag(GenerationId, TagId, DriverInstanceId, EquipmentId, Name, DataType,
|
||||
AccessLevel, TagConfig, WriteIdempotent)
|
||||
VALUES (@Gen, @TagId, @DrvId, @EqId, 'TestDINT', 'Int32', 'ReadWrite',
|
||||
N'{"FullName":"TestDINT","DataType":"DInt"}', 1);
|
||||
|
||||
EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @Gen,
|
||||
@Notes = N'AB CIP smoke — task #211';
|
||||
|
||||
COMMIT;
|
||||
|
||||
PRINT '';
|
||||
PRINT 'AB CIP smoke seed complete.';
|
||||
PRINT ' Cluster: ' + @ClusterId;
|
||||
PRINT ' Node: ' + @NodeId;
|
||||
PRINT ' Generation: ' + CONVERT(nvarchar(20), @Gen);
|
||||
PRINT '';
|
||||
PRINT 'Next steps:';
|
||||
PRINT ' 1. Set src/.../Server/appsettings.json Node:NodeId = "abcip-smoke-node"';
|
||||
PRINT ' Node:ClusterId = "abcip-smoke"';
|
||||
PRINT ' 2. docker compose -f tests/.../AbCip.IntegrationTests/Docker/docker-compose.yml --profile controllogix up -d';
|
||||
PRINT ' 3. dotnet run --project src/ZB.MOM.WW.OtOpcUa.Server';
|
||||
PRINT ' 4. ./scripts/e2e/test-abcip.ps1 -BridgeNodeId "ns=2;s=TestDINT"';
|
||||
125
scripts/smoke/seed-ablegacy-smoke.sql
Normal file
125
scripts/smoke/seed-ablegacy-smoke.sql
Normal file
@@ -0,0 +1,125 @@
|
||||
-- AB Legacy e2e smoke seed — closes #213 (umbrella #209).
|
||||
--
|
||||
-- Works against the ab_server PCCC Docker fixture (one of the slc500 /
|
||||
-- micrologix / plc5 compose profiles) or real SLC 500 / MicroLogix / PLC-5
|
||||
-- hardware. Default HostAddress below points at the Docker fixture with a
|
||||
-- `/1,0` cip-path; libplctag's ab_server rejects empty paths before routing
|
||||
-- to the PCCC dispatcher. Real hardware uses an empty path — change the
|
||||
-- HostAddress to `ab://<plc-ip>:44818/` (note the trailing slash with nothing
|
||||
-- after) before running the seed for that setup.
|
||||
--
|
||||
-- Usage:
|
||||
-- docker compose -f tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml --profile slc500 up -d
|
||||
-- sqlcmd -S "localhost,14330" -d OtOpcUaConfig -U sa -P "OtOpcUaDev_2026!" \
|
||||
-- -i scripts/smoke/seed-ablegacy-smoke.sql
|
||||
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET ANSI_PADDING ON;
|
||||
SET ANSI_WARNINGS ON;
|
||||
SET ARITHABORT ON;
|
||||
SET CONCAT_NULL_YIELDS_NULL ON;
|
||||
|
||||
DECLARE @ClusterId nvarchar(64) = 'ablegacy-smoke';
|
||||
DECLARE @NodeId nvarchar(64) = 'ablegacy-smoke-node';
|
||||
DECLARE @DrvId nvarchar(64) = 'ablegacy-smoke-drv';
|
||||
DECLARE @NsId nvarchar(64) = 'ablegacy-smoke-ns';
|
||||
DECLARE @AreaId nvarchar(64) = 'ablegacy-smoke-area';
|
||||
DECLARE @LineId nvarchar(64) = 'ablegacy-smoke-line';
|
||||
DECLARE @EqId nvarchar(64) = 'ablegacy-smoke-eq';
|
||||
DECLARE @EqUuid uniqueidentifier = '5A1D2030-5A1D-4203-A5A1-D20305A1D203';
|
||||
DECLARE @TagId nvarchar(64) = 'ablegacy-smoke-tag-n7_5';
|
||||
|
||||
BEGIN TRAN;
|
||||
|
||||
DELETE FROM dbo.Tag WHERE TagId IN (@TagId);
|
||||
DELETE FROM dbo.Equipment WHERE EquipmentId = @EqId;
|
||||
DELETE FROM dbo.UnsLine WHERE UnsLineId = @LineId;
|
||||
DELETE FROM dbo.UnsArea WHERE UnsAreaId = @AreaId;
|
||||
DELETE FROM dbo.DriverInstance WHERE DriverInstanceId = @DrvId;
|
||||
DELETE FROM dbo.Namespace WHERE NamespaceId = @NsId;
|
||||
DELETE FROM dbo.ConfigGeneration WHERE ClusterId = @ClusterId;
|
||||
DELETE FROM dbo.ClusterNodeCredential WHERE NodeId = @NodeId;
|
||||
DELETE FROM dbo.ClusterNodeGenerationState WHERE NodeId = @NodeId;
|
||||
DELETE FROM dbo.ClusterNode WHERE NodeId = @NodeId;
|
||||
DELETE FROM dbo.ServerCluster WHERE ClusterId = @ClusterId;
|
||||
|
||||
DELETE FROM dbo.ClusterNodeCredential WHERE Kind = 'SqlLogin' AND Value = 'sa';
|
||||
|
||||
INSERT dbo.ServerCluster(ClusterId, Name, Enterprise, Site, NodeCount, RedundancyMode, Enabled, CreatedBy)
|
||||
VALUES (@ClusterId, 'AB Legacy Smoke', 'zb', 'lab', 1, 'None', 1, 'ablegacy-smoke');
|
||||
|
||||
INSERT dbo.ClusterNode(NodeId, ClusterId, RedundancyRole, Host, OpcUaPort, DashboardPort,
|
||||
ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
|
||||
VALUES (@NodeId, @ClusterId, 'Primary', 'localhost', 4840, 15050,
|
||||
'urn:OtOpcUa:ablegacy-smoke-node', 200, 1, 'ablegacy-smoke');
|
||||
|
||||
INSERT dbo.ClusterNodeCredential(NodeId, Kind, Value, Enabled, CreatedBy)
|
||||
VALUES (@NodeId, 'SqlLogin', 'sa', 1, 'ablegacy-smoke');
|
||||
|
||||
DECLARE @Gen bigint;
|
||||
INSERT dbo.ConfigGeneration(ClusterId, Status, CreatedBy)
|
||||
VALUES (@ClusterId, 'Draft', 'ablegacy-smoke');
|
||||
SET @Gen = SCOPE_IDENTITY();
|
||||
|
||||
INSERT dbo.Namespace(GenerationId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled)
|
||||
VALUES (@Gen, @NsId, @ClusterId, 'Equipment', 'urn:ablegacy-smoke:eq', 1);
|
||||
|
||||
INSERT dbo.UnsArea(GenerationId, UnsAreaId, ClusterId, Name)
|
||||
VALUES (@Gen, @AreaId, @ClusterId, 'lab-floor');
|
||||
|
||||
INSERT dbo.UnsLine(GenerationId, UnsLineId, UnsAreaId, Name)
|
||||
VALUES (@Gen, @LineId, @AreaId, 'ablegacy-line');
|
||||
|
||||
INSERT dbo.Equipment(GenerationId, EquipmentId, EquipmentUuid, DriverInstanceId, UnsLineId,
|
||||
Name, MachineCode, Enabled)
|
||||
VALUES (@Gen, @EqId, @EqUuid, @DrvId, @LineId, 'slc-sim', 'ablegacy-001', 1);
|
||||
|
||||
-- AB Legacy DriverInstance — SLC 500 target. Replace the placeholder gateway
|
||||
-- `192.168.1.10` with the real PLC / RSEmulate host before running.
|
||||
INSERT dbo.DriverInstance(GenerationId, DriverInstanceId, ClusterId, NamespaceId,
|
||||
Name, DriverType, DriverConfig, Enabled)
|
||||
VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'ablegacy-smoke', 'AbLegacy', N'{
|
||||
"TimeoutMs": 2000,
|
||||
"Devices": [
|
||||
{
|
||||
"HostAddress": "ab://127.0.0.1:44818/1,0",
|
||||
"PlcFamily": "Slc500",
|
||||
"DeviceName": "slc-500"
|
||||
}
|
||||
],
|
||||
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000, "ProbeAddress": "S:0" },
|
||||
"Tags": [
|
||||
{
|
||||
"Name": "N7_5",
|
||||
"DeviceHostAddress": "ab://127.0.0.1:44818/1,0",
|
||||
"Address": "N7:5",
|
||||
"DataType": "Int",
|
||||
"Writable": true,
|
||||
"WriteIdempotent": true
|
||||
}
|
||||
]
|
||||
}', 1);
|
||||
|
||||
INSERT dbo.Tag(GenerationId, TagId, DriverInstanceId, EquipmentId, Name, DataType,
|
||||
AccessLevel, TagConfig, WriteIdempotent)
|
||||
VALUES (@Gen, @TagId, @DrvId, @EqId, 'N7_5', 'Int16', 'ReadWrite',
|
||||
N'{"FullName":"N7_5","Address":"N7:5","DataType":"Int"}', 1);
|
||||
|
||||
EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @Gen,
|
||||
@Notes = N'AB Legacy smoke — task #213';
|
||||
|
||||
COMMIT;
|
||||
|
||||
PRINT '';
|
||||
PRINT 'AB Legacy smoke seed complete.';
|
||||
PRINT ' Cluster: ' + @ClusterId;
|
||||
PRINT ' Node: ' + @NodeId;
|
||||
PRINT ' Generation: ' + CONVERT(nvarchar(20), @Gen);
|
||||
PRINT '';
|
||||
PRINT 'NOTE: default points at the ab_server slc500 Docker fixture with a /1,0';
|
||||
PRINT ' cip-path (required by ab_server). For real SLC/MicroLogix/PLC-5';
|
||||
PRINT ' hardware, edit the DriverConfig HostAddress to end with /<empty>';
|
||||
PRINT ' e.g. "ab://<plc-ip>:44818/" and re-run this seed.';
|
||||
156
scripts/smoke/seed-modbus-smoke.sql
Normal file
156
scripts/smoke/seed-modbus-smoke.sql
Normal file
@@ -0,0 +1,156 @@
|
||||
-- Modbus e2e smoke seed — closes #210 (umbrella #209).
|
||||
--
|
||||
-- Idempotent — DROP-and-recreate of one cluster's worth of Modbus test config:
|
||||
-- * 1 ServerCluster ('modbus-smoke') + ClusterNode ('modbus-smoke-node')
|
||||
-- * 1 ConfigGeneration (Draft → Published at the end)
|
||||
-- * 1 Namespace + UnsArea + UnsLine + Equipment
|
||||
-- * 1 Modbus DriverInstance pointing at the pymodbus standard fixture
|
||||
-- (127.0.0.1:5020 per tests/.../Modbus.IntegrationTests/Docker)
|
||||
-- * 1 Tag at HR[200]:UInt16 (HR[100] is auto-increment in standard.json,
|
||||
-- unusable as a write target — the e2e script uses HR[200] for that reason)
|
||||
--
|
||||
-- Usage:
|
||||
-- sqlcmd -S "localhost,14330" -d OtOpcUaConfig -U sa -P "OtOpcUaDev_2026!" \
|
||||
-- -i scripts/smoke/seed-modbus-smoke.sql
|
||||
--
|
||||
-- After seeding, update src/.../Server/appsettings.json:
|
||||
-- Node:NodeId = "modbus-smoke-node"
|
||||
-- Node:ClusterId = "modbus-smoke"
|
||||
--
|
||||
-- Then start the simulator + server + run the e2e script:
|
||||
-- docker compose -f tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/docker-compose.yml --profile standard up -d
|
||||
-- dotnet run --project src/ZB.MOM.WW.OtOpcUa.Server
|
||||
-- ./scripts/e2e/test-modbus.ps1 -BridgeNodeId "ns=2;s=HR200"
|
||||
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET ANSI_PADDING ON;
|
||||
SET ANSI_WARNINGS ON;
|
||||
SET ARITHABORT ON;
|
||||
SET CONCAT_NULL_YIELDS_NULL ON;
|
||||
|
||||
DECLARE @ClusterId nvarchar(64) = 'modbus-smoke';
|
||||
DECLARE @NodeId nvarchar(64) = 'modbus-smoke-node';
|
||||
DECLARE @DrvId nvarchar(64) = 'modbus-smoke-drv';
|
||||
DECLARE @NsId nvarchar(64) = 'modbus-smoke-ns';
|
||||
DECLARE @AreaId nvarchar(64) = 'modbus-smoke-area';
|
||||
DECLARE @LineId nvarchar(64) = 'modbus-smoke-line';
|
||||
DECLARE @EqId nvarchar(64) = 'modbus-smoke-eq';
|
||||
DECLARE @EqUuid uniqueidentifier = '72BD5A10-72BD-45A1-B72B-D5A1072BD5A1';
|
||||
DECLARE @TagHr200 nvarchar(64) = 'modbus-smoke-tag-hr200';
|
||||
|
||||
BEGIN TRAN;
|
||||
|
||||
-- Clean prior smoke state (child rows first).
|
||||
DELETE FROM dbo.Tag WHERE TagId IN (@TagHr200);
|
||||
DELETE FROM dbo.Equipment WHERE EquipmentId = @EqId;
|
||||
DELETE FROM dbo.UnsLine WHERE UnsLineId = @LineId;
|
||||
DELETE FROM dbo.UnsArea WHERE UnsAreaId = @AreaId;
|
||||
DELETE FROM dbo.DriverInstance WHERE DriverInstanceId = @DrvId;
|
||||
DELETE FROM dbo.Namespace WHERE NamespaceId = @NsId;
|
||||
DELETE FROM dbo.ConfigGeneration WHERE ClusterId = @ClusterId;
|
||||
DELETE FROM dbo.ClusterNodeCredential WHERE NodeId = @NodeId;
|
||||
DELETE FROM dbo.ClusterNodeGenerationState WHERE NodeId = @NodeId;
|
||||
DELETE FROM dbo.ClusterNode WHERE NodeId = @NodeId;
|
||||
DELETE FROM dbo.ServerCluster WHERE ClusterId = @ClusterId;
|
||||
|
||||
-- `UX_ClusterNodeCredential_Value` is a unique index on (Kind, Value) WHERE
|
||||
-- Enabled=1, so a `sa` login can only bind to one node at a time. Drop any
|
||||
-- prior smoke cluster's binding before we claim the login for this one.
|
||||
DELETE FROM dbo.ClusterNodeCredential WHERE Kind = 'SqlLogin' AND Value = 'sa';
|
||||
|
||||
-- 1. Cluster + Node.
|
||||
INSERT dbo.ServerCluster(ClusterId, Name, Enterprise, Site, NodeCount, RedundancyMode, Enabled, CreatedBy)
|
||||
VALUES (@ClusterId, 'Modbus Smoke', 'zb', 'lab', 1, 'None', 1, 'modbus-smoke');
|
||||
|
||||
-- DashboardPort 15050 rather than 5000 — HttpListener on :5000 requires
|
||||
-- URL-ACL reservation or admin rights on Windows (HttpListenerException 32).
|
||||
-- 15000+ ports are unreserved by default. Safe to change back when deploying
|
||||
-- with a netsh urlacl grant or reverse-proxy fronting :5000.
|
||||
INSERT dbo.ClusterNode(NodeId, ClusterId, RedundancyRole, Host, OpcUaPort, DashboardPort,
|
||||
ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
|
||||
VALUES (@NodeId, @ClusterId, 'Primary', 'localhost', 4840, 15050,
|
||||
'urn:OtOpcUa:modbus-smoke-node', 200, 1, 'modbus-smoke');
|
||||
|
||||
-- Bind the SQL login this smoke test connects as to the node identity. The
|
||||
-- sp_GetCurrentGenerationForCluster + sp_UpdateClusterNodeGenerationState
|
||||
-- sprocs raise RAISERROR('Unauthorized: caller %s is not bound to NodeId %s')
|
||||
-- when this row is missing. `Kind='SqlLogin'` / `Value='sa'` matches the
|
||||
-- container's SA user; rotate Value for real deployments using a non-SA login.
|
||||
INSERT dbo.ClusterNodeCredential(NodeId, Kind, Value, Enabled, CreatedBy)
|
||||
VALUES (@NodeId, 'SqlLogin', 'sa', 1, 'modbus-smoke');
|
||||
|
||||
-- 2. Draft generation.
|
||||
DECLARE @Gen bigint;
|
||||
INSERT dbo.ConfigGeneration(ClusterId, Status, CreatedBy)
|
||||
VALUES (@ClusterId, 'Draft', 'modbus-smoke');
|
||||
SET @Gen = SCOPE_IDENTITY();
|
||||
|
||||
-- 3. Namespace.
|
||||
INSERT dbo.Namespace(GenerationId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled)
|
||||
VALUES (@Gen, @NsId, @ClusterId, 'Equipment', 'urn:modbus-smoke:eq', 1);
|
||||
|
||||
-- 4. UNS hierarchy.
|
||||
INSERT dbo.UnsArea(GenerationId, UnsAreaId, ClusterId, Name)
|
||||
VALUES (@Gen, @AreaId, @ClusterId, 'lab-floor');
|
||||
|
||||
INSERT dbo.UnsLine(GenerationId, UnsLineId, UnsAreaId, Name)
|
||||
VALUES (@Gen, @LineId, @AreaId, 'modbus-line');
|
||||
|
||||
INSERT dbo.Equipment(GenerationId, EquipmentId, EquipmentUuid, DriverInstanceId, UnsLineId,
|
||||
Name, MachineCode, Enabled)
|
||||
VALUES (@Gen, @EqId, @EqUuid, @DrvId, @LineId, 'modbus-sim', 'modbus-001', 1);
|
||||
|
||||
-- 5. Modbus DriverInstance. DriverConfig mirrors ModbusDriverConfigDto
|
||||
-- (mapped to ModbusDriverOptions by ModbusDriverFactoryExtensions).
|
||||
INSERT dbo.DriverInstance(GenerationId, DriverInstanceId, ClusterId, NamespaceId,
|
||||
Name, DriverType, DriverConfig, Enabled)
|
||||
VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'pymodbus-smoke', 'Modbus', N'{
|
||||
"Host": "127.0.0.1",
|
||||
"Port": 5020,
|
||||
"UnitId": 1,
|
||||
"TimeoutMs": 2000,
|
||||
"AutoReconnect": true,
|
||||
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000, "ProbeAddress": 0 },
|
||||
"Tags": [
|
||||
{
|
||||
"Name": "HR200",
|
||||
"Region": "HoldingRegisters",
|
||||
"Address": 200,
|
||||
"DataType": "UInt16",
|
||||
"Writable": true,
|
||||
"WriteIdempotent": true
|
||||
}
|
||||
]
|
||||
}', 1);
|
||||
|
||||
-- 6. Tag row bound to the Equipment. Driver reports the same tag via
|
||||
-- DiscoverAsync + the walker maps the UnsArea/Line/Equipment/Tag path to the
|
||||
-- driver's folder/variable (NodeId ends up ns=<driver-ns>;s=HR200 per
|
||||
-- ModbusDriver.DiscoverAsync using FullName = tag.Name).
|
||||
INSERT dbo.Tag(GenerationId, TagId, DriverInstanceId, EquipmentId, Name, DataType,
|
||||
AccessLevel, TagConfig, WriteIdempotent)
|
||||
VALUES (@Gen, @TagHr200, @DrvId, @EqId, 'HR200', 'UInt16', 'ReadWrite',
|
||||
N'{"FullName":"HR200","DataType":"UInt16"}', 1);
|
||||
|
||||
-- 7. Publish the generation — flips Status Draft → Published, merges
|
||||
-- ExternalIdReservation, claims cluster write lock.
|
||||
EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @Gen,
|
||||
@Notes = N'Modbus smoke — task #210';
|
||||
|
||||
COMMIT;
|
||||
|
||||
PRINT '';
|
||||
PRINT 'Modbus smoke seed complete.';
|
||||
PRINT ' Cluster: ' + @ClusterId;
|
||||
PRINT ' Node: ' + @NodeId;
|
||||
PRINT ' Generation: ' + CONVERT(nvarchar(20), @Gen);
|
||||
PRINT '';
|
||||
PRINT 'Next steps:';
|
||||
PRINT ' 1. Set src/.../Server/appsettings.json Node:NodeId = "modbus-smoke-node"';
|
||||
PRINT ' Node:ClusterId = "modbus-smoke"';
|
||||
PRINT ' 2. docker compose -f tests/.../Modbus.IntegrationTests/Docker/docker-compose.yml --profile standard up -d';
|
||||
PRINT ' 3. dotnet run --project src/ZB.MOM.WW.OtOpcUa.Server';
|
||||
PRINT ' 4. ./scripts/e2e/test-modbus.ps1 -BridgeNodeId "ns=2;s=HR200"';
|
||||
166
scripts/smoke/seed-phase-7-smoke.sql
Normal file
166
scripts/smoke/seed-phase-7-smoke.sql
Normal file
@@ -0,0 +1,166 @@
|
||||
-- Phase 7 live OPC UA E2E smoke seed (task #240).
|
||||
--
|
||||
-- Idempotent — DROP-and-recreate of one cluster's worth of test config:
|
||||
-- * 1 ServerCluster ('p7-smoke')
|
||||
-- * 1 ClusterNode ('p7-smoke-node')
|
||||
-- * 1 ConfigGeneration (created Draft, then flipped to Published at the end)
|
||||
-- * 1 Namespace (Equipment kind)
|
||||
-- * 1 UnsArea / UnsLine / Equipment / Tag — Tag bound to a real Galaxy attribute
|
||||
-- * 1 DriverInstance (Galaxy)
|
||||
-- * 1 Script + 1 VirtualTag using it
|
||||
-- * 1 Script + 1 ScriptedAlarm using it
|
||||
--
|
||||
-- Drop & re-create deletes ALL rows scoped to the cluster (in dependency order)
|
||||
-- so re-running this script after a code change starts from a clean state.
|
||||
-- Table-level CHECK constraints are validated on insert; if a constraint is
|
||||
-- violated this script aborts with the offending row's column.
|
||||
--
|
||||
-- Usage:
|
||||
-- sqlcmd -S "localhost,14330" -d OtOpcUaConfig -U sa -P "OtOpcUaDev_2026!" \
|
||||
-- -i scripts/smoke/seed-phase-7-smoke.sql
|
||||
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET ANSI_PADDING ON;
|
||||
SET ANSI_WARNINGS ON;
|
||||
SET ARITHABORT ON;
|
||||
SET CONCAT_NULL_YIELDS_NULL ON;
|
||||
|
||||
DECLARE @ClusterId nvarchar(64) = 'p7-smoke';
|
||||
DECLARE @NodeId nvarchar(64) = 'p7-smoke-node';
|
||||
DECLARE @DrvId nvarchar(64) = 'p7-smoke-galaxy';
|
||||
DECLARE @NsId nvarchar(64) = 'p7-smoke-ns';
|
||||
DECLARE @AreaId nvarchar(64) = 'p7-smoke-area';
|
||||
DECLARE @LineId nvarchar(64) = 'p7-smoke-line';
|
||||
DECLARE @EqId nvarchar(64) = 'p7-smoke-eq';
|
||||
DECLARE @EqUuid uniqueidentifier = '5B2CF10D-5B2C-4F10-B5B2-CF10D5B2CF10';
|
||||
DECLARE @TagId nvarchar(64) = 'p7-smoke-tag-source';
|
||||
DECLARE @VtScript nvarchar(64) = 'p7-smoke-script-vt';
|
||||
DECLARE @AlScript nvarchar(64) = 'p7-smoke-script-al';
|
||||
DECLARE @VtId nvarchar(64) = 'p7-smoke-vt-derived';
|
||||
DECLARE @AlId nvarchar(64) = 'p7-smoke-al-overtemp';
|
||||
|
||||
BEGIN TRAN;
|
||||
|
||||
-- Wipe any prior smoke state. Order matters: child rows first.
|
||||
DELETE s FROM dbo.ScriptedAlarmState s
|
||||
WHERE s.ScriptedAlarmId = @AlId;
|
||||
DELETE FROM dbo.ScriptedAlarm WHERE ScriptedAlarmId = @AlId;
|
||||
DELETE FROM dbo.VirtualTag WHERE VirtualTagId = @VtId;
|
||||
DELETE FROM dbo.Script WHERE ScriptId IN (@VtScript, @AlScript);
|
||||
DELETE FROM dbo.Tag WHERE TagId = @TagId;
|
||||
DELETE FROM dbo.Equipment WHERE EquipmentId = @EqId;
|
||||
DELETE FROM dbo.UnsLine WHERE UnsLineId = @LineId;
|
||||
DELETE FROM dbo.UnsArea WHERE UnsAreaId = @AreaId;
|
||||
DELETE FROM dbo.DriverInstance WHERE DriverInstanceId = @DrvId;
|
||||
DELETE FROM dbo.Namespace WHERE NamespaceId = @NsId;
|
||||
DELETE FROM dbo.ConfigGeneration WHERE ClusterId = @ClusterId;
|
||||
DELETE FROM dbo.ClusterNodeCredential WHERE NodeId = @NodeId;
|
||||
DELETE FROM dbo.ClusterNodeGenerationState WHERE NodeId = @NodeId;
|
||||
DELETE FROM dbo.ClusterNode WHERE NodeId = @NodeId;
|
||||
DELETE FROM dbo.ServerCluster WHERE ClusterId = @ClusterId;
|
||||
|
||||
-- 1. Cluster + Node
|
||||
INSERT dbo.ServerCluster(ClusterId, Name, Enterprise, Site, NodeCount, RedundancyMode, Enabled, CreatedBy)
|
||||
VALUES (@ClusterId, 'P7 Smoke', 'zb', 'lab', 1, 'None', 1, 'p7-smoke');
|
||||
|
||||
INSERT dbo.ClusterNode(NodeId, ClusterId, RedundancyRole, Host, OpcUaPort, DashboardPort,
|
||||
ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
|
||||
VALUES (@NodeId, @ClusterId, 'Primary', 'localhost', 4840, 5000,
|
||||
'urn:OtOpcUa:p7-smoke-node', 200, 1, 'p7-smoke');
|
||||
|
||||
-- 2. Generation (created Draft, flipped to Published at the end so insert order
|
||||
-- constraints (one Draft per cluster, etc.) don't fight us).
|
||||
DECLARE @Gen bigint;
|
||||
INSERT dbo.ConfigGeneration(ClusterId, Status, CreatedBy)
|
||||
VALUES (@ClusterId, 'Draft', 'p7-smoke');
|
||||
SET @Gen = SCOPE_IDENTITY();
|
||||
|
||||
-- 3. Namespace
|
||||
INSERT dbo.Namespace(GenerationId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled)
|
||||
VALUES (@Gen, @NsId, @ClusterId, 'Equipment', 'urn:p7-smoke:eq', 1);
|
||||
|
||||
-- 4. UNS hierarchy
|
||||
INSERT dbo.UnsArea(GenerationId, UnsAreaId, ClusterId, Name)
|
||||
VALUES (@Gen, @AreaId, @ClusterId, 'lab-floor');
|
||||
|
||||
INSERT dbo.UnsLine(GenerationId, UnsLineId, UnsAreaId, Name)
|
||||
VALUES (@Gen, @LineId, @AreaId, 'galaxy-line');
|
||||
|
||||
INSERT dbo.Equipment(GenerationId, EquipmentId, EquipmentUuid, DriverInstanceId, UnsLineId,
|
||||
Name, MachineCode, Enabled)
|
||||
VALUES (@Gen, @EqId, @EqUuid, @DrvId, @LineId, 'reactor-1', 'p7-rx-001', 1);
|
||||
|
||||
-- 5. Driver — Galaxy proxy. DriverConfig JSON tells the proxy how to reach the
|
||||
-- already-running OtOpcUaGalaxyHost. Secret + pipe name match
|
||||
-- .local/galaxy-host-secret.txt + the OtOpcUaGalaxyHost service env.
|
||||
INSERT dbo.DriverInstance(GenerationId, DriverInstanceId, ClusterId, NamespaceId,
|
||||
Name, DriverType, DriverConfig, Enabled)
|
||||
VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'galaxy-smoke', 'Galaxy', N'{
|
||||
"DriverInstanceId": "p7-smoke-galaxy",
|
||||
"PipeName": "OtOpcUaGalaxy",
|
||||
"SharedSecret": "4hgDJ4jLcKXmOmD1Ara8xtE8N3R47Q2y1Xf/Eama/Fk=",
|
||||
"ConnectTimeoutMs": 10000
|
||||
}', 1);
|
||||
|
||||
-- 6. One driver-sourced Tag bound to the Equipment. TagConfig is the Galaxy
|
||||
-- fullRef ("DelmiaReceiver_001.DownloadPath" style); replace with a real
|
||||
-- attribute on this Galaxy. The script paths below use
|
||||
-- /lab-floor/galaxy-line/reactor-1/Source which the EquipmentNodeWalker
|
||||
-- emits + the DriverSubscriptionBridge maps to this driver fullRef.
|
||||
INSERT dbo.Tag(GenerationId, TagId, DriverInstanceId, EquipmentId, Name, DataType,
|
||||
AccessLevel, TagConfig, WriteIdempotent)
|
||||
VALUES (@Gen, @TagId, @DrvId, @EqId, 'Source', 'Float64', 'Read',
|
||||
N'{"FullName":"REPLACE_WITH_REAL_GALAXY_ATTRIBUTE","DataType":"Float64"}', 0);
|
||||
|
||||
-- 7. Scripts (SourceHash is SHA-256 of SourceCode, computed externally — using
|
||||
-- a placeholder here; the engine recomputes on first use anyway).
|
||||
INSERT dbo.Script(GenerationId, ScriptId, Name, SourceCode, SourceHash, Language)
|
||||
VALUES
|
||||
(@Gen, @VtScript, 'doubled-source',
|
||||
N'return ((double)ctx.GetTag("/lab-floor/galaxy-line/reactor-1/Source").Value) * 2.0;',
|
||||
'0000000000000000000000000000000000000000000000000000000000000000', 'CSharp'),
|
||||
(@Gen, @AlScript, 'overtemp-predicate',
|
||||
N'return ((double)ctx.GetTag("/lab-floor/galaxy-line/reactor-1/Source").Value) > 50.0;',
|
||||
'0000000000000000000000000000000000000000000000000000000000000000', 'CSharp');
|
||||
|
||||
-- 8. VirtualTag — derived value computed by Roslyn each time Source changes.
|
||||
INSERT dbo.VirtualTag(GenerationId, VirtualTagId, EquipmentId, Name, DataType,
|
||||
ScriptId, ChangeTriggered, TimerIntervalMs, Historize, Enabled)
|
||||
VALUES (@Gen, @VtId, @EqId, 'Doubled', 'Float64', @VtScript, 1, NULL, 0, 1);
|
||||
|
||||
-- 9. ScriptedAlarm — Active when Source > 50.
|
||||
INSERT dbo.ScriptedAlarm(GenerationId, ScriptedAlarmId, EquipmentId, Name, AlarmType,
|
||||
Severity, MessageTemplate, PredicateScriptId,
|
||||
HistorizeToAveva, Retain, Enabled)
|
||||
VALUES (@Gen, @AlId, @EqId, 'OverTemp', 'LimitAlarm', 800,
|
||||
N'Reactor source value {/lab-floor/galaxy-line/reactor-1/Source} exceeded 50',
|
||||
@AlScript, 1, 1, 1);
|
||||
|
||||
-- 10. Publish — flip the generation Status. sp_PublishGeneration takes
|
||||
-- concurrency locks + does ExternalIdReservation merging; we drive it via
|
||||
-- EXEC rather than UPDATE so the rest of the publish workflow runs.
|
||||
EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @Gen,
|
||||
@Notes = N'Phase 7 live smoke — task #240';
|
||||
|
||||
COMMIT;
|
||||
|
||||
PRINT '';
|
||||
PRINT 'Phase 7 smoke seed complete.';
|
||||
PRINT ' Cluster: ' + @ClusterId;
|
||||
PRINT ' Node: ' + @NodeId + ' (set Node:NodeId in appsettings.json)';
|
||||
PRINT ' Generation: ' + CONVERT(nvarchar(20), @Gen);
|
||||
PRINT '';
|
||||
PRINT 'Next steps:';
|
||||
PRINT ' 1. Edit src/ZB.MOM.WW.OtOpcUa.Server/appsettings.json:';
|
||||
PRINT ' Node:NodeId = "p7-smoke-node"';
|
||||
PRINT ' Node:ClusterId = "p7-smoke"';
|
||||
PRINT ' 2. Edit the placeholder Galaxy attribute in dbo.Tag.TagConfig above';
|
||||
PRINT ' so it points at a real attribute on this Galaxy — replace';
|
||||
PRINT ' REPLACE_WITH_REAL_GALAXY_ATTRIBUTE with e.g. "Plant1.Reactor1.Temp".';
|
||||
PRINT ' 3. Start the Server in a non-elevated shell so the Galaxy.Host pipe ACL';
|
||||
PRINT ' accepts the connection:';
|
||||
PRINT ' dotnet run --project src/ZB.MOM.WW.OtOpcUa.Server';
|
||||
PRINT ' 4. Validate via Client.CLI per docs/v2/implementation/phase-7-e2e-smoke.md';
|
||||
127
scripts/smoke/seed-s7-smoke.sql
Normal file
127
scripts/smoke/seed-s7-smoke.sql
Normal file
@@ -0,0 +1,127 @@
|
||||
-- S7 e2e smoke seed — closes #212 (umbrella #209).
|
||||
--
|
||||
-- One-cluster seed pointing at the python-snap7 fixture
|
||||
-- (`docker compose -f tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/docker-compose.yml --profile s7_1500 up -d`).
|
||||
-- python-snap7 listens on port 1102 (non-priv); real S7 CPUs listen on 102.
|
||||
-- Publishes one Int16 tag at DB1.DBW0 under `ns=<N>;s=DB1_DBW0` (driver
|
||||
-- sanitises the dot for browse names — see S7Driver.DiscoverAsync).
|
||||
--
|
||||
-- Usage:
|
||||
-- sqlcmd -S "localhost,14330" -d OtOpcUaConfig -U sa -P "OtOpcUaDev_2026!" \
|
||||
-- -i scripts/smoke/seed-s7-smoke.sql
|
||||
--
|
||||
-- After seeding:
|
||||
-- Node:NodeId = "s7-smoke-node"
|
||||
-- Node:ClusterId = "s7-smoke"
|
||||
-- Then start server + run `./scripts/e2e/test-s7.ps1 -BridgeNodeId "ns=2;s=DB1_DBW0" -S7Host "127.0.0.1:1102"`.
|
||||
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET ANSI_PADDING ON;
|
||||
SET ANSI_WARNINGS ON;
|
||||
SET ARITHABORT ON;
|
||||
SET CONCAT_NULL_YIELDS_NULL ON;
|
||||
|
||||
DECLARE @ClusterId nvarchar(64) = 's7-smoke';
|
||||
DECLARE @NodeId nvarchar(64) = 's7-smoke-node';
|
||||
DECLARE @DrvId nvarchar(64) = 's7-smoke-drv';
|
||||
DECLARE @NsId nvarchar(64) = 's7-smoke-ns';
|
||||
DECLARE @AreaId nvarchar(64) = 's7-smoke-area';
|
||||
DECLARE @LineId nvarchar(64) = 's7-smoke-line';
|
||||
DECLARE @EqId nvarchar(64) = 's7-smoke-eq';
|
||||
DECLARE @EqUuid uniqueidentifier = '17BD5A10-17BD-417B-917B-D5A1017BD5A1';
|
||||
DECLARE @TagId nvarchar(64) = 's7-smoke-tag-db1dbw0';
|
||||
|
||||
BEGIN TRAN;
|
||||
|
||||
DELETE FROM dbo.Tag WHERE TagId IN (@TagId);
|
||||
DELETE FROM dbo.Equipment WHERE EquipmentId = @EqId;
|
||||
DELETE FROM dbo.UnsLine WHERE UnsLineId = @LineId;
|
||||
DELETE FROM dbo.UnsArea WHERE UnsAreaId = @AreaId;
|
||||
DELETE FROM dbo.DriverInstance WHERE DriverInstanceId = @DrvId;
|
||||
DELETE FROM dbo.Namespace WHERE NamespaceId = @NsId;
|
||||
DELETE FROM dbo.ConfigGeneration WHERE ClusterId = @ClusterId;
|
||||
DELETE FROM dbo.ClusterNodeCredential WHERE NodeId = @NodeId;
|
||||
DELETE FROM dbo.ClusterNodeGenerationState WHERE NodeId = @NodeId;
|
||||
DELETE FROM dbo.ClusterNode WHERE NodeId = @NodeId;
|
||||
DELETE FROM dbo.ServerCluster WHERE ClusterId = @ClusterId;
|
||||
|
||||
DELETE FROM dbo.ClusterNodeCredential WHERE Kind = 'SqlLogin' AND Value = 'sa';
|
||||
|
||||
INSERT dbo.ServerCluster(ClusterId, Name, Enterprise, Site, NodeCount, RedundancyMode, Enabled, CreatedBy)
|
||||
VALUES (@ClusterId, 'S7 Smoke', 'zb', 'lab', 1, 'None', 1, 's7-smoke');
|
||||
|
||||
INSERT dbo.ClusterNode(NodeId, ClusterId, RedundancyRole, Host, OpcUaPort, DashboardPort,
|
||||
ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
|
||||
VALUES (@NodeId, @ClusterId, 'Primary', 'localhost', 4840, 15050,
|
||||
'urn:OtOpcUa:s7-smoke-node', 200, 1, 's7-smoke');
|
||||
-- Dashboard moved off :5000 (Windows URL-ACL).
|
||||
|
||||
INSERT dbo.ClusterNodeCredential(NodeId, Kind, Value, Enabled, CreatedBy)
|
||||
VALUES (@NodeId, 'SqlLogin', 'sa', 1, 's7-smoke');
|
||||
|
||||
DECLARE @Gen bigint;
|
||||
INSERT dbo.ConfigGeneration(ClusterId, Status, CreatedBy)
|
||||
VALUES (@ClusterId, 'Draft', 's7-smoke');
|
||||
SET @Gen = SCOPE_IDENTITY();
|
||||
|
||||
INSERT dbo.Namespace(GenerationId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled)
|
||||
VALUES (@Gen, @NsId, @ClusterId, 'Equipment', 'urn:s7-smoke:eq', 1);
|
||||
|
||||
INSERT dbo.UnsArea(GenerationId, UnsAreaId, ClusterId, Name)
|
||||
VALUES (@Gen, @AreaId, @ClusterId, 'lab-floor');
|
||||
|
||||
INSERT dbo.UnsLine(GenerationId, UnsLineId, UnsAreaId, Name)
|
||||
VALUES (@Gen, @LineId, @AreaId, 's7-line');
|
||||
|
||||
INSERT dbo.Equipment(GenerationId, EquipmentId, EquipmentUuid, DriverInstanceId, UnsLineId,
|
||||
Name, MachineCode, Enabled)
|
||||
VALUES (@Gen, @EqId, @EqUuid, @DrvId, @LineId, 's7-sim', 's7-001', 1);
|
||||
|
||||
-- S7 DriverInstance — python-snap7 S7-1500 profile, slot 0, port 1102.
|
||||
-- DriverConfig shape mirrors S7DriverConfigDto.
|
||||
INSERT dbo.DriverInstance(GenerationId, DriverInstanceId, ClusterId, NamespaceId,
|
||||
Name, DriverType, DriverConfig, Enabled)
|
||||
VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'snap7-smoke', 'S7', N'{
|
||||
"Host": "127.0.0.1",
|
||||
"Port": 1102,
|
||||
"CpuType": "S71500",
|
||||
"Rack": 0,
|
||||
"Slot": 0,
|
||||
"TimeoutMs": 5000,
|
||||
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000, "ProbeAddress": "MW0" },
|
||||
"Tags": [
|
||||
{
|
||||
"Name": "DB1_DBW0",
|
||||
"Address": "DB1.DBW0",
|
||||
"DataType": "Int16",
|
||||
"Writable": true,
|
||||
"WriteIdempotent": true
|
||||
}
|
||||
]
|
||||
}', 1);
|
||||
|
||||
INSERT dbo.Tag(GenerationId, TagId, DriverInstanceId, EquipmentId, Name, DataType,
|
||||
AccessLevel, TagConfig, WriteIdempotent)
|
||||
VALUES (@Gen, @TagId, @DrvId, @EqId, 'DB1_DBW0', 'Int16', 'ReadWrite',
|
||||
N'{"FullName":"DB1_DBW0","Address":"DB1.DBW0","DataType":"Int16"}', 1);
|
||||
|
||||
EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @Gen,
|
||||
@Notes = N'S7 smoke — task #212';
|
||||
|
||||
COMMIT;
|
||||
|
||||
PRINT '';
|
||||
PRINT 'S7 smoke seed complete.';
|
||||
PRINT ' Cluster: ' + @ClusterId;
|
||||
PRINT ' Node: ' + @NodeId;
|
||||
PRINT ' Generation: ' + CONVERT(nvarchar(20), @Gen);
|
||||
PRINT '';
|
||||
PRINT 'Next steps:';
|
||||
PRINT ' 1. Set src/.../Server/appsettings.json Node:NodeId = "s7-smoke-node"';
|
||||
PRINT ' Node:ClusterId = "s7-smoke"';
|
||||
PRINT ' 2. docker compose -f tests/.../S7.IntegrationTests/Docker/docker-compose.yml --profile s7_1500 up -d';
|
||||
PRINT ' 3. dotnet run --project src/ZB.MOM.WW.OtOpcUa.Server';
|
||||
PRINT ' 4. ./scripts/e2e/test-s7.ps1 -BridgeNodeId "ns=2;s=DB1_DBW0" -S7Host "127.0.0.1:1102"';
|
||||
64
src/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverFactoryRegistry.cs
Normal file
64
src/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverFactoryRegistry.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Process-singleton registry of <see cref="IDriver"/> factories keyed by
|
||||
/// <c>DriverInstance.DriverType</c> string. Each driver project ships a DI
|
||||
/// extension (e.g. <c>services.AddGalaxyProxyDriverFactory()</c>) that registers
|
||||
/// its factory at startup; the bootstrapper looks up the factory by
|
||||
/// <c>DriverInstance.DriverType</c> + invokes it with the row's
|
||||
/// <c>DriverInstanceId</c> + <c>DriverConfig</c> JSON.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Closes the gap surfaced by task #240 live smoke — DriverInstance rows in
|
||||
/// the central config DB had no path to materialise as registered <see cref="IDriver"/>
|
||||
/// instances. The factory registry is the seam.
|
||||
/// </remarks>
|
||||
public sealed class DriverFactoryRegistry
|
||||
{
|
||||
private readonly Dictionary<string, Func<string, string, IDriver>> _factories
|
||||
= new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly object _lock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Register a factory for <paramref name="driverType"/>. Throws if a factory is
|
||||
/// already registered for that type — drivers are singletons by type-name in
|
||||
/// this process.
|
||||
/// </summary>
|
||||
/// <param name="driverType">Matches <c>DriverInstance.DriverType</c>.</param>
|
||||
/// <param name="factory">
|
||||
/// Receives <c>(driverInstanceId, driverConfigJson)</c>; returns a new
|
||||
/// <see cref="IDriver"/>. Must NOT call <see cref="IDriver.InitializeAsync"/>
|
||||
/// itself — the bootstrapper calls it via <see cref="DriverHost.RegisterAsync"/>
|
||||
/// so the host's per-driver retry semantics apply uniformly.
|
||||
/// </param>
|
||||
public void Register(string driverType, Func<string, string, IDriver> factory)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
|
||||
ArgumentNullException.ThrowIfNull(factory);
|
||||
lock (_lock)
|
||||
{
|
||||
if (_factories.ContainsKey(driverType))
|
||||
throw new InvalidOperationException(
|
||||
$"DriverType '{driverType}' factory already registered for this process");
|
||||
_factories[driverType] = factory;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Try to look up the factory for <paramref name="driverType"/>. Returns null
|
||||
/// if no driver assembly registered one — bootstrapper logs + skips so a
|
||||
/// missing-assembly deployment doesn't take down the whole server.
|
||||
/// </summary>
|
||||
public Func<string, string, IDriver>? TryGet(string driverType)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
|
||||
lock (_lock) return _factories.GetValueOrDefault(driverType);
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<string> RegisteredTypes
|
||||
{
|
||||
get { lock (_lock) return [.. _factories.Keys]; }
|
||||
}
|
||||
}
|
||||
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,151 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Static factory registration helper for <see cref="AbCipDriver"/>. Server's Program.cs
|
||||
/// calls <see cref="Register"/> once at startup; the bootstrapper (task #248) then
|
||||
/// materialises AB CIP DriverInstance rows from the central config DB into live driver
|
||||
/// instances. Mirrors <c>GalaxyProxyDriverFactoryExtensions</c>.
|
||||
/// </summary>
|
||||
public static class AbCipDriverFactoryExtensions
|
||||
{
|
||||
public const string DriverTypeName = "AbCip";
|
||||
|
||||
public static void Register(DriverFactoryRegistry registry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
registry.Register(DriverTypeName, CreateInstance);
|
||||
}
|
||||
|
||||
internal static AbCipDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||
|
||||
var dto = JsonSerializer.Deserialize<AbCipDriverConfigDto>(driverConfigJson, JsonOptions)
|
||||
?? throw new InvalidOperationException(
|
||||
$"AB CIP driver config for '{driverInstanceId}' deserialised to null");
|
||||
|
||||
var options = new AbCipDriverOptions
|
||||
{
|
||||
Devices = dto.Devices is { Count: > 0 }
|
||||
? [.. dto.Devices.Select(d => new AbCipDeviceOptions(
|
||||
HostAddress: d.HostAddress ?? throw new InvalidOperationException(
|
||||
$"AB CIP config for '{driverInstanceId}' has a device missing HostAddress"),
|
||||
PlcFamily: ParseEnum<AbCipPlcFamily>(d.PlcFamily, "device", driverInstanceId, "PlcFamily",
|
||||
fallback: AbCipPlcFamily.ControlLogix),
|
||||
DeviceName: d.DeviceName))]
|
||||
: [],
|
||||
Tags = dto.Tags is { Count: > 0 }
|
||||
? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))]
|
||||
: [],
|
||||
Probe = new AbCipProbeOptions
|
||||
{
|
||||
Enabled = dto.Probe?.Enabled ?? true,
|
||||
Interval = TimeSpan.FromMilliseconds(dto.Probe?.IntervalMs ?? 5_000),
|
||||
Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000),
|
||||
ProbeTagPath = dto.Probe?.ProbeTagPath,
|
||||
},
|
||||
Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 2_000),
|
||||
EnableControllerBrowse = dto.EnableControllerBrowse ?? false,
|
||||
EnableAlarmProjection = dto.EnableAlarmProjection ?? false,
|
||||
AlarmPollInterval = TimeSpan.FromMilliseconds(dto.AlarmPollIntervalMs ?? 1_000),
|
||||
};
|
||||
|
||||
return new AbCipDriver(options, driverInstanceId);
|
||||
}
|
||||
|
||||
private static AbCipTagDefinition BuildTag(AbCipTagDto t, string driverInstanceId) =>
|
||||
new(
|
||||
Name: t.Name ?? throw new InvalidOperationException(
|
||||
$"AB CIP config for '{driverInstanceId}' has a tag missing Name"),
|
||||
DeviceHostAddress: t.DeviceHostAddress ?? throw new InvalidOperationException(
|
||||
$"AB CIP tag '{t.Name}' in '{driverInstanceId}' missing DeviceHostAddress"),
|
||||
TagPath: t.TagPath ?? throw new InvalidOperationException(
|
||||
$"AB CIP tag '{t.Name}' in '{driverInstanceId}' missing TagPath"),
|
||||
DataType: ParseEnum<AbCipDataType>(t.DataType, t.Name, driverInstanceId, "DataType"),
|
||||
Writable: t.Writable ?? true,
|
||||
WriteIdempotent: t.WriteIdempotent ?? false,
|
||||
Members: t.Members is { Count: > 0 }
|
||||
? [.. t.Members.Select(m => new AbCipStructureMember(
|
||||
Name: m.Name ?? throw new InvalidOperationException(
|
||||
$"AB CIP tag '{t.Name}' in '{driverInstanceId}' has a member missing Name"),
|
||||
DataType: ParseEnum<AbCipDataType>(m.DataType, t.Name, driverInstanceId,
|
||||
$"Members[{m.Name}].DataType"),
|
||||
Writable: m.Writable ?? true,
|
||||
WriteIdempotent: m.WriteIdempotent ?? false))]
|
||||
: null,
|
||||
SafetyTag: t.SafetyTag ?? false);
|
||||
|
||||
private static T ParseEnum<T>(string? raw, string? tagName, string driverInstanceId, string field,
|
||||
T? fallback = null) where T : struct, Enum
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
if (fallback.HasValue) return fallback.Value;
|
||||
throw new InvalidOperationException(
|
||||
$"AB CIP tag '{tagName ?? "<unnamed>"}' in '{driverInstanceId}' missing {field}");
|
||||
}
|
||||
return Enum.TryParse<T>(raw, ignoreCase: true, out var v)
|
||||
? v
|
||||
: throw new InvalidOperationException(
|
||||
$"AB CIP tag '{tagName}' has unknown {field} '{raw}'. " +
|
||||
$"Expected one of {string.Join(", ", Enum.GetNames<T>())}");
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
};
|
||||
|
||||
internal sealed class AbCipDriverConfigDto
|
||||
{
|
||||
public int? TimeoutMs { get; init; }
|
||||
public bool? EnableControllerBrowse { get; init; }
|
||||
public bool? EnableAlarmProjection { get; init; }
|
||||
public int? AlarmPollIntervalMs { get; init; }
|
||||
public List<AbCipDeviceDto>? Devices { get; init; }
|
||||
public List<AbCipTagDto>? Tags { get; init; }
|
||||
public AbCipProbeDto? Probe { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AbCipDeviceDto
|
||||
{
|
||||
public string? HostAddress { get; init; }
|
||||
public string? PlcFamily { get; init; }
|
||||
public string? DeviceName { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AbCipTagDto
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public string? DeviceHostAddress { get; init; }
|
||||
public string? TagPath { get; init; }
|
||||
public string? DataType { get; init; }
|
||||
public bool? Writable { get; init; }
|
||||
public bool? WriteIdempotent { get; init; }
|
||||
public List<AbCipMemberDto>? Members { get; init; }
|
||||
public bool? SafetyTag { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AbCipMemberDto
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public string? DataType { get; init; }
|
||||
public bool? Writable { get; init; }
|
||||
public bool? WriteIdempotent { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AbCipProbeDto
|
||||
{
|
||||
public bool? Enabled { get; init; }
|
||||
public int? IntervalMs { get; init; }
|
||||
public int? TimeoutMs { get; init; }
|
||||
public string? ProbeTagPath { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<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"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// Static factory registration helper for <see cref="AbLegacyDriver"/>. Server's Program.cs
|
||||
/// calls <see cref="Register"/> once at startup; the bootstrapper (task #248) then
|
||||
/// materialises AB Legacy DriverInstance rows from the central config DB into live
|
||||
/// driver instances. Mirrors <c>GalaxyProxyDriverFactoryExtensions</c>.
|
||||
/// </summary>
|
||||
public static class AbLegacyDriverFactoryExtensions
|
||||
{
|
||||
public const string DriverTypeName = "AbLegacy";
|
||||
|
||||
public static void Register(DriverFactoryRegistry registry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
registry.Register(DriverTypeName, CreateInstance);
|
||||
}
|
||||
|
||||
internal static AbLegacyDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||
|
||||
var dto = JsonSerializer.Deserialize<AbLegacyDriverConfigDto>(driverConfigJson, JsonOptions)
|
||||
?? throw new InvalidOperationException(
|
||||
$"AB Legacy driver config for '{driverInstanceId}' deserialised to null");
|
||||
|
||||
var options = new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = dto.Devices is { Count: > 0 }
|
||||
? [.. dto.Devices.Select(d => new AbLegacyDeviceOptions(
|
||||
HostAddress: d.HostAddress ?? throw new InvalidOperationException(
|
||||
$"AB Legacy config for '{driverInstanceId}' has a device missing HostAddress"),
|
||||
PlcFamily: ParseEnum<AbLegacyPlcFamily>(d.PlcFamily, driverInstanceId, "PlcFamily",
|
||||
fallback: AbLegacyPlcFamily.Slc500),
|
||||
DeviceName: d.DeviceName))]
|
||||
: [],
|
||||
Tags = dto.Tags is { Count: > 0 }
|
||||
? [.. dto.Tags.Select(t => new AbLegacyTagDefinition(
|
||||
Name: t.Name ?? throw new InvalidOperationException(
|
||||
$"AB Legacy config for '{driverInstanceId}' has a tag missing Name"),
|
||||
DeviceHostAddress: t.DeviceHostAddress ?? throw new InvalidOperationException(
|
||||
$"AB Legacy tag '{t.Name}' in '{driverInstanceId}' missing DeviceHostAddress"),
|
||||
Address: t.Address ?? throw new InvalidOperationException(
|
||||
$"AB Legacy tag '{t.Name}' in '{driverInstanceId}' missing Address"),
|
||||
DataType: ParseEnum<AbLegacyDataType>(t.DataType, driverInstanceId, "DataType",
|
||||
tagName: t.Name),
|
||||
Writable: t.Writable ?? true,
|
||||
WriteIdempotent: t.WriteIdempotent ?? false))]
|
||||
: [],
|
||||
Probe = new AbLegacyProbeOptions
|
||||
{
|
||||
Enabled = dto.Probe?.Enabled ?? true,
|
||||
Interval = TimeSpan.FromMilliseconds(dto.Probe?.IntervalMs ?? 5_000),
|
||||
Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000),
|
||||
ProbeAddress = dto.Probe?.ProbeAddress ?? "S:0",
|
||||
},
|
||||
Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 2_000),
|
||||
};
|
||||
|
||||
return new AbLegacyDriver(options, driverInstanceId);
|
||||
}
|
||||
|
||||
private static T ParseEnum<T>(string? raw, string driverInstanceId, string field,
|
||||
string? tagName = null, T? fallback = null) where T : struct, Enum
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
if (fallback.HasValue) return fallback.Value;
|
||||
throw new InvalidOperationException(
|
||||
$"AB Legacy {(tagName is null ? "config" : $"tag '{tagName}'")} in '{driverInstanceId}' missing {field}");
|
||||
}
|
||||
return Enum.TryParse<T>(raw, ignoreCase: true, out var v)
|
||||
? v
|
||||
: throw new InvalidOperationException(
|
||||
$"AB Legacy {(tagName is null ? "config" : $"tag '{tagName}'")} has unknown {field} '{raw}'. " +
|
||||
$"Expected one of {string.Join(", ", Enum.GetNames<T>())}");
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
};
|
||||
|
||||
internal sealed class AbLegacyDriverConfigDto
|
||||
{
|
||||
public int? TimeoutMs { get; init; }
|
||||
public List<AbLegacyDeviceDto>? Devices { get; init; }
|
||||
public List<AbLegacyTagDto>? Tags { get; init; }
|
||||
public AbLegacyProbeDto? Probe { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AbLegacyDeviceDto
|
||||
{
|
||||
public string? HostAddress { get; init; }
|
||||
public string? PlcFamily { get; init; }
|
||||
public string? DeviceName { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AbLegacyTagDto
|
||||
{
|
||||
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 AbLegacyProbeDto
|
||||
{
|
||||
public bool? Enabled { get; init; }
|
||||
public int? IntervalMs { get; init; }
|
||||
public int? TimeoutMs { get; init; }
|
||||
public string? ProbeAddress { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<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"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
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,57 @@
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Probes a Fanuc CNC: opens a FOCAS session + reads one PMC address. No public
|
||||
/// simulator exists — this command only produces meaningful results against a real
|
||||
/// CNC with Fwlib32.dll present. Against a dev box it surfaces
|
||||
/// <c>BadCommunicationError</c> (DLL missing) which is still a useful signal that
|
||||
/// the CLI wire-up is correct.
|
||||
/// </summary>
|
||||
[Command("probe", Description = "Verify the CNC is reachable + a sample FOCAS read succeeds.")]
|
||||
public sealed class ProbeCommand : FocasCommandBase
|
||||
{
|
||||
[CommandOption("address", 'a', Description =
|
||||
"FOCAS address to probe (default R100 — PMC R-file register 100).")]
|
||||
public string Address { get; init; } = "R100";
|
||||
|
||||
[CommandOption("type", Description = "Data type (default Int16).")]
|
||||
public FocasDataType DataType { get; init; } = FocasDataType.Int16;
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
|
||||
var probeTag = new FocasTagDefinition(
|
||||
Name: "__probe",
|
||||
DeviceHostAddress: HostAddress,
|
||||
Address: Address,
|
||||
DataType: DataType,
|
||||
Writable: false);
|
||||
var options = BuildOptions([probeTag]);
|
||||
|
||||
await using var driver = new FocasDriver(options, DriverInstanceId);
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
var snapshot = await driver.ReadAsync(["__probe"], ct);
|
||||
var health = driver.GetHealth();
|
||||
|
||||
await console.Output.WriteLineAsync($"CNC: {CncHost}:{CncPort}");
|
||||
await console.Output.WriteLineAsync($"Series: {Series}");
|
||||
await console.Output.WriteLineAsync($"Health: {health.State}");
|
||||
if (health.LastError is { } err)
|
||||
await console.Output.WriteLineAsync($"Last error: {err}");
|
||||
await console.Output.WriteLineAsync();
|
||||
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Read one FOCAS address (PMC R/G/F file, parameter, macro, axis register).
|
||||
/// </summary>
|
||||
[Command("read", Description = "Read a single FOCAS address.")]
|
||||
public sealed class ReadCommand : FocasCommandBase
|
||||
{
|
||||
[CommandOption("address", 'a', Description =
|
||||
"FOCAS address. Examples: R100 (PMC R-file word); X0.0 (PMC X-bit); " +
|
||||
"PARAM:1815/0 (parameter 1815, axis 0); MACRO:500 (macro variable 500).",
|
||||
IsRequired = true)]
|
||||
public string Address { get; init; } = default!;
|
||||
|
||||
[CommandOption("type", 't', Description =
|
||||
"Bit / Byte / Int16 / Int32 / Float32 / Float64 / String (default Int16).")]
|
||||
public FocasDataType DataType { get; init; } = FocasDataType.Int16;
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
|
||||
var tagName = SynthesiseTagName(Address, DataType);
|
||||
var tag = new FocasTagDefinition(
|
||||
Name: tagName,
|
||||
DeviceHostAddress: HostAddress,
|
||||
Address: Address,
|
||||
DataType: DataType,
|
||||
Writable: false);
|
||||
var options = BuildOptions([tag]);
|
||||
|
||||
await using var driver = new FocasDriver(options, DriverInstanceId);
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
var snapshot = await driver.ReadAsync([tagName], ct);
|
||||
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string SynthesiseTagName(string address, FocasDataType type)
|
||||
=> $"{address}:{type}";
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Watch a FOCAS address via polled subscription until Ctrl+C. FOCAS has no push
|
||||
/// model; <c>PollGroupEngine</c> handles the tick loop.
|
||||
/// </summary>
|
||||
[Command("subscribe", Description = "Watch a FOCAS address via polled subscription until Ctrl+C.")]
|
||||
public sealed class SubscribeCommand : FocasCommandBase
|
||||
{
|
||||
[CommandOption("address", 'a', Description = "FOCAS address — same format as `read`.", IsRequired = true)]
|
||||
public string Address { get; init; } = default!;
|
||||
|
||||
[CommandOption("type", 't', Description =
|
||||
"Bit / Byte / Int16 / Int32 / Float32 / Float64 / String (default Int16).")]
|
||||
public FocasDataType DataType { get; init; } = FocasDataType.Int16;
|
||||
|
||||
[CommandOption("interval-ms", 'i', Description = "Publishing interval ms (default 1000).")]
|
||||
public int IntervalMs { get; init; } = 1000;
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
|
||||
var tagName = ReadCommand.SynthesiseTagName(Address, DataType);
|
||||
var tag = new FocasTagDefinition(
|
||||
Name: tagName,
|
||||
DeviceHostAddress: HostAddress,
|
||||
Address: Address,
|
||||
DataType: DataType,
|
||||
Writable: false);
|
||||
var options = BuildOptions([tag]);
|
||||
|
||||
await using var driver = new FocasDriver(options, DriverInstanceId);
|
||||
ISubscriptionHandle? handle = null;
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
|
||||
driver.OnDataChange += (_, e) =>
|
||||
{
|
||||
var line = $"[{DateTime.UtcNow:HH:mm:ss.fff}] " +
|
||||
$"{e.FullReference} = {SnapshotFormatter.FormatValue(e.Snapshot.Value)} " +
|
||||
$"({SnapshotFormatter.FormatStatus(e.Snapshot.StatusCode)})";
|
||||
console.Output.WriteLine(line);
|
||||
};
|
||||
|
||||
handle = await driver.SubscribeAsync([tagName], TimeSpan.FromMilliseconds(IntervalMs), ct);
|
||||
|
||||
await console.Output.WriteLineAsync(
|
||||
$"Subscribed to {Address} @ {IntervalMs}ms. Ctrl+C to stop.");
|
||||
try
|
||||
{
|
||||
await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected on Ctrl+C.
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (handle is not null)
|
||||
{
|
||||
try { await driver.UnsubscribeAsync(handle, CancellationToken.None); }
|
||||
catch { /* teardown best-effort */ }
|
||||
}
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Globalization;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Write one value to a FOCAS address. PMC G/R writes are real — be careful
|
||||
/// which file you hit on a running machine. Parameter writes may require the
|
||||
/// CNC to be in MDI mode + the parameter-write switch enabled.
|
||||
/// </summary>
|
||||
[Command("write", Description = "Write a single FOCAS address.")]
|
||||
public sealed class WriteCommand : FocasCommandBase
|
||||
{
|
||||
[CommandOption("address", 'a', Description = "FOCAS address — same format as `read`.", IsRequired = true)]
|
||||
public string Address { get; init; } = default!;
|
||||
|
||||
[CommandOption("type", 't', Description =
|
||||
"Bit / Byte / Int16 / Int32 / Float32 / Float64 / String (default Int16).")]
|
||||
public FocasDataType DataType { get; init; } = FocasDataType.Int16;
|
||||
|
||||
[CommandOption("value", 'v', Description =
|
||||
"Value to write. Parsed per --type (booleans accept true/false/1/0).",
|
||||
IsRequired = true)]
|
||||
public string Value { get; init; } = default!;
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
|
||||
var tagName = ReadCommand.SynthesiseTagName(Address, DataType);
|
||||
var tag = new FocasTagDefinition(
|
||||
Name: tagName,
|
||||
DeviceHostAddress: HostAddress,
|
||||
Address: Address,
|
||||
DataType: DataType,
|
||||
Writable: true);
|
||||
var options = BuildOptions([tag]);
|
||||
|
||||
var parsed = ParseValue(Value, DataType);
|
||||
|
||||
await using var driver = new FocasDriver(options, DriverInstanceId);
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
|
||||
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(Address, results[0]));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
internal static object ParseValue(string raw, FocasDataType type) => type switch
|
||||
{
|
||||
FocasDataType.Bit => ParseBool(raw),
|
||||
FocasDataType.Byte => sbyte.Parse(raw, CultureInfo.InvariantCulture),
|
||||
FocasDataType.Int16 => short.Parse(raw, CultureInfo.InvariantCulture),
|
||||
FocasDataType.Int32 => int.Parse(raw, CultureInfo.InvariantCulture),
|
||||
FocasDataType.Float32 => float.Parse(raw, CultureInfo.InvariantCulture),
|
||||
FocasDataType.Float64 => double.Parse(raw, CultureInfo.InvariantCulture),
|
||||
FocasDataType.String => raw,
|
||||
_ => throw new CliFx.Exceptions.CommandException($"Unsupported DataType '{type}' for write."),
|
||||
};
|
||||
|
||||
private static bool ParseBool(string raw) => raw.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"1" or "true" or "on" or "yes" => true,
|
||||
"0" or "false" or "off" or "no" => false,
|
||||
_ => throw new CliFx.Exceptions.CommandException(
|
||||
$"Boolean value '{raw}' is not recognised. Use true/false, 1/0, on/off, or yes/no."),
|
||||
};
|
||||
}
|
||||
58
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs
Normal file
58
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using CliFx.Attributes;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// Base for every FOCAS CLI command. Carries the CNC endpoint options
|
||||
/// (host / port / series) + exposes <see cref="BuildOptions"/> so each command
|
||||
/// can synthesise a <see cref="FocasDriverOptions"/> with one device + one tag.
|
||||
/// </summary>
|
||||
public abstract class FocasCommandBase : DriverCommandBase
|
||||
{
|
||||
[CommandOption("cnc-host", 'h', Description =
|
||||
"CNC IP address or hostname. FOCAS-over-EIP listens on port 8193 by default.",
|
||||
IsRequired = true)]
|
||||
public string CncHost { get; init; } = default!;
|
||||
|
||||
[CommandOption("cnc-port", 'p', Description = "FOCAS TCP port (default 8193).")]
|
||||
public int CncPort { get; init; } = 8193;
|
||||
|
||||
[CommandOption("series", 's', Description =
|
||||
"CNC series: Unknown / Zero_i_D / Zero_i_F / Zero_i_MF / Zero_i_TF / Sixteen_i / " +
|
||||
"Thirty_i / ThirtyOne_i / ThirtyTwo_i / PowerMotion_i (default Unknown).")]
|
||||
public FocasCncSeries Series { get; init; } = FocasCncSeries.Unknown;
|
||||
|
||||
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 2000).")]
|
||||
public int TimeoutMs { get; init; } = 2000;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override TimeSpan Timeout
|
||||
{
|
||||
get => TimeSpan.FromMilliseconds(TimeoutMs);
|
||||
init { /* driven by TimeoutMs */ }
|
||||
}
|
||||
|
||||
/// <summary>Canonical FOCAS host-address string, shape <c>focas://host:port</c>.</summary>
|
||||
protected string HostAddress => $"focas://{CncHost}:{CncPort}";
|
||||
|
||||
/// <summary>
|
||||
/// Build a <see cref="FocasDriverOptions"/> with the CNC target this base collected
|
||||
/// + the tag list a subclass supplies. Probe disabled; the default
|
||||
/// <see cref="FwlibFocasClientFactory"/> attempts <c>Fwlib32.dll</c> P/Invoke, which
|
||||
/// throws <see cref="DllNotFoundException"/> at first call when the DLL is absent —
|
||||
/// surfaced through the driver as <c>BadCommunicationError</c>.
|
||||
/// </summary>
|
||||
protected FocasDriverOptions BuildOptions(IReadOnlyList<FocasTagDefinition> tags) => new()
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(
|
||||
HostAddress: HostAddress,
|
||||
DeviceName: $"cli-{CncHost}:{CncPort}",
|
||||
Series: Series)],
|
||||
Tags = tags,
|
||||
Timeout = Timeout,
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
};
|
||||
|
||||
protected string DriverInstanceId => $"focas-cli-{CncHost}:{CncPort}";
|
||||
}
|
||||
12
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Program.cs
Normal file
12
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Program.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using CliFx;
|
||||
|
||||
return await new CliApplicationBuilder()
|
||||
.AddCommandsFromThisAssembly()
|
||||
.SetExecutableName("otopcua-focas-cli")
|
||||
.SetDescription(
|
||||
"OtOpcUa FOCAS test-client — ad-hoc probe + PMC/param/macro reads/writes + polled " +
|
||||
"subscriptions against Fanuc CNCs via the FOCAS/2 protocol. Requires a real CNC + a " +
|
||||
"licensed Fwlib32.dll on PATH (or next to the executable) — no public simulator " +
|
||||
"exists. Addresses use FocasAddressParser syntax: R100, X0.0, PARAM:1815/0, MACRO:500.")
|
||||
.Build()
|
||||
.RunAsync(args);
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli</RootNamespace>
|
||||
<AssemblyName>otopcua-focas-cli</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CliFx" Version="2.3.6"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Cli.Common\ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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>
|
||||
<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"/>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy;
|
||||
|
||||
/// <summary>
|
||||
/// Static factory registration helper for <see cref="GalaxyProxyDriver"/>. Server's
|
||||
/// Program.cs calls <see cref="Register"/> once at startup; the bootstrapper (task #248)
|
||||
/// then materialises Galaxy DriverInstance rows from the central config DB into live
|
||||
/// driver instances. No dependency on Microsoft.Extensions.DependencyInjection so the
|
||||
/// driver project stays free of DI machinery.
|
||||
/// </summary>
|
||||
public static class GalaxyProxyDriverFactoryExtensions
|
||||
{
|
||||
public const string DriverTypeName = "Galaxy";
|
||||
|
||||
/// <summary>
|
||||
/// Register the Galaxy driver factory in the supplied <see cref="DriverFactoryRegistry"/>.
|
||||
/// Throws if 'Galaxy' is already registered — single-instance per process.
|
||||
/// </summary>
|
||||
public static void Register(DriverFactoryRegistry registry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
registry.Register(DriverTypeName, CreateInstance);
|
||||
}
|
||||
|
||||
internal static GalaxyProxyDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||
|
||||
// DriverConfig column is a JSON object that mirrors GalaxyProxyOptions.
|
||||
// Required: PipeName, SharedSecret. Optional: ConnectTimeoutMs (defaults to 10s).
|
||||
// The DriverInstanceId from the row wins over any value in the JSON — the row
|
||||
// is the authoritative identity per the schema's UX_DriverInstance_Generation_LogicalId.
|
||||
using var doc = JsonDocument.Parse(driverConfigJson);
|
||||
var root = doc.RootElement;
|
||||
|
||||
string pipeName = root.TryGetProperty("PipeName", out var p) && p.ValueKind == JsonValueKind.String
|
||||
? p.GetString()!
|
||||
: throw new InvalidOperationException(
|
||||
$"GalaxyProxyDriver config for '{driverInstanceId}' missing required PipeName");
|
||||
string sharedSecret = root.TryGetProperty("SharedSecret", out var s) && s.ValueKind == JsonValueKind.String
|
||||
? s.GetString()!
|
||||
: throw new InvalidOperationException(
|
||||
$"GalaxyProxyDriver config for '{driverInstanceId}' missing required SharedSecret");
|
||||
var connectTimeout = root.TryGetProperty("ConnectTimeoutMs", out var t) && t.ValueKind == JsonValueKind.Number
|
||||
? TimeSpan.FromMilliseconds(t.GetInt32())
|
||||
: TimeSpan.FromSeconds(10);
|
||||
|
||||
return new GalaxyProxyDriver(new GalaxyProxyOptions
|
||||
{
|
||||
DriverInstanceId = driverInstanceId,
|
||||
PipeName = pipeName,
|
||||
SharedSecret = sharedSecret,
|
||||
ConnectTimeout = connectTimeout,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<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.Galaxy.Shared\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
|
||||
</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>
|
||||
@@ -0,0 +1,132 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Static factory registration helper for <see cref="ModbusDriver"/>. Server's Program.cs
|
||||
/// calls <see cref="Register"/> once at startup; the bootstrapper (task #248) then
|
||||
/// materialises Modbus DriverInstance rows from the central config DB into live driver
|
||||
/// instances. Mirrors <c>GalaxyProxyDriverFactoryExtensions</c> / <c>FocasDriverFactoryExtensions</c>.
|
||||
/// </summary>
|
||||
public static class ModbusDriverFactoryExtensions
|
||||
{
|
||||
public const string DriverTypeName = "Modbus";
|
||||
|
||||
public static void Register(DriverFactoryRegistry registry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
registry.Register(DriverTypeName, CreateInstance);
|
||||
}
|
||||
|
||||
internal static ModbusDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||
|
||||
var dto = JsonSerializer.Deserialize<ModbusDriverConfigDto>(driverConfigJson, JsonOptions)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Modbus driver config for '{driverInstanceId}' deserialised to null");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(dto.Host))
|
||||
throw new InvalidOperationException(
|
||||
$"Modbus driver config for '{driverInstanceId}' missing required Host");
|
||||
|
||||
var options = new ModbusDriverOptions
|
||||
{
|
||||
Host = dto.Host!,
|
||||
Port = dto.Port ?? 502,
|
||||
UnitId = dto.UnitId ?? 1,
|
||||
Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 2_000),
|
||||
MaxRegistersPerRead = dto.MaxRegistersPerRead ?? 125,
|
||||
MaxRegistersPerWrite = dto.MaxRegistersPerWrite ?? 123,
|
||||
AutoReconnect = dto.AutoReconnect ?? true,
|
||||
Tags = dto.Tags is { Count: > 0 }
|
||||
? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))]
|
||||
: [],
|
||||
Probe = new ModbusProbeOptions
|
||||
{
|
||||
Enabled = dto.Probe?.Enabled ?? true,
|
||||
Interval = TimeSpan.FromMilliseconds(dto.Probe?.IntervalMs ?? 5_000),
|
||||
Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000),
|
||||
ProbeAddress = dto.Probe?.ProbeAddress ?? 0,
|
||||
},
|
||||
};
|
||||
|
||||
return new ModbusDriver(options, driverInstanceId);
|
||||
}
|
||||
|
||||
private static ModbusTagDefinition BuildTag(ModbusTagDto t, string driverInstanceId) =>
|
||||
new(
|
||||
Name: t.Name ?? throw new InvalidOperationException(
|
||||
$"Modbus config for '{driverInstanceId}' has a tag missing Name"),
|
||||
Region: ParseEnum<ModbusRegion>(t.Region, t.Name, driverInstanceId, "Region"),
|
||||
Address: t.Address ?? throw new InvalidOperationException(
|
||||
$"Modbus tag '{t.Name}' in '{driverInstanceId}' missing Address"),
|
||||
DataType: ParseEnum<ModbusDataType>(t.DataType, t.Name, driverInstanceId, "DataType"),
|
||||
Writable: t.Writable ?? true,
|
||||
ByteOrder: t.ByteOrder is null
|
||||
? ModbusByteOrder.BigEndian
|
||||
: ParseEnum<ModbusByteOrder>(t.ByteOrder, t.Name, driverInstanceId, "ByteOrder"),
|
||||
BitIndex: t.BitIndex ?? 0,
|
||||
StringLength: t.StringLength ?? 0,
|
||||
StringByteOrder: t.StringByteOrder is null
|
||||
? ModbusStringByteOrder.HighByteFirst
|
||||
: ParseEnum<ModbusStringByteOrder>(t.StringByteOrder, t.Name, driverInstanceId, "StringByteOrder"),
|
||||
WriteIdempotent: t.WriteIdempotent ?? false);
|
||||
|
||||
private static T ParseEnum<T>(string? raw, string? tagName, string driverInstanceId, string field) where T : struct, Enum
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
throw new InvalidOperationException(
|
||||
$"Modbus tag '{tagName ?? "<unnamed>"}' in '{driverInstanceId}' missing {field}");
|
||||
return Enum.TryParse<T>(raw, ignoreCase: true, out var v)
|
||||
? v
|
||||
: throw new InvalidOperationException(
|
||||
$"Modbus tag '{tagName}' has unknown {field} '{raw}'. " +
|
||||
$"Expected one of {string.Join(", ", Enum.GetNames<T>())}");
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
};
|
||||
|
||||
internal sealed class ModbusDriverConfigDto
|
||||
{
|
||||
public string? Host { get; init; }
|
||||
public int? Port { get; init; }
|
||||
public byte? UnitId { get; init; }
|
||||
public int? TimeoutMs { get; init; }
|
||||
public ushort? MaxRegistersPerRead { get; init; }
|
||||
public ushort? MaxRegistersPerWrite { get; init; }
|
||||
public bool? AutoReconnect { get; init; }
|
||||
public List<ModbusTagDto>? Tags { get; init; }
|
||||
public ModbusProbeDto? Probe { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class ModbusTagDto
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public string? Region { get; init; }
|
||||
public ushort? Address { get; init; }
|
||||
public string? DataType { get; init; }
|
||||
public bool? Writable { get; init; }
|
||||
public string? ByteOrder { get; init; }
|
||||
public byte? BitIndex { get; init; }
|
||||
public ushort? StringLength { get; init; }
|
||||
public string? StringByteOrder { get; init; }
|
||||
public bool? WriteIdempotent { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class ModbusProbeDto
|
||||
{
|
||||
public bool? Enabled { get; init; }
|
||||
public int? IntervalMs { get; init; }
|
||||
public int? TimeoutMs { get; init; }
|
||||
public ushort? ProbeAddress { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<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"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
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>
|
||||
125
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverFactoryExtensions.cs
Normal file
125
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverFactoryExtensions.cs
Normal file
@@ -0,0 +1,125 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using S7NetCpuType = global::S7.Net.CpuType;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
|
||||
/// <summary>
|
||||
/// Static factory registration helper for <see cref="S7Driver"/>. Server's Program.cs
|
||||
/// calls <see cref="Register"/> once at startup; the bootstrapper (task #248) then
|
||||
/// materialises S7 DriverInstance rows from the central config DB into live driver
|
||||
/// instances. Mirrors <c>GalaxyProxyDriverFactoryExtensions</c>.
|
||||
/// </summary>
|
||||
public static class S7DriverFactoryExtensions
|
||||
{
|
||||
public const string DriverTypeName = "S7";
|
||||
|
||||
public static void Register(DriverFactoryRegistry registry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
registry.Register(DriverTypeName, CreateInstance);
|
||||
}
|
||||
|
||||
internal static S7Driver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||
|
||||
var dto = JsonSerializer.Deserialize<S7DriverConfigDto>(driverConfigJson, JsonOptions)
|
||||
?? throw new InvalidOperationException(
|
||||
$"S7 driver config for '{driverInstanceId}' deserialised to null");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(dto.Host))
|
||||
throw new InvalidOperationException(
|
||||
$"S7 driver config for '{driverInstanceId}' missing required Host");
|
||||
|
||||
var options = new S7DriverOptions
|
||||
{
|
||||
Host = dto.Host!,
|
||||
Port = dto.Port ?? 102,
|
||||
CpuType = ParseEnum<S7NetCpuType>(dto.CpuType, driverInstanceId, "CpuType",
|
||||
fallback: S7NetCpuType.S71500),
|
||||
Rack = dto.Rack ?? 0,
|
||||
Slot = dto.Slot ?? 0,
|
||||
Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 5_000),
|
||||
Tags = dto.Tags is { Count: > 0 }
|
||||
? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))]
|
||||
: [],
|
||||
Probe = new S7ProbeOptions
|
||||
{
|
||||
Enabled = dto.Probe?.Enabled ?? true,
|
||||
Interval = TimeSpan.FromMilliseconds(dto.Probe?.IntervalMs ?? 5_000),
|
||||
Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000),
|
||||
ProbeAddress = dto.Probe?.ProbeAddress ?? "MW0",
|
||||
},
|
||||
};
|
||||
|
||||
return new S7Driver(options, driverInstanceId);
|
||||
}
|
||||
|
||||
private static S7TagDefinition BuildTag(S7TagDto t, string driverInstanceId) =>
|
||||
new(
|
||||
Name: t.Name ?? throw new InvalidOperationException(
|
||||
$"S7 config for '{driverInstanceId}' has a tag missing Name"),
|
||||
Address: t.Address ?? throw new InvalidOperationException(
|
||||
$"S7 tag '{t.Name}' in '{driverInstanceId}' missing Address"),
|
||||
DataType: ParseEnum<S7DataType>(t.DataType, driverInstanceId, "DataType",
|
||||
tagName: t.Name),
|
||||
Writable: t.Writable ?? true,
|
||||
StringLength: t.StringLength ?? 254,
|
||||
WriteIdempotent: t.WriteIdempotent ?? false);
|
||||
|
||||
private static T ParseEnum<T>(string? raw, string driverInstanceId, string field,
|
||||
string? tagName = null, T? fallback = null) where T : struct, Enum
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
if (fallback.HasValue) return fallback.Value;
|
||||
throw new InvalidOperationException(
|
||||
$"S7 tag '{tagName ?? "<unnamed>"}' in '{driverInstanceId}' missing {field}");
|
||||
}
|
||||
return Enum.TryParse<T>(raw, ignoreCase: true, out var v)
|
||||
? v
|
||||
: throw new InvalidOperationException(
|
||||
$"S7 {(tagName is null ? "config" : $"tag '{tagName}'")} has unknown {field} '{raw}'. " +
|
||||
$"Expected one of {string.Join(", ", Enum.GetNames<T>())}");
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
};
|
||||
|
||||
internal sealed class S7DriverConfigDto
|
||||
{
|
||||
public string? Host { get; init; }
|
||||
public int? Port { get; init; }
|
||||
public string? CpuType { get; init; }
|
||||
public short? Rack { get; init; }
|
||||
public short? Slot { get; init; }
|
||||
public int? TimeoutMs { get; init; }
|
||||
public List<S7TagDto>? Tags { get; init; }
|
||||
public S7ProbeDto? Probe { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class S7TagDto
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public string? Address { get; init; }
|
||||
public string? DataType { get; init; }
|
||||
public bool? Writable { get; init; }
|
||||
public int? StringLength { get; init; }
|
||||
public bool? WriteIdempotent { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class S7ProbeDto
|
||||
{
|
||||
public bool? Enabled { get; init; }
|
||||
public int? IntervalMs { get; init; }
|
||||
public int? TimeoutMs { get; init; }
|
||||
public string? ProbeAddress { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<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"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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>
|
||||
88
src/ZB.MOM.WW.OtOpcUa.Server/DriverInstanceBootstrapper.cs
Normal file
88
src/ZB.MOM.WW.OtOpcUa.Server/DriverInstanceBootstrapper.cs
Normal file
@@ -0,0 +1,88 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Task #248 — bridges the gap surfaced by the Phase 7 live smoke (#240) where
|
||||
/// <c>DriverInstance</c> rows in the central config DB had no path to materialise
|
||||
/// as live <see cref="Core.Abstractions.IDriver"/> instances in <see cref="DriverHost"/>.
|
||||
/// Called from <c>OpcUaServerService.ExecuteAsync</c> after the bootstrap loads
|
||||
/// the published generation, before address-space build.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Per row: looks up the <c>DriverType</c> string in
|
||||
/// <see cref="DriverFactoryRegistry"/>, calls the factory with the row's
|
||||
/// <c>DriverInstanceId</c> + <c>DriverConfig</c> JSON to construct an
|
||||
/// <see cref="Core.Abstractions.IDriver"/>, then registers via
|
||||
/// <see cref="DriverHost.RegisterAsync"/> which invokes <c>InitializeAsync</c>
|
||||
/// under the host's lifecycle semantics.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Unknown <c>DriverType</c> = factory not registered = log a warning and skip.
|
||||
/// Per plan decision #12 (driver isolation), failure to construct or initialize
|
||||
/// one driver doesn't prevent the rest from coming up — the Server keeps serving
|
||||
/// the others' subtrees + the operator can fix the misconfigured row + republish
|
||||
/// to retry.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class DriverInstanceBootstrapper(
|
||||
DriverFactoryRegistry factories,
|
||||
DriverHost driverHost,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<DriverInstanceBootstrapper> logger)
|
||||
{
|
||||
public async Task<int> RegisterDriversFromGenerationAsync(long generationId, CancellationToken ct)
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||
|
||||
var rows = await db.DriverInstances.AsNoTracking()
|
||||
.Where(d => d.GenerationId == generationId && d.Enabled)
|
||||
.ToListAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var registered = 0;
|
||||
var skippedUnknownType = 0;
|
||||
var failedInit = 0;
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var factory = factories.TryGet(row.DriverType);
|
||||
if (factory is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"DriverInstance {Id} skipped — DriverType '{Type}' has no registered factory (known: {Known})",
|
||||
row.DriverInstanceId, row.DriverType, string.Join(",", factories.RegisteredTypes));
|
||||
skippedUnknownType++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var driver = factory(row.DriverInstanceId, row.DriverConfig);
|
||||
await driverHost.RegisterAsync(driver, row.DriverConfig, ct).ConfigureAwait(false);
|
||||
registered++;
|
||||
logger.LogInformation(
|
||||
"DriverInstance {Id} ({Type}) registered + initialized", row.DriverInstanceId, row.DriverType);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Plan decision #12 — driver isolation. Log + continue so one bad row
|
||||
// doesn't deny the OPC UA endpoint to the rest of the fleet.
|
||||
logger.LogError(ex,
|
||||
"DriverInstance {Id} ({Type}) failed to initialize — driver state will reflect Faulted; operator can republish to retry",
|
||||
row.DriverInstanceId, row.DriverType);
|
||||
failedInit++;
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"DriverInstanceBootstrapper: gen={Gen} registered={Registered} skippedUnknownType={Skipped} failedInit={Failed}",
|
||||
generationId, registered, skippedUnknownType, failedInit);
|
||||
return registered;
|
||||
}
|
||||
}
|
||||
@@ -371,7 +371,20 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
BrowseName = new QualifiedName(_variable.BrowseName.Name + "_Condition", _owner.NamespaceIndex),
|
||||
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.Severity.Value = (ushort)MapSeverity(info.InitialSeverity);
|
||||
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.ActiveState.Value = new LocalizedText("Inactive");
|
||||
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);
|
||||
_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);
|
||||
}
|
||||
}
|
||||
@@ -398,6 +421,26 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
AlarmSeverity.Critical => 900,
|
||||
_ => 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)
|
||||
|
||||
@@ -118,7 +118,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
_server = new OtOpcUaServer(_driverHost, _authenticator, _pipelineBuilder, _loggerFactory,
|
||||
authzGate: _authzGate, scopeResolver: _scopeResolver,
|
||||
tierLookup: _tierLookup, resilienceConfigLookup: _resilienceConfigLookup,
|
||||
virtualReadable: _virtualReadable, scriptedAlarmReadable: _scriptedAlarmReadable);
|
||||
virtualReadable: _virtualReadable, scriptedAlarmReadable: _scriptedAlarmReadable,
|
||||
anonymousRoles: _options.AnonymousRoles);
|
||||
await _application.Start(_server).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("OPC UA server started — endpoint={Endpoint} driverCount={Count}",
|
||||
|
||||
@@ -85,4 +85,15 @@ public sealed class OpcUaServerOptions
|
||||
/// <c>LdapOptions.Enabled = false</c>, UserName token attempts are rejected.
|
||||
/// </summary>
|
||||
public LdapOptions Ldap { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Roles granted to anonymous OPC UA sessions. Default empty — anonymous clients can
|
||||
/// read <c>FreeAccess</c> attributes but cannot write <c>Operate</c>/<c>Tune</c>/
|
||||
/// <c>Configure</c> tags (<see cref="WriteAuthzPolicy"/> rejects the empty role set).
|
||||
/// Dev + smoke-test deployments that need anonymous writes populate this with the
|
||||
/// role names they want, e.g. <c>["WriteOperate"]</c> to match v1's anonymous-can-
|
||||
/// operate default. Production deployments leave it empty + route operators through
|
||||
/// UserName auth.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> AnonymousRoles { get; init; } = [];
|
||||
}
|
||||
|
||||
@@ -34,6 +34,15 @@ public sealed class OtOpcUaServer : StandardServer
|
||||
private readonly IReadable? _virtualReadable;
|
||||
private readonly IReadable? _scriptedAlarmReadable;
|
||||
|
||||
/// <summary>
|
||||
/// Roles granted to anonymous sessions. When non-empty, <see cref="OnImpersonateUser"/>
|
||||
/// wraps <c>AnonymousIdentityToken</c> in a <see cref="RoleBasedIdentity"/> carrying
|
||||
/// these roles so <see cref="DriverNodeManager"/>'s write-authz check passes for
|
||||
/// matching classifications. Empty (the default) preserves the pre-existing behaviour
|
||||
/// of rejecting anonymous writes at <c>Operate</c> or higher.
|
||||
/// </summary>
|
||||
private readonly IReadOnlyList<string> _anonymousRoles;
|
||||
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly List<DriverNodeManager> _driverNodeManagers = new();
|
||||
|
||||
@@ -47,7 +56,8 @@ public sealed class OtOpcUaServer : StandardServer
|
||||
Func<string, DriverTier>? tierLookup = null,
|
||||
Func<string, string?>? resilienceConfigLookup = null,
|
||||
IReadable? virtualReadable = null,
|
||||
IReadable? scriptedAlarmReadable = null)
|
||||
IReadable? scriptedAlarmReadable = null,
|
||||
IReadOnlyList<string>? anonymousRoles = null)
|
||||
{
|
||||
_driverHost = driverHost;
|
||||
_authenticator = authenticator;
|
||||
@@ -58,6 +68,7 @@ public sealed class OtOpcUaServer : StandardServer
|
||||
_resilienceConfigLookup = resilienceConfigLookup;
|
||||
_virtualReadable = virtualReadable;
|
||||
_scriptedAlarmReadable = scriptedAlarmReadable;
|
||||
_anonymousRoles = anonymousRoles ?? [];
|
||||
_loggerFactory = loggerFactory;
|
||||
}
|
||||
|
||||
@@ -112,7 +123,9 @@ public sealed class OtOpcUaServer : StandardServer
|
||||
switch (args.NewIdentity)
|
||||
{
|
||||
case AnonymousIdentityToken:
|
||||
args.Identity = new UserIdentity(); // anonymous
|
||||
args.Identity = _anonymousRoles.Count == 0
|
||||
? new UserIdentity() // anonymous, no roles — production default
|
||||
: new RoleBasedIdentity("(anonymous)", "Anonymous", _anonymousRoles);
|
||||
return;
|
||||
|
||||
case UserNameIdentityToken user:
|
||||
|
||||
@@ -18,6 +18,7 @@ public sealed class OpcUaServerService(
|
||||
DriverHost driverHost,
|
||||
OpcUaApplicationHost applicationHost,
|
||||
DriverEquipmentContentRegistry equipmentContentRegistry,
|
||||
DriverInstanceBootstrapper driverBootstrapper,
|
||||
Phase7Composer phase7Composer,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<OpcUaServerService> logger) : BackgroundService
|
||||
@@ -37,6 +38,13 @@ public sealed class OpcUaServerService(
|
||||
// address space until the first publish, then the registry fills on next restart.
|
||||
if (result.GenerationId is { } gen)
|
||||
{
|
||||
// Task #248 — register IDriver instances from the published DriverInstance
|
||||
// rows BEFORE the equipment-content load + Phase 7 compose, so the rest of
|
||||
// the pipeline sees a populated DriverHost. Without this step Phase 7's
|
||||
// CachedTagUpstreamSource has no upstream feed + virtual-tag scripts read
|
||||
// BadNodeIdUnknown for every tag path (gap surfaced by task #240 smoke).
|
||||
await driverBootstrapper.RegisterDriversFromGenerationAsync(gen, stoppingToken);
|
||||
|
||||
await PopulateEquipmentContentAsync(gen, stoppingToken);
|
||||
|
||||
// Phase 7 follow-up #246 — load Script + VirtualTag + ScriptedAlarm rows,
|
||||
|
||||
@@ -9,6 +9,12 @@ using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
using ZB.MOM.WW.OtOpcUa.Server;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Phase7;
|
||||
@@ -77,6 +83,7 @@ var opcUaOptions = new OpcUaServerOptions
|
||||
SecurityProfile = Enum.TryParse<OpcUaSecurityProfile>(opcUaSection.GetValue<string>("SecurityProfile"), true, out var p)
|
||||
? p : OpcUaSecurityProfile.None,
|
||||
Ldap = ldapOptions,
|
||||
AnonymousRoles = opcUaSection.GetSection("AnonymousRoles").Get<string[]>() ?? [],
|
||||
};
|
||||
|
||||
builder.Services.AddSingleton(options);
|
||||
@@ -89,6 +96,23 @@ builder.Services.AddSingleton<ILocalConfigCache>(_ => new LiteDbConfigCache(opti
|
||||
builder.Services.AddSingleton<DriverHost>();
|
||||
builder.Services.AddSingleton<NodeBootstrap>();
|
||||
|
||||
// Task #248 — driver-instance bootstrap pipeline. DriverFactoryRegistry is the
|
||||
// type-name → factory map; each driver project's static Register call pre-loads
|
||||
// its factory so the bootstrapper can materialise DriverInstance rows from the
|
||||
// central DB into live IDriver instances.
|
||||
builder.Services.AddSingleton<DriverFactoryRegistry>(_ =>
|
||||
{
|
||||
var registry = new DriverFactoryRegistry();
|
||||
GalaxyProxyDriverFactoryExtensions.Register(registry);
|
||||
FocasDriverFactoryExtensions.Register(registry);
|
||||
ModbusDriverFactoryExtensions.Register(registry);
|
||||
AbCipDriverFactoryExtensions.Register(registry);
|
||||
AbLegacyDriverFactoryExtensions.Register(registry);
|
||||
S7DriverFactoryExtensions.Register(registry);
|
||||
return registry;
|
||||
});
|
||||
builder.Services.AddSingleton<DriverInstanceBootstrapper>();
|
||||
|
||||
// ADR-001 Option A wiring — the registry is the handoff between OpcUaServerService's
|
||||
// bootstrap-time population pass + OpcUaApplicationHost's StartAsync walker invocation.
|
||||
// DriverEquipmentContentRegistry.Get is the equipmentContentLookup delegate that PR #155
|
||||
|
||||
@@ -34,6 +34,12 @@
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.VirtualTags\ZB.MOM.WW.OtOpcUa.Core.VirtualTags.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.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.Driver.Modbus\ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.AbCip\ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.S7\ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Analyzers\ZB.MOM.WW.OtOpcUa.Analyzers.csproj"
|
||||
OutputItemType="Analyzer" ReferenceOutputAssembly="false"/>
|
||||
</ItemGroup>
|
||||
|
||||
@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
@@ -32,13 +33,43 @@ public sealed class AdminWebAppFactory : IAsyncDisposable
|
||||
public long SeededGenerationId { get; private set; }
|
||||
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()
|
||||
{
|
||||
var port = GetFreeTcpPort();
|
||||
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);
|
||||
// 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
|
||||
// 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));
|
||||
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 =>
|
||||
opt.UseInMemoryDatabase($"e2e-{Guid.NewGuid():N}"));
|
||||
opt.UseInMemoryDatabase(dbName));
|
||||
|
||||
builder.Services.AddScoped<Admin.Services.ClusterService>();
|
||||
builder.Services.AddScoped<Admin.Services.GenerationService>();
|
||||
@@ -72,6 +108,12 @@ public sealed class AdminWebAppFactory : IAsyncDisposable
|
||||
_app.UseAuthorization();
|
||||
_app.UseAntiforgery();
|
||||
_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.
|
||||
using (var scope = _app.Services.CreateScope())
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Playwright;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.E2ETests;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.4 UnsTab drag-drop E2E smoke (task #199). This PR lands the Playwright +
|
||||
/// WebApplicationFactory-equivalent scaffolding so future E2E coverage builds on it
|
||||
/// rather than setting it up from scratch.
|
||||
/// Phase 6.4 UnsTab drag-drop E2E. Task #199 landed the scaffolding; task #242 (this file)
|
||||
/// drives the Blazor Server interactive circuit through a real drag-drop → confirm-modal
|
||||
/// → apply flow and a 409 concurrent-edit flow, both via Chromium.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Current scope.</b> The host-reachability smoke below proves the infra works:
|
||||
/// Kestrel-on-a-free-port, InMemory DbContext swap, <see cref="TestAuthHandler"/>
|
||||
/// bypass, and Playwright-to-real-browser are all exercised. The actual drag-drop
|
||||
/// interactive assertion is filed as a follow-up (task #242) because
|
||||
/// Blazor Server interactive render through a test-owned pipeline needs a dedicated
|
||||
/// diagnosis pass — the scaffolding lands here first so that follow-up can focus on
|
||||
/// the Blazor-specific wiring instead of rebuilding the harness.
|
||||
/// <b>Harness notes.</b> <see cref="AdminWebAppFactory"/> points the content root at
|
||||
/// the Admin assembly directory + sets <c>ApplicationName</c> + calls
|
||||
/// <c>UseStaticWebAssets</c> so <c>/_framework/blazor.web.js</c> + <c>/app.css</c>
|
||||
/// resolve from the Admin's <c>staticwebassets.development.json</c> manifest (which
|
||||
/// stitches together Admin <c>wwwroot</c> + the framework NuGet cache). Hubs
|
||||
/// <c>/hubs/fleet</c> + <c>/hubs/alerts</c> are mapped so <c>ClusterDetail</c>'s
|
||||
/// <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>
|
||||
/// </remarks>
|
||||
[Trait("Category", "E2E")]
|
||||
@@ -35,34 +40,20 @@ public sealed class UnsTabDragDropE2ETests
|
||||
await using var app = new AdminWebAppFactory();
|
||||
await app.StartAsync();
|
||||
|
||||
PlaywrightFixture fixture;
|
||||
try
|
||||
{
|
||||
fixture = new PlaywrightFixture();
|
||||
await fixture.InitializeAsync();
|
||||
}
|
||||
catch (PlaywrightBrowserMissingException)
|
||||
{
|
||||
Assert.Skip("Chromium not installed. Run playwright.ps1 install chromium.");
|
||||
return;
|
||||
}
|
||||
var fixture = await TryInitPlaywrightAsync();
|
||||
if (fixture is null) return;
|
||||
|
||||
try
|
||||
{
|
||||
var ctx = await fixture.Browser.NewContextAsync();
|
||||
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);
|
||||
|
||||
response.ShouldNotBeNull();
|
||||
response!.Status.ShouldBeLessThan(500,
|
||||
$"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();
|
||||
body.Length.ShouldBeGreaterThan(0, "empty body = routing pipeline didn't hit Razor");
|
||||
}
|
||||
@@ -71,4 +62,148 @@ public sealed class UnsTabDragDropE2ETests
|
||||
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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user