Compare commits
75 Commits
phase-3-pr
...
phase-3-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24435712c4 | ||
| 3f7b4d05e6 | |||
|
|
a79c5f3008 | ||
| a5299a2fee | |||
|
|
a65215684c | ||
| 82f2dfcfa3 | |||
|
|
0433d3a35e | ||
| 141673fc80 | |||
|
|
db56a95819 | ||
| 89bd726fa8 | |||
|
|
238748bc98 | ||
| b21d550836 | |||
|
|
91eaf534c8 | ||
| d33e38e059 | |||
|
|
d8ef35d5bd | ||
| 5e318a1ab6 | |||
|
|
394d126b2e | ||
| 0eab1271be | |||
|
|
d5034c40f7 | ||
| 5e67c49f7c | |||
|
|
0575280a3b | ||
| 8150177296 | |||
|
|
56d8af8bdb | ||
| be8261a4ac | |||
| 65de2b4a09 | |||
| fccb566a30 | |||
| 9ccc7338b8 | |||
| e33783e042 | |||
|
|
a44fc7a610 | ||
|
|
d4c1873998 | ||
|
|
f52b7d8979 | ||
|
|
b54724a812 | ||
|
|
10c724b5b6 | ||
| 8c89d603e8 | |||
| 299bd4a932 | |||
|
|
c506ea298a | ||
|
|
9e2b5b330f | ||
| d5c6280333 | |||
| 476ce9b7c5 | |||
| 954bf55d28 | |||
| 9fb3cf7512 | |||
|
|
793c787315 | ||
|
|
cde018aec1 | ||
|
|
9892a0253d | ||
|
|
b5464f11ee | ||
| dae29f14c8 | |||
| f306793e36 | |||
| 9e61873cc0 | |||
| 1a60470d4a | |||
| 635f67bb02 | |||
|
|
a3f2f95344 | ||
|
|
463c5a4320 | ||
|
|
2b5222f5db | ||
|
|
8248b126ce | ||
|
|
cd19022d19 | ||
| 5ee9acb255 | |||
|
|
02fccbc762 | ||
| faeab34541 | |||
|
|
a05b84858d | ||
| c59ac9e52d | |||
|
|
02a0e8efd1 | ||
| 7009483d16 | |||
|
|
9de96554dc | ||
| af35fac0ef | |||
|
|
aa8834a231 | ||
| 976e73e051 | |||
|
|
8fb3dbe53b | ||
|
|
a61e637411 | ||
| e4885aadd0 | |||
|
|
52a29100b1 | ||
| 19bcf20fbe | |||
|
|
8adc8f5ab8 | ||
| 261869d84e | |||
|
|
08c90d19fd | ||
| 5cc120d836 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -29,3 +29,4 @@ packages/
|
||||
# Claude Code (per-developer settings, runtime lock files, agent transcripts)
|
||||
.claude/
|
||||
|
||||
.local/
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.S7/ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.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.UI/ZB.MOM.WW.OtOpcUa.Client.UI.csproj"/>
|
||||
@@ -21,10 +23,13 @@
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/ZB.MOM.WW.OtOpcUa.Admin.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.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.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.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.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests.csproj"/>
|
||||
|
||||
1
_p54.json
Normal file
1
_p54.json
Normal file
@@ -0,0 +1 @@
|
||||
{"title":"Phase 3 PR 54 -- Siemens S7 Modbus TCP quirks research doc","body":"## Summary\n\nAdds `docs/v2/s7.md` (485 lines) covering Siemens SIMATIC S7 family Modbus TCP behavior. Mirrors the `docs/v2/dl205.md` template for future per-quirk implementation PRs.\n\n## Key findings for the implementation track\n\n- **No fixed memory map** — every S7 Modbus server is user-wired via `MB_SERVER`/`MODBUSCP`/`MODBUSPN` library blocks. Driver must accept per-site config, not assume a vendor layout.\n- **MB_SERVER requires non-optimized DBs** (STATUS `0x8383` if optimized). Most common field bug.\n- **Word order default = ABCD** (opposite of DL260). Driver's S7 profile default must be `ByteOrder.BigEndian`, not `WordSwap`.\n- **One port per MB_SERVER instance** — multi-client requires parallel FBs on 503/504/… Most clients assume port 502 multiplexes (wrong on S7).\n- **CP 343-1 Lean is server-only**, requires the `2XV9450-1MB00` license.\n- **FC20/21/22/23/43 all return Illegal Function** on every S7 variant — driver must not attempt FC23 bulk-read optimization for S7.\n- **STOP-mode behavior non-deterministic** across firmware bands — treat both read/write STOP-mode responses as unavailable.\n\nTwo items flagged as unconfirmed rumour (V2.0+ float byte-order claim, STOP-mode caching location).\n\nNo code, no tests — implementation lands in PRs 56+.\n\n## Test plan\n- [x] Doc renders as markdown\n- [x] 31 citations present\n- [x] Section structure matches dl205.md template","head":"phase-3-pr54-s7-research-doc","base":"v2"}
|
||||
1
_p55.json
Normal file
1
_p55.json
Normal file
@@ -0,0 +1 @@
|
||||
{"title":"Phase 3 PR 55 -- Mitsubishi MELSEC Modbus TCP quirks research doc","body":"## Summary\n\nAdds `docs/v2/mitsubishi.md` (451 lines) covering MELSEC Q/L/iQ-R/iQ-F/FX3U Modbus TCP behavior. Mirrors `docs/v2/dl205.md` template for per-quirk implementation PRs.\n\n## Key findings for the implementation track\n\n- **Module naming trap** — `QJ71MB91` is SERIAL RTU, not TCP. TCP module is `QJ71MT91`. Surface clearly in driver docs.\n- **No canonical mapping** — per-site 'Modbus Device Assignment Parameter' block (up to 16 entries). Treat mapping as runtime config.\n- **X/Y hex vs octal depends on family** — Q/L/iQ-R use HEX (X20 = decimal 32); FX/iQ-F use OCTAL (X20 = decimal 16). Helper must take a family selector.\n- **Word order CDAB default** across all MELSEC families (opposite of Siemens S7). Driver Mitsubishi profile default: `ByteOrder.WordSwap`.\n- **D-registers binary by default** (opposite of DL205's BCD default). Caller opts in to `Bcd16`/`Bcd32` when ladder uses BCD.\n- **FX5U needs firmware ≥ 1.060** for Modbus TCP server — older is client-only.\n- **FX3U-ENET vs FX3U-ENET-P502 vs FX3U-ENET-ADP** — only the middle one binds port 502; the last has no Modbus at all. Common operator mis-purchase.\n- **QJ71MT91 does NOT support FC22 / FC23** — iQ-R / iQ-F do. Bulk-read optimization must gate on capability.\n- **STOP-mode writes configurable** on Q/L/iQ-R/iQ-F (default accept), always rejected on FX3U-ENET.\n\nThree unconfirmed rumours flagged separately.\n\nNo code, no tests — implementation lands in PRs 58+.\n\n## Test plan\n- [x] Doc renders as markdown\n- [x] 17 citations present\n- [x] Per-model test naming matrix included (`Mitsubishi_QJ71MT91_*`, `Mitsubishi_FX5U_*`, `Mitsubishi_FX3U_ENET_*`, shared `Mitsubishi_Common_*`)","head":"phase-3-pr55-mitsubishi-research-doc","base":"v2"}
|
||||
@@ -1,56 +1,47 @@
|
||||
# V1 Archive Status (Phase 2 Stream D, 2026-04-18)
|
||||
# V1 Archive Status — CLOSED (Phase 2 Streams D + E complete)
|
||||
|
||||
This document inventories every v1 surface that's been **functionally superseded** by v2 but
|
||||
**physically retained** in the build until the deletion PR (Phase 2 PR 3). Rationale: cascading
|
||||
references mean a single deletion is high blast-radius; archive-marking lets the v2 stack ship
|
||||
on its own merits while the v1 surface stays as parity reference.
|
||||
> **Status as of 2026-04-18: the v1 archive has been fully removed from the tree.**
|
||||
> This document is retained as historical record of the Phase 2 Stream D / E closure.
|
||||
|
||||
## Archived projects
|
||||
## Final state
|
||||
|
||||
| Path | Status | Replaced by | Build behavior |
|
||||
|---|---|---|---|
|
||||
| `src/ZB.MOM.WW.OtOpcUa.Host/` | Archive (executable in build) | `OtOpcUa.Server` + `Driver.Galaxy.Host` + `Driver.Galaxy.Proxy` | Builds; not deployed by v2 install scripts |
|
||||
| `src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/` | Archive (plugin in build) | TODO: port into `Driver.Galaxy.Host/Backend/Historian/` (Task B.1.h follow-up) | Builds; loaded only by archived Host |
|
||||
| `tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/` | Archive | `Driver.Galaxy.E2E` + per-component test projects | `<IsTestProject>false</IsTestProject>` — `dotnet test slnx` skips |
|
||||
| `tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/` | Archive | `Driver.Galaxy.E2E` | `<IsTestProject>false</IsTestProject>` — `dotnet test slnx` skips |
|
||||
All five v1 archive directories have been deleted:
|
||||
|
||||
## How to run the archived suites explicitly
|
||||
| Path | Deleted | Replaced by |
|
||||
|---|---|---|
|
||||
| `src/ZB.MOM.WW.OtOpcUa.Host/` | ✅ | `OtOpcUa.Server` + `Driver.Galaxy.Host` + `Driver.Galaxy.Proxy` |
|
||||
| `src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/` | ✅ | `Driver.Galaxy.Host/Backend/Historian/` (ported in Phase 3 PRs 51-55) |
|
||||
| `tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests/` | ✅ | `Driver.Galaxy.Host.Tests/Historian/` |
|
||||
| `tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/` | ✅ | Per-component `*.Tests` projects + `Driver.Galaxy.E2E` |
|
||||
| `tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/` | ✅ | `Driver.Galaxy.E2E` + `Driver.Modbus.IntegrationTests` |
|
||||
|
||||
```powershell
|
||||
# v1 unit tests (494):
|
||||
dotnet test tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive
|
||||
## Closure timeline
|
||||
|
||||
# v1 integration tests (6):
|
||||
dotnet test tests/ZB.MOM.WW.OtOpcUa.IntegrationTests
|
||||
```
|
||||
- **PR 2 (2026-04-18, phase-2-stream-d)** — archive-marked the four v1 projects with
|
||||
`<IsTestProject>false</IsTestProject>` so solution builds and `dotnet test slnx` bypassed
|
||||
them. Capture: `docs/v2/implementation/exit-gate-phase-2-final.md`.
|
||||
- **Phase 3 PR 18 (2026-04-18)** — deleted the archived project source trees. Leftover
|
||||
`bin/` and `obj/` residue remained on disk from pre-deletion builds.
|
||||
- **Phase 2 PR 61 (2026-04-18, this closure PR)** — scrubbed the empty residue directories
|
||||
and confirmed `dotnet build ZB.MOM.WW.OtOpcUa.slnx` clean with 0 errors.
|
||||
|
||||
Both still pass on this dev box — they're the parity reference for Phase 2 PR 3's deletion
|
||||
decision.
|
||||
## Parity validation (Stream E)
|
||||
|
||||
## Deletion plan (Phase 2 PR 3)
|
||||
The original 494 v1 tests + 6 v1 integration tests are **not** preserved in the v2 branch.
|
||||
Their parity-bar role is now filled by:
|
||||
|
||||
Pre-conditions:
|
||||
- [ ] `Driver.Galaxy.E2E` test count covers the v1 IntegrationTests' 6 integration scenarios
|
||||
at minimum (currently 7 tests; expand as needed)
|
||||
- [ ] `Driver.Galaxy.Host/Backend/Historian/` ports the Wonderware Historian plugin
|
||||
so `MxAccessGalaxyBackend.HistoryReadAsync` returns real data (Task B.1.h)
|
||||
- [ ] Operator review on a separate PR — destructive change
|
||||
|
||||
Steps:
|
||||
1. `git rm -r src/ZB.MOM.WW.OtOpcUa.Host/`
|
||||
2. `git rm -r src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/`
|
||||
(or move it under Driver.Galaxy.Host first if the lift is part of the same PR)
|
||||
3. `git rm -r tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/`
|
||||
4. `git rm -r tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/`
|
||||
5. Edit `ZB.MOM.WW.OtOpcUa.slnx` — remove the four project lines
|
||||
6. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → confirm clean
|
||||
7. `dotnet test ZB.MOM.WW.OtOpcUa.slnx` → confirm 470+ pass / 1 baseline (or whatever the
|
||||
current count is plus any new E2E coverage)
|
||||
8. Commit: "Phase 2 Stream D — delete v1 archive (Host + Historian.Aveva + v1Tests + IntegrationTests)"
|
||||
9. PR 3 against `v2`, link this doc + exit-gate-phase-2-final.md
|
||||
10. One reviewer signoff
|
||||
- `Driver.Galaxy.E2E` — cross-FX subprocess parity (spawns the net48 x86 Galaxy.Host.exe
|
||||
+ connects via real named pipe, exercises every `IDriver` capability through the
|
||||
supervisor). Stability-findings regression tests (4 × 2026-04-13 findings) live here.
|
||||
- Per-component `*.Tests` projects — cover the code that moved out of the monolith into
|
||||
discrete v2 projects. Running `dotnet test ZB.MOM.WW.OtOpcUa.slnx` executes all of them
|
||||
as one solution-level gate.
|
||||
- `Driver.Modbus.IntegrationTests` — adds Modbus TCP driver coverage that didn't exist in
|
||||
v1 (DL205, S7-1500, Mitsubishi MELSEC via pymodbus sim profiles — PRs 30, 56-60).
|
||||
- Live-stack smoke tests (`Driver.Galaxy.E2E/LiveStack/`) — optional, gated on presence
|
||||
of the `OtOpcUaGalaxyHost` service + Galaxy repository on the dev box (PRs 33, 36, 37).
|
||||
|
||||
## Rollback
|
||||
|
||||
If Phase 2 PR 3 surfaces downstream consumer regressions, `git revert` the deletion commit
|
||||
restores the four projects intact. The v2 stack continues to ship from the v2 branch.
|
||||
`git revert` of the deletion commits restores the projects intact. The v2 stack continues
|
||||
to ship from the `v2` branch regardless.
|
||||
|
||||
295
docs/v2/dl205.md
Normal file
295
docs/v2/dl205.md
Normal file
@@ -0,0 +1,295 @@
|
||||
# AutomationDirect DirectLOGIC DL205 / DL260 — Modbus quirks
|
||||
|
||||
AutomationDirect's DirectLOGIC DL205 family (D2-250-1, D2-260, D2-262, D2-262M) and
|
||||
its larger DL260 sibling speak Modbus TCP (via the H2-ECOM100 / H2-EBC100 Ethernet
|
||||
coprocessors, and the DL260's built-in Ethernet port) and Modbus RTU (via the CPU
|
||||
serial ports in "Modbus" mode). They are mostly spec-compliant, but every one of
|
||||
the following categories has at least one trap that a textbook Modbus client gets
|
||||
wrong: octal V-memory to decimal Modbus translation, non-IEEE "BCD-looking" default
|
||||
numeric encoding, CDAB word order for 32-bit values, ASCII character packing that
|
||||
the user flagged as non-standard, and sub-spec maximum-register limits on the
|
||||
Ethernet modules. This document catalogues each quirk, cites primary sources, and
|
||||
names the ModbusPal integration test we'd write for it (convention from
|
||||
`docs/v2/modbus-test-plan.md`: `DL205_<behavior>`).
|
||||
|
||||
## Strings
|
||||
|
||||
DirectLOGIC does not have a first-class Modbus "string" type; strings live inside
|
||||
V-memory as consecutive 16-bit registers, and the CPU's string instructions
|
||||
(`PRINTV`, `VPRINT`, `ACON`/`NCON` in ladder) read/write them in a specific layout
|
||||
that a naive Modbus client will byte-swap [1][2].
|
||||
|
||||
- **Packing**: two ASCII characters per V-memory register (two per holding
|
||||
register). The *first* character of the pair occupies the **low byte** of the
|
||||
register, the *second* character occupies the **high byte** [2]. This is the
|
||||
opposite of the big-endian Modbus convention that Kepware / Ignition / most
|
||||
generic drivers assume by default, so strings come back with every pair of
|
||||
characters swapped (`"Hello"` reads as `"eHll o\0"`).
|
||||
- **Termination**: null-terminated (`0x00` in the character byte). There is no
|
||||
length prefix. Writes must pad the final register's unused byte with `0x00`.
|
||||
- **Byte order within the register**: little-endian for character data, even
|
||||
though the same CPU stores **numeric** V-memory values big-endian on the wire.
|
||||
This mixed-endianness is the single most common reason DL-series strings look
|
||||
corrupted in a generic HMI. Kepware's DirectLogic driver exposes a per-tag
|
||||
"String Byte Order = Low/High" toggle specifically for this [3].
|
||||
- **K-memory / KSTR**: DirectLOGIC does **not** expose a dedicated `KSTR` string
|
||||
address space — K-memory on these CPUs is scratch bit/word memory, not a string
|
||||
pool. Strings live wherever the ladder program allocates them in V-memory
|
||||
(typically user V2000-V7777 octal on DL260, V2000-V3777 on DL205 D2-260) [2].
|
||||
- **Maximum length**: bounded only by the V-memory region assigned. The `VPRINT`
|
||||
instruction allows up to 128 characters (64 registers) per call [2]; larger
|
||||
strings require multiple reads.
|
||||
- **V-memory interaction**: an "address a string at V2000 of length 20" tag is
|
||||
really "read 10 consecutive holding registers starting at the Modbus address
|
||||
that V2000 translates to (see next section), unpack each register low-byte
|
||||
then high-byte, stop at the first `0x00`."
|
||||
|
||||
Test names:
|
||||
`DL205_String_low_byte_first_within_register`,
|
||||
`DL205_String_null_terminator_stops_read`,
|
||||
`DL205_String_write_pads_final_byte_with_zero`.
|
||||
|
||||
## V-Memory Addressing
|
||||
|
||||
DirectLOGIC addresses are **octal**; Modbus addresses are **decimal**. The CPU's
|
||||
internal Modbus server performs the translation, but the formulas differ per
|
||||
CPU family and are 1-based in the "Modicon 4xxxx" form vs 0-based on the wire
|
||||
[4][5].
|
||||
|
||||
Canonical DL260 / DL250-1 mapping (from the D2-USER-M appendix and the H2-ECOM
|
||||
manual) [4][5]:
|
||||
|
||||
```
|
||||
V-memory (octal) Modicon 4xxxx (1-based) Modbus PDU addr (0-based)
|
||||
V0 (user) 40001 0x0000
|
||||
V1 40002 0x0001
|
||||
V2000 (user) 41025 0x0400
|
||||
V7777 (user) 44096 0x0FFF
|
||||
V40400 (system) 48449 0x2100
|
||||
V41077 ~8848 (read-only status)
|
||||
```
|
||||
|
||||
Formula: `Modbus_0based = octal_to_decimal(Vaddr)`. So `V2000` octal = `1024`
|
||||
decimal = Modbus PDU address `0x0400`. The "4xxxx" Modicon view just adds 1 and
|
||||
prefixes the register bank digit.
|
||||
|
||||
- **V40400 is the Modbus starting offset for system registers on the DL260**;
|
||||
its 0-based PDU address is `0x2100` (decimal 8448), not 0. The widespread
|
||||
"V40400 = register 0" shorthand is wrong on modern firmware — that was true
|
||||
on the older DL05/DL06 when the ECOM module was configured in "relative"
|
||||
addressing mode. On the H2-ECOM100 factory default ("absolute" mode), V40400
|
||||
maps to 0x2100 [5].
|
||||
- **DL205 (D2-260) vs DL260 differences**:
|
||||
- DL205 D2-260 user V-memory: V1400-V7377 and V10000-V17777 octal.
|
||||
- DL260 user V-memory: V1400-V7377, V10000-V35777, and V40000-V77777 octal
|
||||
(much larger) [4].
|
||||
- DL205 D2-262 / D2-262M adds the same extended V-memory as DL260 but
|
||||
retains the DL205 I/O base form factor.
|
||||
- Neither DL205 sub-model changes the *formula* — only the valid range.
|
||||
- **Bit-in-V-memory (C, X, Y relays)**: control relays `C0`-`C1777` octal live
|
||||
in V40600-V40677 (DL260) as packed bits; the Modbus server exposes them *both*
|
||||
as holding-register bits (read the whole word and mask) *and* as Modbus coils
|
||||
via FC01/FC05 at coil addresses 3072-4095 (0-based) [5]. `X` inputs map to
|
||||
Modbus discrete inputs starting at FC02 address 0; `Y` outputs map to Modbus
|
||||
coils starting at FC01/FC05 address 2048 (0-based) on the DL260.
|
||||
- **Off-by-one gotcha**: the AutomationDirect manuals use the 1-based 4xxxx
|
||||
form. Kepware, libmodbus, pymodbus, and the .NET stack all take the 0-based
|
||||
PDU form. When the manual says "V2000 = 41025" you send `0x0400`, not
|
||||
`0x0401`.
|
||||
|
||||
Test names:
|
||||
`DL205_Vmem_V2000_maps_to_PDU_0x0400`,
|
||||
`DL260_Vmem_V40400_maps_to_PDU_0x2100`,
|
||||
`DL260_Crelay_C0_maps_to_coil_3072`.
|
||||
|
||||
## Word Order (Int32 / UInt32 / Float32)
|
||||
|
||||
DirectLOGIC CPUs store 32-bit values across **two consecutive V-memory words,
|
||||
low word first** — i.e., `CDAB` when viewed as a Modbus register pair [1][3].
|
||||
Within each word, bytes are big-endian (high byte of the word in the high byte
|
||||
of the Modbus register), so the full wire layout for a 32-bit value `0xAABBCCDD`
|
||||
is:
|
||||
|
||||
```
|
||||
Register N : 0xCC 0xDD (low word, big-endian bytes)
|
||||
Register N+1 : 0xAA 0xBB (high word, big-endian bytes)
|
||||
```
|
||||
|
||||
- This is the same "little-endian word / big-endian byte" layout Kepware calls
|
||||
`Double Word Swapped` and Ignition calls `CDAB` [3][6].
|
||||
- **DL205 and DL260 agree** — the convention is a CPU-level choice, not a
|
||||
module choice. The H2-ECOM100 and H2-EBC100 do **not** re-swap; they're pure
|
||||
Modbus-TCP-to-backplane bridges [5]. The DL260 built-in Ethernet port
|
||||
behaves identically.
|
||||
- **Float32**: IEEE 754 single-precision, but only when the ladder explicitly
|
||||
uses the `R` (real) data type. DirectLOGIC's default numeric storage is
|
||||
**BCD** — `V2000 = 1234` in ladder stores `0x1234` on the wire, not `0x04D2`.
|
||||
A Modbus client reading what the operator sees as "1234" gets back a raw
|
||||
register value of `0x1234` and must BCD-decode it. Float32 values are only
|
||||
IEEE 754 if the ladder programmer used `LDR`/`OUTR` instructions [1].
|
||||
- **Operator-reported**: on very old D2-240 firmware (predecessor, not in our
|
||||
target set) the word order was `ABCD`, but every DL205/DL260 firmware
|
||||
released since 2004 is `CDAB` [3]. _Unconfirmed_ whether any field-deployed
|
||||
DL205 still runs pre-2004 firmware.
|
||||
|
||||
Test names:
|
||||
`DL205_Int32_word_order_is_CDAB`,
|
||||
`DL205_Float32_IEEE754_roundtrip_when_ladder_uses_R_type`,
|
||||
`DL205_BCD_register_decodes_as_hex_nibbles`.
|
||||
|
||||
## Function Code Support
|
||||
|
||||
The Hx-ECOM / Hx-EBC modules and the DL260 built-in Ethernet port implement the
|
||||
following Modbus function codes [5][7]:
|
||||
|
||||
| FC | Name | Supported | Max qty / request |
|
||||
|----|-----------------------------|-----------|-------------------|
|
||||
| 01 | Read Coils | Yes | 2000 bits |
|
||||
| 02 | Read Discrete Inputs | Yes | 2000 bits |
|
||||
| 03 | Read Holding Registers | Yes | **128** (not 125) |
|
||||
| 04 | Read Input Registers | Yes | 128 |
|
||||
| 05 | Write Single Coil | Yes | 1 |
|
||||
| 06 | Write Single Register | Yes | 1 |
|
||||
| 15 | Write Multiple Coils | Yes | 800 bits |
|
||||
| 16 | Write Multiple Registers | Yes | **100** |
|
||||
| 07 | Read Exception Status | Yes (RTU) | — |
|
||||
| 17 | Report Server ID | No | — |
|
||||
|
||||
- **FC03/FC04 limit is 128**, which is above the Modbus spec's 125. Requesting
|
||||
129+ returns exception code `03` (Illegal Data Value) [5].
|
||||
- **FC16 limit is 100**, below the spec's 123. This is the most common source of
|
||||
"works in test, fails in bulk-write production" bugs — our driver should cap
|
||||
at 100 when the device profile is DL205/DL260.
|
||||
- **No custom function codes** are exposed on the Modbus port. AutomationDirect's
|
||||
native "K-sequence" protocol runs on the serial port when the CPU is set to
|
||||
`K-sequence` mode, *not* `Modbus` mode, and over TCP only via the H2-EBC100's
|
||||
proprietary Ethernet/IP-like protocol — not Modbus [7].
|
||||
|
||||
Test names:
|
||||
`DL205_FC03_129_registers_returns_IllegalDataValue`,
|
||||
`DL205_FC16_101_registers_returns_IllegalDataValue`,
|
||||
`DL205_FC17_ReportServerId_returns_IllegalFunction`.
|
||||
|
||||
## Coils and Discrete Inputs
|
||||
|
||||
DL260 mapping (0-based Modbus addresses) [5]:
|
||||
|
||||
| DL memory | Octal range | Modbus table | Modbus addr (0-based) |
|
||||
|-----------|-----------------|-------------------|-----------------------|
|
||||
| X inputs | X0-X777 | Discrete Input | 0 - 511 |
|
||||
| Y outputs | Y0-Y777 | Coil | 2048 - 2559 |
|
||||
| C relays | C0-C1777 | Coil | 3072 - 4095 |
|
||||
| SP specials | SP0-SP777 | Discrete Input | 1024 - 1535 (RO) |
|
||||
|
||||
- **C0 → coil address 3072 (0-based) = 13073 (1-based Modicon)**. Y0 → coil
|
||||
2048 = 12049. These offsets are wired into the CPU and cannot be remapped.
|
||||
- **Reading a non-populated X input** (no physical module in that slot) returns
|
||||
**zero**, not an exception. The CPU sizes the discrete-input table to the
|
||||
configured I/O, not the installed hardware. Confirmed in the DL260 user
|
||||
manual's I/O configuration chapter [4].
|
||||
- **Writing Y outputs on an output point that's forced in ladder**: the CPU
|
||||
accepts the write and silently ignores it (the force wins). No exception is
|
||||
returned. _Operator-reported_, matches Kepware driver release notes [3].
|
||||
|
||||
Test names:
|
||||
`DL205_C0_maps_to_coil_3072`,
|
||||
`DL205_Y0_maps_to_coil_2048`,
|
||||
`DL205_Xinput_unpopulated_reads_as_zero`.
|
||||
|
||||
## Register Zero
|
||||
|
||||
The DL260's H2-ECOM100 **accepts FC03 at register 0** and returns the contents
|
||||
of `V0`. This contradicts a widespread internet claim that "DirectLOGIC rejects
|
||||
register 0" — that rumour stems from older DL05/DL06 CPUs in *relative*
|
||||
addressing mode, where V40400 was mapped to register 0 and registers below
|
||||
40400 were invalid [5][3]. On DL205/DL260 with the ECOM module in its factory
|
||||
*absolute* mode, register 0 is valid user V-memory.
|
||||
|
||||
- Our driver's `ModbusProbeOptions.ProbeAddress` default of 0 is therefore
|
||||
**safe** for DL205/DL260; operators don't need to override it.
|
||||
- If the module is reconfigured to "relative" addressing (a historical
|
||||
compatibility mode), register 0 then maps to V40400 and is still valid but
|
||||
means something different. The probe will still succeed.
|
||||
|
||||
Test name: `DL205_FC03_register_0_returns_V0_contents`.
|
||||
|
||||
## Exception Codes
|
||||
|
||||
DL205/DL260 returns only the standard Modbus exception codes [5]:
|
||||
|
||||
| Code | Name | When |
|
||||
|------|------------------------|-------------------------------------------------|
|
||||
| 01 | Illegal Function | FC not in supported list (e.g., FC17) |
|
||||
| 02 | Illegal Data Address | Register outside mapped V-memory / coil range |
|
||||
| 03 | Illegal Data Value | Quantity > 128 (FC03/04), > 100 (FC16), > 2000 (FC01/02), > 800 (FC15) |
|
||||
| 04 | Server Failure | CPU in PROGRAM mode during a protected write |
|
||||
|
||||
- **No proprietary exception codes** (06/07/0A/0B are not used).
|
||||
- **Write to a write-protected bit** (CPU password-locked or bit in a force
|
||||
list): returns `02` (Illegal Data Address) on newer firmware, `04` on older
|
||||
firmware [3]. _Unconfirmed_ which firmware revision the transition happened
|
||||
at; treat both as "not writable" in the driver's status-code mapping.
|
||||
- **Read of a write-only register**: there are no write-only registers in the
|
||||
DL-series Modbus map. Every writable register is also readable.
|
||||
|
||||
Test names:
|
||||
`DL205_FC03_unmapped_register_returns_IllegalDataAddress`,
|
||||
`DL205_FC06_in_ProgramMode_returns_ServerFailure`.
|
||||
|
||||
## Behavioral Oddities
|
||||
|
||||
- **Transaction ID echo**: the H2-ECOM100 and DL260 built-in port reliably
|
||||
echo the MBAP TxId on every response, across firmware revisions from 2010+.
|
||||
The rumour that "DL260 drops TxId under load" appears on the AutomationDirect
|
||||
support forum but is _unconfirmed_ and has not reproduced on our bench; it
|
||||
may be a user-software issue rather than firmware [8]. Our driver's
|
||||
single-flight + TxId-match guard handles it either way.
|
||||
- **Concurrency**: the ECOM serializes requests internally. Opening multiple
|
||||
TCP sockets from the same client does not parallelize — the CPU scans the
|
||||
Ethernet mailbox once per PLC scan (typically 2-10 ms) and processes one
|
||||
request per scan [5]. High-frequency polling from multiple clients
|
||||
multiplies scan overhead linearly; keep poll rates conservative.
|
||||
- **Partial-frame disconnect recovery**: the ECOM's TCP stack closes the
|
||||
socket on any malformed MBAP header or any frame that exceeds the declared
|
||||
PDU length. It does not resynchronize mid-stream. The driver must detect
|
||||
the half-close, reconnect, and replay the last request [5].
|
||||
- **Keepalive**: the ECOM does **not** send TCP keepalives. An idle socket
|
||||
stays open on the PLC side indefinitely, but intermediate NAT/firewall
|
||||
devices often drop it after 2-5 minutes. Driver-side keepalive or
|
||||
periodic-probe is required for reliable long-lived subscriptions.
|
||||
- **Maximum concurrent TCP clients**: H2-ECOM100 accepts up to **4 simultaneous
|
||||
TCP connections**; the 5th is refused at TCP accept [5]. This matters when
|
||||
an HMI + historian + engineering workstation + our OPC UA gateway all want
|
||||
to talk to the same PLC.
|
||||
|
||||
Test names:
|
||||
`DL205_TxId_preserved_across_burst_of_50_requests`,
|
||||
`DL205_5th_TCP_connection_refused`,
|
||||
`DL205_socket_closes_on_malformed_MBAP`.
|
||||
|
||||
## References
|
||||
|
||||
1. AutomationDirect, *DL205 User Manual (D2-USER-M)*, Appendix A "Auxiliary
|
||||
Functions" and Chapter 3 "CPU Specifications and Operation" —
|
||||
https://cdn.automationdirect.com/static/manuals/d2userm/d2userm.html
|
||||
2. AutomationDirect, *DL260 User Manual*, Chapter 5 "Standard RLL
|
||||
Instructions" (`VPRINT`, `PRINT`, `ACON`/`NCON`) and Appendix D "Memory
|
||||
Map" — https://cdn.automationdirect.com/static/manuals/d2userm/d2userm.html
|
||||
3. Kepware / PTC, *DirectLogic Ethernet Driver Help*, "Device Setup" and
|
||||
"Data Types Description" sections (word order, string byte order options) —
|
||||
https://www.kepware.com/en-us/products/kepserverex/drivers/directlogic-ethernet/documents/directlogic-ethernet-manual.pdf
|
||||
4. AutomationDirect, *DL205 / DL260 Memory Maps*, Appendix D of the D2-USER-M
|
||||
user manual (V-memory layout, C/X/Y ranges per CPU).
|
||||
5. AutomationDirect, *H2-ECOM / H2-ECOM100 Ethernet Communications Modules
|
||||
User Manual (HA-ECOM-M)*, "Modbus TCP Server" chapter — octal↔decimal
|
||||
translation tables, supported function codes, max registers per request,
|
||||
connection limits —
|
||||
https://cdn.automationdirect.com/static/manuals/hxecomm/hxecomm.html
|
||||
6. Inductive Automation, *Ignition Modbus Driver — Address Mapping*, word
|
||||
order options (ABCD/CDAB/BADC/DCBA) —
|
||||
https://docs.inductiveautomation.com/docs/8.1/ignition-modules/opc-ua/drivers/modbus-v2
|
||||
7. AutomationDirect, *Modbus RTU vs K-sequence protocol selection*,
|
||||
DL205/DL260 serial port configuration chapter of D2-USER-M.
|
||||
8. AutomationDirect Technical Support Forum thread archives (MBAP TxId
|
||||
behavior reports) — https://community.automationdirect.com/ (search:
|
||||
"ECOM100 transaction id"). _Unconfirmed_ operator reports only.
|
||||
@@ -7,24 +7,50 @@ Basic256Sha256 endpoints and alarms are observable through
|
||||
specific before the stack can fully replace the v1 deployment, in
|
||||
rough priority order.
|
||||
|
||||
## 1. Proxy-side `IHistoryProvider` for `ReadAtTime` / `ReadEvents`
|
||||
|
||||
**Status**: Capability surface complete (PR 35). OPC UA HistoryRead service-handler
|
||||
wiring in `DriverNodeManager` remains as the next step; integration-test still
|
||||
pending.
|
||||
## 1. Proxy-side `IHistoryProvider` for `ReadAtTime` / `ReadEvents` — **DONE (PRs 35 + 38)**
|
||||
|
||||
PR 35 extended `IHistoryProvider` with `ReadAtTimeAsync` + `ReadEventsAsync`
|
||||
(default throwing implementations so existing impls keep compiling), added the
|
||||
`HistoricalEvent` + `HistoricalEventsResult` records to
|
||||
`Core.Abstractions`, and implemented both methods in `GalaxyProxyDriver` on top
|
||||
of the PR 10 / PR 11 IPC messages. Wire-to-domain mapping (`ToHistoricalEvent`)
|
||||
is unit-tested for field fidelity, null-preservation, and `DateTimeKind.Utc`.
|
||||
`HistoricalEvent` + `HistoricalEventsResult` records to `Core.Abstractions`,
|
||||
and implemented both methods in `GalaxyProxyDriver` on top of the PR 10 / PR 11
|
||||
IPC messages.
|
||||
|
||||
**Remaining**:
|
||||
- `DriverNodeManager` wires the new capability methods onto `HistoryRead`
|
||||
`AtTime` + `Events` service handlers.
|
||||
- Integration test: OPC UA client calls `HistoryReadAtTime` / `HistoryReadEvents`,
|
||||
value flows through IPC to the Host's `HistorianDataSource`, back to the client.
|
||||
PR 38 wired the OPC UA HistoryRead service-handler through
|
||||
`DriverNodeManager` by overriding `CustomNodeManager2`'s four per-kind hooks —
|
||||
`HistoryReadRawModified` / `HistoryReadProcessed` / `HistoryReadAtTime` /
|
||||
`HistoryReadEvents`. Each walks `nodesToProcess`, resolves the driver-side
|
||||
full reference from `NodeId.Identifier`, dispatches to the right
|
||||
`IHistoryProvider` method, and populates the paired results + errors lists
|
||||
(both must be set — the MasterNodeManager merges them and a Good result with
|
||||
an unset error slot serializes as `BadHistoryOperationUnsupported` on the
|
||||
wire). Historized variables gain `AccessLevels.HistoryRead` so the stack
|
||||
dispatches; the driver root folder gains `EventNotifiers.HistoryRead` so
|
||||
`HistoryReadEvents` can target it.
|
||||
|
||||
Aggregate translation uses a small `MapAggregate` helper that handles
|
||||
`Average` / `Minimum` / `Maximum` / `Total` / `Count` (the enum surface the
|
||||
driver exposes) and returns null for unsupported aggregates so the handler
|
||||
can surface `BadAggregateNotSupported`. Raw+Processed+AtTime wrap driver
|
||||
samples as `HistoryData` in an `ExtensionObject`; Events emits a
|
||||
`HistoryEvent` with the standard BaseEventType field list (EventId /
|
||||
SourceName / Message / Severity / Time / ReceiveTime) — custom
|
||||
`SelectClause` evaluation is an explicit follow-up.
|
||||
|
||||
**Tests**:
|
||||
|
||||
- `DriverNodeManagerHistoryMappingTests` — 12 unit cases pinning
|
||||
`MapAggregate`, `BuildHistoryData`, `BuildHistoryEvent`, `ToDataValue`.
|
||||
- `HistoryReadIntegrationTests` — 5 end-to-end cases drive a real OPC UA
|
||||
client (`Session.HistoryRead`) against a fake `IHistoryProvider` driver
|
||||
through the running stack. Covers raw round-trip, processed with Average
|
||||
aggregate, unsupported aggregate → `BadAggregateNotSupported`, at-time
|
||||
timestamp forwarding, and events field-list shape.
|
||||
|
||||
**Deferred**:
|
||||
- Continuation-point plumbing via `Session.Save/RestoreHistoryContinuationPoint`.
|
||||
Driver returns null continuations today so the pass-through is fine.
|
||||
- Per-`SelectClause` evaluation in HistoryReadEvents — clients that send a
|
||||
custom field selection currently get the standard BaseEventType layout.
|
||||
|
||||
## 2. Write-gating by role — **DONE (PR 26)**
|
||||
|
||||
@@ -77,18 +103,51 @@ drive a full OPC UA session with username/password, then read an
|
||||
`IHostConnectivityProbe`-style "whoami" node to verify the role surfaced).
|
||||
That needs a test-only address-space node and is a separate PR.
|
||||
|
||||
## 5. Full Galaxy live-service smoke test against the merged v2 stack
|
||||
## 5. Full Galaxy live-service smoke test against the merged v2 stack — **IN PROGRESS (PRs 36 + 37)**
|
||||
|
||||
**Status**: Individual pieces have live smoke tests (PR 5 MXAccess, PR 13
|
||||
probe manager, PR 14 alarm tracker), but the full loop — OPC UA client →
|
||||
`OtOpcUaServer` → `GalaxyProxyDriver` (in-process) → named-pipe to
|
||||
Galaxy.Host subprocess → live MXAccess runtime → real Galaxy objects — has
|
||||
no single end-to-end smoke test.
|
||||
PR 36 shipped the prerequisites helper (`AvevaPrerequisites`) that probes
|
||||
every dependency a live smoke test needs and produces actionable skip
|
||||
messages.
|
||||
|
||||
**To do**:
|
||||
- Test that spawns the full topology, discovers a deployed Galaxy object,
|
||||
subscribes to one of its attributes, writes a value back, and asserts the
|
||||
write round-tripped through MXAccess. Skip when ArchestrA isn't running.
|
||||
PR 37 shipped the live-stack smoke test project structure:
|
||||
`tests/Driver.Galaxy.Proxy.Tests/LiveStack/` with `LiveStackFixture` (connects
|
||||
to the *already-running* `OtOpcUaGalaxyHost` Windows service via named pipe;
|
||||
never spawns the Host process) and `LiveStackSmokeTests` covering:
|
||||
|
||||
- Fixture initializes successfully (IPC handshake succeeds end-to-end).
|
||||
- Driver reports `DriverState.Healthy` post-handshake.
|
||||
- `DiscoverAsync` returns at least one variable from the live Galaxy.
|
||||
- `GetHostStatuses` reports at least one Platform/AppEngine host.
|
||||
- `ReadAsync` on a discovered variable round-trips through
|
||||
Proxy → Host pipe → MXAccess → back without a BadInternalError.
|
||||
|
||||
Shared secret + pipe name resolve from `OTOPCUA_GALAXY_SECRET` /
|
||||
`OTOPCUA_GALAXY_PIPE` env vars, falling back to reading the service's
|
||||
registry-stored Environment values (requires elevated test host).
|
||||
|
||||
**PR 40** added the write + subscribe facts targeting
|
||||
`DelmiaReceiver_001.TestAttribute` (the writable Boolean UDA the dev Galaxy
|
||||
ships under TestMachine_001) — write-then-read with a 5s scan-window poll +
|
||||
restore-on-finally, and subscribe-then-write asserting both an initial-value
|
||||
OnDataChange and a post-write OnDataChange. PR 39 added the elevated-shell
|
||||
short-circuit so a developer running from an admin window gets an actionable
|
||||
skip instead of `UnauthorizedAccessException`.
|
||||
|
||||
**Run the live tests** (from a NORMAL non-admin PowerShell):
|
||||
|
||||
```powershell
|
||||
$env:OTOPCUA_GALAXY_SECRET = Get-Content C:\Users\dohertj2\Desktop\lmxopcua\.local\galaxy-host-secret.txt
|
||||
cd C:\Users\dohertj2\Desktop\lmxopcua
|
||||
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests --filter "FullyQualifiedName~LiveStackSmokeTests"
|
||||
```
|
||||
|
||||
Expected: 7/7 pass against the running `OtOpcUaGalaxyHost` service.
|
||||
|
||||
**Remaining for #5 in production-grade form**:
|
||||
- Confirm the suite passes from a non-elevated shell (operator action).
|
||||
- Add similar facts for an alarm-source attribute once `TestMachine_001` (or
|
||||
a sibling) carries a deployed alarm condition — the current dev Galaxy's
|
||||
TestAttribute isn't alarm-flagged.
|
||||
|
||||
## 6. Second driver instance on the same server — **DONE (PR 32)**
|
||||
|
||||
|
||||
451
docs/v2/mitsubishi.md
Normal file
451
docs/v2/mitsubishi.md
Normal file
@@ -0,0 +1,451 @@
|
||||
# Mitsubishi Electric MELSEC — Modbus TCP quirks
|
||||
|
||||
Mitsubishi's MELSEC family speaks Modbus TCP through a patchwork of add-on modules
|
||||
and built-in Ethernet ports, not a single unified stack. The module names are
|
||||
confusingly similar (`QJ71MB91` is *serial* RTU, `QJ71MT91` is the TCP/IP module
|
||||
[9]; `LJ71MT91` is the L-series equivalent; `RJ71EN71` is the iQ-R Ethernet module
|
||||
with a MODBUS/TCP *slave* mode bolted on [8]; `FX3U-ENET`, `FX3U-ENET-P502`,
|
||||
`FX3U-ENET-ADP`, `FX3GE` built-in, and `FX5U` built-in are all different code
|
||||
paths) — and every one of the categories below has at least one trap a textbook
|
||||
Modbus client gets wrong: hex-numbered X/Y devices colliding with decimal Modbus
|
||||
addresses, a user-defined "device assignment" parameter block that means *no two
|
||||
sites are identical*, CDAB-vs-ABCD word order driven by how the ladder built the
|
||||
32-bit value, sub-spec FC16 caps on the older QJ71MT91, and an FX3U port-502
|
||||
licensing split that makes `FX3U-ENET` and `FX3U-ENET-P502` different SKUs.
|
||||
This document catalogues each quirk, cites primary sources, and names the
|
||||
ModbusPal integration test we'd write for it (convention from
|
||||
`docs/v2/modbus-test-plan.md`: `Mitsubishi_<model>_<behavior>`).
|
||||
|
||||
## Models and server/client capability
|
||||
|
||||
| Model | Family | Modbus TCP server | Modbus TCP client | Source |
|
||||
|------------------------|----------|-------------------|-------------------|--------|
|
||||
| `QJ71MT91` | MELSEC-Q | Yes (slave) | Yes (master) | [9] |
|
||||
| `QJ71MB91` | MELSEC-Q | **Serial only** — RS-232/422/485 RTU, *not TCP* | — | [1][3] |
|
||||
| `LJ71MT91` | MELSEC-L | Yes (slave) | Yes (master) | [10] |
|
||||
| `RJ71EN71` / `RnENCPU` | MELSEC iQ-R | Yes (slave) | Yes (master) | [8] |
|
||||
| `RJ71C24` / `RJ71C24-R2` | MELSEC iQ-R | RTU (serial) | RTU (serial) | [13] |
|
||||
| iQ-R built-in Ethernet | CPU | Yes (slave) | Yes (master) | [7] |
|
||||
| iQ-F `FX5U` built-in Ethernet | CPU | Yes, firmware ≥ 1.060 [11] | Yes | [7][11][12] |
|
||||
| `FX3U-ENET` | FX3U bolt-on | Yes (slave), but **not on port 502** [5] | Yes | [4][5] |
|
||||
| `FX3U-ENET-P502` | FX3U bolt-on | Yes (slave), port 502 enabled | Yes | [5] |
|
||||
| `FX3U-ENET-ADP` | FX3U adapter | **No MODBUS** [5] | No MODBUS | [5] |
|
||||
| `FX3GE` built-in | FX3GE CPU | No MODBUS (needs ENET module) [6] | No | [6] |
|
||||
| `FX3G` + `FX3U-ENET` | FX3G | Yes via ENET module | Yes | [6] |
|
||||
|
||||
- A common integration mistake is to buy `FX3U-ENET-ADP` expecting MODBUS —
|
||||
that adapter speaks only MC protocol / SLMP. Our driver should surface a clear
|
||||
capability error, not "connection refused", when the operator's device tag
|
||||
says `FX3U-ENET-ADP` [5].
|
||||
- Older forum threads assert the FX5U is "client only" [12] — that was true on
|
||||
firmware ≤ 1.040. Firmware 1.060 and later ship the parameter-driven MODBUS
|
||||
TCP server built-in and need no function blocks [11].
|
||||
|
||||
## Modbus device assignment (the parameter block)
|
||||
|
||||
Unlike a DL260 where the CPU exposes a *fixed* V-memory-to-Modbus mapping, every
|
||||
MELSEC MODBUS-TCP module exposes a **Modbus Device Assignment Parameter** block
|
||||
that the engineer configures in GX Works2 / GX Configurator-MB / GX Works3.
|
||||
Each of the four Modbus tables (Coil, Input, Input Register, Holding Register)
|
||||
can be split into up to 16 independent "assignment" entries, each binding a
|
||||
contiguous Modbus address range to a MELSEC device head (`M0`, `D0`, `X0`,
|
||||
`Y0`, `B0`, `W0`, `SM0`, `SD0`, `R0`, etc.) and a point count [3][7][8][9].
|
||||
|
||||
- **There is no canonical "MELSEC Modbus mapping"**. Two sites running the same
|
||||
QJ71MT91 module can expose completely different Modbus layouts. Our driver
|
||||
must treat the mapping as site-data (config-file-driven), not as a device
|
||||
profile constant.
|
||||
- **Default values do exist** — both GX Configurator-MB (for Q/L series) and
|
||||
GX Works3 (for iQ-R / iQ-F / FX5) ship a "dedicated pattern" default that is
|
||||
applied when the engineer does not override the assignment. Per the FX5
|
||||
MODBUS Communication manual (JY997D56101) and the QJ71MT91 manual, the FX5
|
||||
dedicated default is [3][7][11]:
|
||||
|
||||
| Modbus table | Modbus range (0-based) | MELSEC device | Head |
|
||||
|--------------------|------------------------|---------------|------|
|
||||
| Coil (FC01/05/15) | 0 – 7679 | M | M0 |
|
||||
| Coil | 8192 – 8959 | Y | Y0 |
|
||||
| Input (FC02) | 0 – 7679 | M | M0 |
|
||||
| Input | 8192 – 8959 | X | X0 |
|
||||
| Input Register (FC04) | 0 – 6143 | D | D0 |
|
||||
| Holding Register (FC03/06/16) | 0 – 6143 | D | D0 |
|
||||
|
||||
This matches the widely circulated "FC03 @ 0 = D0" convention that shows up
|
||||
in Ubidots / Ignition / AdvancedHMI integration guides [6][12].
|
||||
|
||||
- **X/Y in the default mapping occupy a second, non-zero Modbus range** (8192+
|
||||
on FX5; similar on Q/L/iQ-R). Driver users who expect "X0 = coil 0" will be
|
||||
reading M0 instead. Document this clearly.
|
||||
- **Assignment-range collisions silently disable the slave.** The QJ71MT91
|
||||
manual states explicitly that if any two of assignments 1-16 duplicate the
|
||||
head Modbus device number, the slave function is inactive with no clear
|
||||
error — the module just won't respond [9]. The driver probe will look like a
|
||||
simple timeout; the site engineer has to open GX Configurator-MB to diagnose.
|
||||
|
||||
Test names:
|
||||
`Mitsubishi_FX5U_default_mapping_coil_0_is_M0`,
|
||||
`Mitsubishi_FX5U_default_mapping_holding_0_is_D0`,
|
||||
`Mitsubishi_QJ71MT91_duplicate_assignment_head_disables_slave`.
|
||||
|
||||
## X/Y addressing — hex on MELSEC, decimal on Modbus
|
||||
|
||||
**MELSEC X (input) and Y (output) device numbers are hexadecimal on Q / L /
|
||||
iQ-R** and **octal** on FX / iQ-F (with a GX Works3 toggle) [14][15].
|
||||
|
||||
- On a Q CPU, `X20` means decimal **32**, not 20. On an FX5U in default (octal)
|
||||
mode, `X20` means decimal **16**. GX Works3 exposes a project-level option to
|
||||
display FX5U X/Y in hex to match Q/L/iQ-R convention — the same physical
|
||||
input is then called `X10` [14].
|
||||
- The Modbus Device Assignment Parameter block takes the *head device* as a
|
||||
MELSEC-native number, which is interpreted in the CPU's native base
|
||||
(hex for Q/L/iQ-R, octal for FX/iQ-F). After that, **Modbus offsets from
|
||||
the head are plain decimal** — the module does not apply a second hex
|
||||
conversion [3][9].
|
||||
- Example (QJ71MT91 on a Q CPU): assignment "Coil 0 = X0, 512 points" exposes
|
||||
physical `X0` through `X1FF` (hex) as coils 0-511. A client reading coil 32
|
||||
gets the bit `X20` (hex) — i.e. the 33rd input, not the value at "input 20"
|
||||
that the operator wrote on the wiring diagram in decimal.
|
||||
- **Driver bug source**: if the operator's tag configuration says "read X20" and
|
||||
the driver helpfully converts "20" to decimal 20 → coil offset 20, the
|
||||
returned bit is actually `X14` (hex) — off by twelve. Our config layer must
|
||||
preserve the MELSEC-native base that the site engineer sees in GX Works.
|
||||
- Timers/counters (`T`, `C`, `ST`) are always decimal in MELSEC notation.
|
||||
Internal relays (`M`, `B`, `L`), data registers (`D`, `W`, `R`, `ZR`),
|
||||
and special relays/registers (`SM`, `SD`) also decimal. **Only `X` and `Y`
|
||||
(and on Q/L/iQ-R, `B` link relays and `W` link registers) use hex**, and
|
||||
the X/Y decision is itself family-dependent [14][15].
|
||||
|
||||
Test names:
|
||||
`Mitsubishi_Q_X_address_is_hex_X20_equals_coil_offset_32`,
|
||||
`Mitsubishi_FX5U_X_address_is_octal_X20_equals_coil_offset_16`,
|
||||
`Mitsubishi_W_link_register_is_hex_W10_equals_holding_offset_16`.
|
||||
|
||||
## Word order for 32-bit values
|
||||
|
||||
MELSEC stores 32-bit ladder values (`DINT`, `DWORD`, `REAL` / single-precision
|
||||
float) across **two consecutive D-registers, low word first** — i.e., `CDAB`
|
||||
when viewed as a Modbus register pair [2][6].
|
||||
|
||||
```
|
||||
D100 (low word) : 0xCC 0xDD (big-endian bytes within the word)
|
||||
D101 (high word) : 0xAA 0xBB
|
||||
```
|
||||
|
||||
A Modbus master reading D100/D101 as a `float` with default (ABCD) word order
|
||||
gets garbage. Ignition's built-in Modbus driver notes Mitsubishi as a "CDAB
|
||||
device" specifically for this reason [2].
|
||||
|
||||
- **Q / L / iQ-R / iQ-F all agree** — this is a CPU-level convention, not a
|
||||
module choice. Both the QJ71MT91 manual and the FX5 MODBUS Communication
|
||||
manual describe 32-bit access by "reading the lower 16 bits from the start
|
||||
address and the upper 16 bits from start+1" [6][11].
|
||||
- **Byte order within each register is big-endian** (Modbus standard). The
|
||||
module does not byte-swap.
|
||||
- **Configurable?** The MODBUS modules themselves do **not** expose a word-
|
||||
order toggle; the behavior is fixed to how the CPU laid out the value in the
|
||||
two D-registers. If the ladder programmer used an `SWAP` instruction or a
|
||||
union-style assignment, the word order can be whatever they made it — but
|
||||
for values produced by the standard `D→DBL` and `FLT`/`FLT2` instructions
|
||||
it is always CDAB [2].
|
||||
- **FX5U quirk**: the FX5 MODBUS Communication manual tells the programmer to
|
||||
use the `SWAP` instruction *if* the remote Modbus peer requires
|
||||
little-endian *byte* ordering (BADC) [11]. This is only relevant when the
|
||||
FX5U is the Modbus *client*, but it confirms the FX5U's native wire layout
|
||||
is big-endian-byte / little-endian-word (CDAB) on the server side too.
|
||||
- **Rumoured exception**: a handful of MrPLC forum threads report iQ-R
|
||||
RJ71EN71 firmware < 1.05 returning DWORDs in `ABCD` order when accessed via
|
||||
the built-in Ethernet port's MODBUS slave [8]. _Unconfirmed_; treat as a
|
||||
per-site test.
|
||||
|
||||
Test names:
|
||||
`Mitsubishi_Float32_word_order_is_CDAB`,
|
||||
`Mitsubishi_Int32_word_order_is_CDAB`,
|
||||
`Mitsubishi_FX5U_SWAP_instruction_changes_byte_order_not_word_order`.
|
||||
|
||||
## BCD vs binary encoding
|
||||
|
||||
**MELSEC stores integer values in D-registers as plain binary two's-complement**,
|
||||
not BCD [16]. This is the opposite of AutomationDirect DirectLOGIC, where
|
||||
V-memory defaults to BCD and the ladder must explicitly request binary.
|
||||
|
||||
- A ladder `MOV K1234 D100` stores `0x04D2` (1234 decimal) in D100, not
|
||||
`0x1234`. The Modbus master reads `0x04D2` and decodes it as an integer
|
||||
directly — no BCD conversion needed [16].
|
||||
- **Timer / counter current values** (`T0` current value, `C0` count) are
|
||||
stored in binary as word devices on Q/L/iQ-R/iQ-F. The ladder preset
|
||||
(`K...`) is also binary [16][17].
|
||||
- **Timer / counter preset `K` operand in FX3U / earlier FX**: also binary when
|
||||
loaded from a D-register or a `K` constant. The older A-series CPUs had BCD
|
||||
presets on some timer types, but MELSEC-Q, L, iQ-R, iQ-F, and FX3U all use
|
||||
binary presets by default [17].
|
||||
- The FX3U programming manual dedicates `FNC 18 BCD` and `FNC 19 BIN` to
|
||||
explicit conversion — their existence confirms that anything in D-registers
|
||||
that came from a `BCD` instruction output is BCD, but nothing is BCD by
|
||||
default [17].
|
||||
- **7-segment display registers** are a common site-specific exception — many
|
||||
ladders pack `BCD D100` into a D-register so the operator panel can drive
|
||||
a display directly. Our driver should not assume; expose a per-tag
|
||||
"encoding = binary | BCD" knob.
|
||||
|
||||
Test names:
|
||||
`Mitsubishi_D_register_stores_binary_not_BCD`,
|
||||
`Mitsubishi_FX3U_timer_current_value_is_binary`.
|
||||
|
||||
## Max registers per request
|
||||
|
||||
From the FX5 MODBUS Communication manual Chapter 11 [11]:
|
||||
|
||||
| FC | Name | FX5U (built-in) | QJ71MT91 | iQ-R (RJ71EN71 / built-in) | FX3U-ENET |
|
||||
|----|----------------------------|-----------------|--------------|-----------------------------|-----------|
|
||||
| 01 | Read Coils | 1-2000 | 1-2000 [9] | 1-2000 [8] | 1-2000 |
|
||||
| 02 | Read Discrete Inputs | 1-2000 | 1-2000 | 1-2000 | 1-2000 |
|
||||
| 03 | Read Holding Registers | **1-125** | 1-125 [9] | 1-125 [8] | 1-125 |
|
||||
| 04 | Read Input Registers | 1-125 | 1-125 | 1-125 | 1-125 |
|
||||
| 05 | Write Single Coil | 1 | 1 | 1 | 1 |
|
||||
| 06 | Write Single Register | 1 | 1 | 1 | 1 |
|
||||
| 0F | Write Multiple Coils | 1-1968 | 1-1968 | 1-1968 | 1-1968 |
|
||||
| 10 | Write Multiple Registers | **1-123** | 1-123 | 1-123 | 1-123 |
|
||||
| 16 | Mask Write Register | 1 | not supported | 1 | not supported |
|
||||
| 17 | Read/Write Multiple Regs | R:1-125, W:1-121 | not supported | R:1-125, W:1-121 | not supported |
|
||||
|
||||
- **The FX5U / iQ-R native-port limits match the Modbus spec**: 125 for FC03/04,
|
||||
123 for FC16 [11]. No sub-spec caps like DL260's 100-register ceiling.
|
||||
- **QJ71MT91 does not support FC16 (0x16, Mask Write Register) or FC17
|
||||
(0x17, Read/Write Multiple)** — requesting them returns exception `01`
|
||||
Illegal Function [9]. FX5U and iQ-R *do* support both.
|
||||
- **QJ71MT91 device size**: 64k points (65,536) for each of Coil / Input /
|
||||
Input Register / Holding Register, plus up to 4086k points for Extended
|
||||
File Register via a secondary assignment range [9].
|
||||
- **FX3U-ENET / -P502 function code list is a strict subset** of the common
|
||||
eight (FC01/02/03/04/05/06/0F/10). FC16 and FC17 not supported [4].
|
||||
|
||||
Test names:
|
||||
`Mitsubishi_FX5U_FC03_126_registers_returns_IllegalDataValue`,
|
||||
`Mitsubishi_FX5U_FC16_124_registers_returns_IllegalDataValue`,
|
||||
`Mitsubishi_QJ71MT91_FC16_MaskWrite_returns_IllegalFunction`,
|
||||
`Mitsubishi_QJ71MT91_FC23_ReadWrite_returns_IllegalFunction`.
|
||||
|
||||
## Exception codes
|
||||
|
||||
MELSEC MODBUS modules return **only the standard Modbus exception codes 01-04**;
|
||||
no proprietary exception codes are exposed on the wire [8][9][11]. Module-
|
||||
internal diagnostics (buffer-memory error codes like `7380H`) are logged but
|
||||
not returned as Modbus exceptions.
|
||||
|
||||
| Code | Name | MELSEC trigger |
|
||||
|------|----------------------|---------------------------------------------------------|
|
||||
| 01 | Illegal Function | FC17 or FC16 on QJ71MT91/FX3U; FC08 (Diagnostics); FC43 |
|
||||
| 02 | Illegal Data Address | Modbus address outside any assignment range |
|
||||
| 03 | Illegal Data Value | Quantity out of per-FC range (see table above); odd coil-byte count |
|
||||
| 04 | Server Device Failure | See below |
|
||||
|
||||
- **04 (Server Failure) triggers on MELSEC**:
|
||||
- CPU in STOP or PAUSE during a write to an assignment whose "Access from
|
||||
External Device" permission is set to "Disabled in STOP" [9][11].
|
||||
*With the default "always enabled" setting the write succeeds in STOP
|
||||
mode* — another common trap.
|
||||
- CPU errors (parameter error, watchdog) during any access.
|
||||
- Assignment points to a device range that is not configured (e.g. write
|
||||
to `D16384` when CPU D-device size is 12288).
|
||||
- **Write to a "System Area" device** (e.g., `SD` special registers that are
|
||||
CPU-reserved read-only) returns `04`, not `02`, on QJ71MT91 and iQ-R — the
|
||||
assignment is valid, the device exists, but the CPU rejects the write [8][9].
|
||||
- **FX3U-ENET / -P502** returns `04` on any write attempt while the CPU is in
|
||||
STOP, regardless of permission settings — the older firmware does not
|
||||
implement the "Access from External Device" granularity that Q/L/iQ-R/iQ-F
|
||||
expose [4].
|
||||
- **No rumour of proprietary codes 05-0B** from MELSEC; operators sometimes
|
||||
report "exception 0A" but those traces all came from a third-party gateway
|
||||
sitting between the master and the MELSEC module.
|
||||
|
||||
Test names:
|
||||
`Mitsubishi_QJ71MT91_STOP_mode_write_with_Disabled_permission_returns_ServerFailure`,
|
||||
`Mitsubishi_QJ71MT91_STOP_mode_write_with_default_permission_succeeds`,
|
||||
`Mitsubishi_SD_system_register_write_returns_ServerFailure`,
|
||||
`Mitsubishi_FX3U_STOP_mode_write_always_returns_ServerFailure`.
|
||||
|
||||
## Connection behavior
|
||||
|
||||
Max simultaneous Modbus TCP clients, per module [7][8][9][11]:
|
||||
|
||||
| Model | Max TCP connections | Port 502 | Keepalive | Source |
|
||||
|----------------------|---------------------|----------|-----------|--------|
|
||||
| `QJ71MT91` | 16 (shared with master role) | Yes | No | [9] |
|
||||
| `LJ71MT91` | 16 | Yes | No | [10] |
|
||||
| iQ-R built-in / `RJ71EN71` | 16 | Yes | Configurable (KeepAlive = ON in parameter) | [8] |
|
||||
| iQ-F `FX5U` built-in | 8 | Yes | Configurable | [7][11] |
|
||||
| `FX3U-ENET` | 8 TCP, but **not port 502** | No (port < 1024 blocked) | No | [4][5] |
|
||||
| `FX3U-ENET-P502` | 8, port 502 enabled | Yes | No | [5] |
|
||||
|
||||
- **QJ71MT91's 16 is total connections shared between slave-listen and
|
||||
master-initiated sockets** [9]. A site that uses the same module as both
|
||||
master to downstream VFDs and slave to upstream SCADA splits the 16 pool.
|
||||
- **FX3U-ENET port-502 gotcha**: if the engineer loads a configuration with
|
||||
port 502 into a non-P502 ENET module, GX Works shows the download as
|
||||
successful; on next power cycle the module enters error state and the
|
||||
MODBUS listener never starts. This is documented on third-party FX3G
|
||||
integration guides [6].
|
||||
- **CPU STOP → RUN transition**: does **not** drop Modbus connections on any
|
||||
MELSEC family. Existing sockets stay open; outstanding requests during the
|
||||
transition may see exception 04 for a few scans but then resume [8][9].
|
||||
- **CPU reset (power cycle or `SM1255` forced reset)** drops all Modbus
|
||||
connections and the module re-listens after typically 5-10 seconds.
|
||||
- **Idle timeout**: QJ71MT91 and iQ-R have a per-connection "Alive-Check"
|
||||
(idle timer) parameter, default 0 (disabled). If enabled, default 10 s
|
||||
probe interval, 3 retries before close [8][9]. FX5U similar defaults.
|
||||
- **Keep-alive (TCP-level)**: only iQ-R / iQ-F expose a TCP keep-alive option
|
||||
(parameter "KeepAlive" in the Ethernet settings); QJ71MT91 and FX3U-ENET
|
||||
do not — so NAT/firewall idle drops require driver-side pinging.
|
||||
|
||||
Test names:
|
||||
`Mitsubishi_QJ71MT91_17th_connection_refused`,
|
||||
`Mitsubishi_FX5U_9th_connection_refused`,
|
||||
`Mitsubishi_STOP_to_RUN_transition_preserves_socket`,
|
||||
`Mitsubishi_CPU_reset_closes_all_sockets`.
|
||||
|
||||
## Behavioral oddities
|
||||
|
||||
- **Transaction ID echo**: QJ71MT91 and iQ-R reliably echo the MBAP TxId on
|
||||
every response across firmware revisions; no reports of TxId drops under
|
||||
load [8][9]. FX3U-ENET has an older, less-tested TCP stack; at least one
|
||||
MrPLC thread reports out-of-order TxId echoes under heavy polling on
|
||||
firmware < 1.14 [4]. _Unconfirmed_ on current firmware.
|
||||
- **Per-connection request serialization**: all MELSEC slaves serialize
|
||||
requests within a single TCP connection — a new request is not processed
|
||||
until the prior response has been sent. Pipelining multiple requests on one
|
||||
socket causes the module to queue them in buffer memory and respond in
|
||||
order, but **the queue depth is 1** on QJ71MT91 (a second in-flight request
|
||||
is held on the TCP receive buffer, not queued) [9]. Driver should treat
|
||||
Mitsubishi slaves as strictly single-flight per socket.
|
||||
- **Partial-frame handling**: QJ71MT91 and iQ-R close the socket on malformed
|
||||
MBAP length fields. FX5U resynchronises at the next valid MBAP header
|
||||
within 100 ms but will emit an error to `SD` diagnostics [11]. Driver must
|
||||
reconnect on half-close and replay.
|
||||
- **FX3U UDP vs TCP**: `FX3U-ENET` supports both UDP and TCP MODBUS transports;
|
||||
UDP is lossy and reorders under load. Default is TCP. Some legacy SCADA
|
||||
configurations pinned the module to UDP for multicast discovery — do not
|
||||
select UDP unless the site requires it [4].
|
||||
- **Known firmware-revision variants**:
|
||||
- QJ71MT91 ≤ firmware 10052000000 (year-month format): FC15 with coil
|
||||
count that forces byte-count to an odd value silently truncates the
|
||||
last coil. Fixed in later revisions [9]. _Operator-reported_.
|
||||
- FX5U firmware < 1.060: no native MODBUS TCP server — only accessible via
|
||||
a predefined-protocol function block hack. Firmware ≥ 1.060 ships
|
||||
parameter-based server. Our capability probe should read `SD203`
|
||||
(firmware version) and flag < 1.060 as unsupported for server mode [11][12].
|
||||
- iQ-R RJ71EN71 early firmware: possible ABCD word order (rumoured,
|
||||
unconfirmed) [8].
|
||||
- **SD (special-register) reads during assignment-parameter load**: while
|
||||
the CPU is loading a new MODBUS device assignment parameter (~1-2 s), the
|
||||
slave returns exception 04 Server Failure on every request. Happens after
|
||||
a parameter write from GX Configurator-MB [9].
|
||||
- **iQ-R "Station-based block transfer" collision**: if the RJ71EN71 is also
|
||||
running CC-Link IE Control on the same module, a MODBUS/TCP request that
|
||||
arrives during a CCIE cyclic period is delayed to the next scan — visible
|
||||
as jittery response time, not a failure [8].
|
||||
|
||||
Test names:
|
||||
`Mitsubishi_QJ71MT91_single_flight_per_socket`,
|
||||
`Mitsubishi_FX5U_malformed_MBAP_resync_within_100ms`,
|
||||
`Mitsubishi_FX3U_TxId_preserved_across_burst`,
|
||||
`Mitsubishi_FX5U_firmware_below_1_060_reports_no_server_mode`.
|
||||
|
||||
## Model-specific differences for test coverage
|
||||
|
||||
Summary of which quirks differ per model, so test-class naming can reflect them:
|
||||
|
||||
| Quirk | QJ71MT91 | LJ71MT91 | iQ-R (RJ71EN71 / built-in) | iQ-F (FX5U) | FX3U-ENET(-P502) |
|
||||
|------------------------------------------|----------|----------|----------------------------|-------------|------------------|
|
||||
| FC16 Mask-Write supported | No | No | Yes | Yes | No |
|
||||
| FC17 Read/Write Multiple supported | No | No | Yes | Yes | No |
|
||||
| Max connections | 16 | 16 | 16 | 8 | 8 |
|
||||
| X/Y numbering base | hex | hex | hex | octal (default) | octal |
|
||||
| 32-bit word order | CDAB | CDAB | CDAB (firmware-dependent rumour of ABCD) | CDAB | CDAB |
|
||||
| Port 502 supported | Yes | Yes | Yes | Yes | P502 only |
|
||||
| STOP-mode write permission configurable | Yes | Yes | Yes | Yes | No (always blocks) |
|
||||
| TCP keep-alive parameter | No | No | Yes | Yes | No |
|
||||
| Modbus device assignment — max entries | 16 | 16 | 16 | 16 | 8 |
|
||||
| Server via parameter (no FB) | Yes | Yes | Yes | Yes (fw ≥ 1.060) | Yes |
|
||||
|
||||
- **Test file layout**: `Mitsubishi_QJ71MT91_*`, `Mitsubishi_LJ71MT91_*`,
|
||||
`Mitsubishi_iQR_*`, `Mitsubishi_FX5U_*`, `Mitsubishi_FX3U_ENET_*`,
|
||||
`Mitsubishi_FX3U_ENET_P502_*`. iQ-R built-in Ethernet and the RJ71EN71
|
||||
behave identically for MODBUS/TCP slave purposes and can share a file
|
||||
`Mitsubishi_iQR_*`.
|
||||
- **Cross-model shared tests** (word order CDAB, binary not BCD, standard
|
||||
exception codes, 125-register FC03 cap) can live in a single
|
||||
`Mitsubishi_Common_*` fixture.
|
||||
|
||||
## References
|
||||
|
||||
1. Mitsubishi Electric, *MODBUS Interface Module User's Manual — QJ71MB91*
|
||||
(SH-080578ENG), RS-232/422/485 MODBUS RTU serial module for MELSEC-Q —
|
||||
https://dl.mitsubishielectric.com/dl/fa/document/manual/plc/sh080578eng/sh080578engk.pdf
|
||||
2. Inductive Automation, *Ignition Modbus Driver — Mitsubishi Q / iQ-R word
|
||||
order*, documents CDAB convention —
|
||||
https://docs.inductiveautomation.com/docs/8.1/ignition-modules/opc-ua/drivers/modbus-v2
|
||||
and forum discussion https://forum.inductiveautomation.com/t/modbus-tcp-device-word-byte-order/65984
|
||||
3. Mitsubishi Electric, *Programmable Controller User's Manual QJ71MB91 MODBUS
|
||||
Interface Module*, Chapter 7 "Parameter Setting" describing the Modbus
|
||||
Device Assignment Parameter block (assignments 1-16, head-device
|
||||
configuration) —
|
||||
https://www.lcautomation.com/dbdocument/29156/QJ71MB91%20Users%20manual.pdf
|
||||
4. Mitsubishi Electric, *FX3U-ENET User's Manual* (JY997D18101), Chapter on
|
||||
MODBUS/TCP communication; function code support and connection limits —
|
||||
https://dl.mitsubishielectric.com/dl/fa/document/manual/plc_fx/jy997d18101/jy997d18101h.pdf
|
||||
5. Venus Automation, *Mitsubishi FX3U-ENET-P502 Module — Open Port 502 for
|
||||
Modbus TCP/IP* —
|
||||
https://venusautomation.com.au/mitsubishi-fx3u-enet-p502-module-open-port-502-for-modbus-tcp-ip/
|
||||
and FX3U-ENET-ADP user manual (JY997D45801), which confirms the -ADP
|
||||
variant does not support MODBUS —
|
||||
https://dl.mitsubishielectric.com/dl/fa/document/manual/plc_fx/jy997d45801/jy997d45801h.pdf
|
||||
6. XML Control / Ubidots integration notes, *FX3G Modbus* — port-502 trap,
|
||||
D-register mapping default, word order reference —
|
||||
https://sites.google.com/site/xmlcontrol/archive/fx3g-modbus
|
||||
and https://ubidots.com/blog/mitsubishi-plc-as-modbus-tcp-server/
|
||||
7. FA Support Me, *Modbus TCP on Built-in Ethernet port in iQ-F and iQ-R* —
|
||||
confirms 16-connection limit on iQ-R, 8 on iQ-F, parameter-driven
|
||||
configuration via GX Works3 —
|
||||
https://www.fasupportme.com/portal/en/kb/articles/modbus-tcp-on-build-in-ethernet-port-in-iq-f-and-iq-r-en
|
||||
8. Mitsubishi Electric, *MELSEC iQ-R Ethernet User's Manual (Application)*
|
||||
(SH-081259ENG) and *MELSEC iQ-RJ71EN71 User's Manual* Chapter on
|
||||
"Communications Using Modbus/TCP" —
|
||||
https://www.allied-automation.com/wp-content/uploads/2015/02/MITSUBISHI_manual_plc_iq-r_ethernet_users.pdf
|
||||
and https://www.manualslib.com/manual/1533351/Mitsubishi-Electric-Melsec-Iq-Rj71en71.html?page=109
|
||||
9. Mitsubishi Electric, *MODBUS/TCP Interface Module User's Manual — QJ71MT91*
|
||||
(SH-080446ENG), exception codes page 248, device assignment parameter
|
||||
pages 116-124, duplicate-assignment-disables-slave note —
|
||||
https://dl.mitsubishielectric.com/dl/fa/document/manual/plc/sh080446eng/sh080446engj.pdf
|
||||
10. Mitsubishi Electric, *MELSEC-L Network Features* — LJ71MT91 documented as
|
||||
L-series equivalent of QJ71MT91 with identical MODBUS/TCP behavior —
|
||||
https://us.mitsubishielectric.com/fa/en/products/cnt/programmable-controllers/melsec-l-series/network/features/
|
||||
11. Mitsubishi Electric, *MELSEC iQ-F FX5 User's Manual (MODBUS Communication)*
|
||||
(JY997D56101), Chapter 11 "Modbus/TCP Communication Specifications" —
|
||||
function code max-quantity table, frame specification, device assignment
|
||||
defaults —
|
||||
https://dl.mitsubishielectric.com/dl/fa/document/manual/plcf/jy997d56101/jy997d56101h.pdf
|
||||
12. MrPLC forum, *FX5U Modbus-TCP Server (Slave)*, firmware ≥ 1.60 enables
|
||||
native server via parameter; earlier firmware required function block —
|
||||
https://mrplc.com/forums/topic/31883-fx5u-modbus-tcp-server-slave/
|
||||
and Industrial Monitor Direct's "FX5U MODBUS TCP Server Workaround"
|
||||
article (reflects older firmware behavior) —
|
||||
https://industrialmonitordirect.com/blogs/knowledgebase/mitsubishi-fx5u-modbus-tcp-server-configuration-workaround
|
||||
13. Mitsubishi Electric, *MELSEC iQ-R MODBUS and MODBUS/TCP Reference Manual —
|
||||
RJ71C24 / RJ71C24-R2* (BCN-P5999-1060) — RJ71C24 is serial RTU only,
|
||||
not TCP —
|
||||
https://dl.mitsubishielectric.com/dl/fa/document/manual/plc/bcn-p5999-1060/bcnp59991060b.pdf
|
||||
14. HMS Industrial Networks, *eWON and Mitsubishi FX5U PLC* (KB-0264-00) —
|
||||
documents that FX5U X/Y are octal in GX Works3 but hex when viewed as a
|
||||
Q-series PLC through eWON; the project-level hex/octal toggle —
|
||||
https://hmsnetworks.blob.core.windows.net/www/docs/librariesprovider10/downloads-monitored/manuals/knowledge-base/kb-0264-00-en-ewon-and-mitsubishi-fx5u-plc.pdf
|
||||
15. Fernhill Software, *Mitsubishi Melsec PLC Data Address* — documents
|
||||
hex-vs-octal device numbering split across MELSEC families —
|
||||
https://www.fernhillsoftware.com/help/drivers/mitsubishi-melsec/data-address-format.html
|
||||
16. Inductive Automation support, *Understanding Mitsubishi PLCs* — D registers
|
||||
store signed 16-bit binary, not BCD; DINT combines two consecutive D
|
||||
registers —
|
||||
https://support.inductiveautomation.com/hc/en-us/articles/16517576753165-Understanding-Mitsubishi-PLCs
|
||||
17. Mitsubishi Electric, *FXCPU Structured Programming Manual [Device &
|
||||
Common]* (JY997D26001) — FNC 18 BCD and FNC 19 BIN explicit-conversion
|
||||
instructions confirm binary-by-default storage —
|
||||
https://dl.mitsubishielectric.com/dl/fa/document/manual/plc_fx/jy997d26001/jy997d26001l.pdf
|
||||
@@ -13,55 +13,61 @@ confirmed DL205 quirk lands in a follow-up PR as a named test in that project.
|
||||
|
||||
## Harness
|
||||
|
||||
**Chosen simulator: ModbusPal** (Java, scriptable). Rationale:
|
||||
- Scriptable enough to mimic device-specific behaviors (non-standard register
|
||||
layouts, custom exception codes, intentional response delays).
|
||||
- Runs locally, no CI dependency. Tests skip when `localhost:502` (or the configured
|
||||
simulator endpoint) isn't reachable.
|
||||
- Free + long-maintained — physical PLC bench is unavailable in most dev
|
||||
environments, and renting cloud PLCs isn't worth the per-test cost.
|
||||
**Chosen simulator: pymodbus 3.13.0** (`pip install 'pymodbus[simulator]==3.13.0'`).
|
||||
Replaced ModbusPal in PR 43 — see `tests/.../Pymodbus/README.md` for the
|
||||
trade-off rationale. Headline reasons:
|
||||
|
||||
**Setup pattern** (not yet codified in a script — will land alongside the integration
|
||||
test project):
|
||||
1. Install ModbusPal, load the per-device `.xmpp` profile from
|
||||
`tests/Driver.Modbus.IntegrationTests/ModbusPal/` (TBD directory).
|
||||
2. Start the simulator listening on `localhost:502` (or override via
|
||||
`MODBUS_SIM_ENDPOINT` env var).
|
||||
3. `dotnet test` the integration project — tests auto-skip when the endpoint is
|
||||
unreachable, so forgetting to start the simulator doesn't wedge CI.
|
||||
- **Headless** pure-Python CLI; no Java GUI, runs cleanly on a CI runner.
|
||||
- **Maintained** — current stable 3.13.0; ModbusPal 1.6b is abandoned.
|
||||
- **All four standard tables** (HR, IR, coils, DI) configurable; ModbusPal
|
||||
1.6b only exposed HR + coils.
|
||||
- **Built-in actions** (`increment`, `random`, `timestamp`, `uptime`) +
|
||||
optional custom-Python actions for declarative dynamic behaviors.
|
||||
- **Per-register raw uint16 seeding** — encoding the DL205 string-byte-order
|
||||
/ BCD / CDAB-float quirks stays explicit (the quirk math lives in the
|
||||
`_quirk` JSON-comment fields next to each register).
|
||||
- Pip-installable on Windows; sidesteps the privileged-port admin
|
||||
requirement by defaulting to TCP **5020** instead of 502.
|
||||
|
||||
**Setup pattern**:
|
||||
1. `pip install "pymodbus[simulator]==3.13.0"`.
|
||||
2. Start the simulator with one of the in-repo profiles:
|
||||
`tests\.../Pymodbus\serve.ps1 -Profile standard` (or `-Profile dl205`).
|
||||
3. `dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests` —
|
||||
tests auto-skip when the endpoint is unreachable. Default endpoint is
|
||||
`localhost:5020`; override via `MODBUS_SIM_ENDPOINT` for a real PLC on its
|
||||
native port 502.
|
||||
|
||||
## Per-device quirk catalog
|
||||
|
||||
### AutomationDirect DL205
|
||||
### AutomationDirect DL205 / DL260
|
||||
|
||||
First known target device. Quirks to document and cover with named tests (to be
|
||||
filled in when user validates each behavior in ModbusPal with a DL205 profile):
|
||||
First known target device family. **Full quirk catalog with primary-source citations
|
||||
and per-quirk integration-test names lives at [`dl205.md`](dl205.md)** — that doc is
|
||||
the reference; this section is the testing roadmap.
|
||||
|
||||
- **Word order for 32-bit values**: _pending_ — confirm whether DL205 uses ABCD
|
||||
(Modbus TCP standard) or CDAB (Siemens-style word-swap) for Int32/UInt32/Float32.
|
||||
Test name: `DL205_Float32_word_order_is_CDAB` (or `ABCD`, whichever proves out).
|
||||
- **Register-zero access**: _pending_ — some DL205 configurations reject FC03 at
|
||||
register 0 with exception code 02 (illegal data address). If confirmed, the
|
||||
integration test suite verifies `ModbusProbeOptions.ProbeAddress` default of 0
|
||||
triggers the rejection and operators must override; test name:
|
||||
`DL205_FC03_at_register_0_returns_IllegalDataAddress`.
|
||||
- **Coil addressing base**: _pending_ — DL205 documentation sometimes uses 1-based
|
||||
coil addresses; verify the driver's zero-based addressing matches the physical
|
||||
PLC without an off-by-one adjustment.
|
||||
- **Maximum registers per FC03**: _pending_ — Modbus spec caps at 125; some DL205
|
||||
models enforce a lower limit (e.g., 64). Test name:
|
||||
`DL205_FC03_beyond_max_registers_returns_IllegalDataValue`.
|
||||
- **Response framing under sustained load**: _pending_ — the driver's
|
||||
single-flight semaphore assumes the server pairs requests/responses by
|
||||
transaction id; at least one DL205 firmware revision is reported to drop the
|
||||
TxId under load. If reproduced in ModbusPal we add a retry + log-and-continue
|
||||
path to `ModbusTcpTransport`.
|
||||
- **Exception code on coil write to a protected bit**: _pending_ — some DL205
|
||||
setups protect internal coils; the driver should surface the PLC's exception
|
||||
PDU as `BadNotWritable` rather than `BadInternalError`.
|
||||
Confirmed quirks (priority order — top items are highest-impact for our driver
|
||||
and ship first as PR 41+):
|
||||
|
||||
_User action item_: as each quirk is validated in ModbusPal, replace the _pending_
|
||||
marker with the confirmed behavior and file a named test in the integration suite.
|
||||
| Quirk | Driver impact | Integration-test name |
|
||||
|---|---|---|
|
||||
| **String packing**: 2 chars/register, **first char in low byte** (opposite of generic Modbus) | `ModbusDataType.String` decoder must be configurable per-device family — current code assumes high-byte-first | `DL205_String_low_byte_first_within_register` |
|
||||
| **Word order CDAB** for Int32/UInt32/Float32 | Already configurable via `ModbusByteOrder.WordSwap`; default per device profile | `DL205_Int32_word_order_is_CDAB` |
|
||||
| **BCD-as-default** numeric storage (only IEEE 754 when ladder uses `R` type) | New decoder mode — register reads as `0x1234` for ladder value `1234`, not as decimal `4660` | `DL205_BCD_register_decodes_as_hex_nibbles` |
|
||||
| **FC16 capped at 100 registers** (below the spec's 123) | Bulk-write batching must cap per-device-family | `DL205_FC16_101_registers_returns_IllegalDataValue` |
|
||||
| **FC03/04 capped at 128** (above the spec's 125) | Less impactful — clients that respect the spec's 125 stay safe | `DL205_FC03_129_registers_returns_IllegalDataValue` |
|
||||
| **V-memory octal-to-decimal addressing** (V2000 octal → 0x0400 decimal) | New address-format helper in profile config so operators can write `V2000` instead of computing `1024` themselves | `DL205_Vmem_V2000_maps_to_PDU_0x0400` |
|
||||
| **C-relay → coil 3072 / Y-output → coil 2048** offsets | Hard-coded constants in DL205 device profile | `DL205_C0_maps_to_coil_3072`, `DL205_Y0_maps_to_coil_2048` |
|
||||
| **Register 0 is valid** (rejects-register-0 rumour was DL05/DL06 relative-mode artefact) | None — current default is safe | `DL205_FC03_register_0_returns_V0_contents` |
|
||||
| **Max 4 simultaneous TCP clients** on H2-ECOM100 | Connect-time: handle TCP-accept failure with a clearer error message | `DL205_5th_TCP_connection_refused` |
|
||||
| **No TCP keepalive** | Driver-side periodic-probe (already wired via `IHostConnectivityProbe`) | _Covered by existing `ModbusProbeTests`_ |
|
||||
| **No mid-stream resync on malformed MBAP** | Already covered — single-flight + reconnect-on-error | _Covered by existing `ModbusDriverTests`_ |
|
||||
| **Write-protect exception code: `02` newer / `04` older** | Translate either to `BadNotWritable` | `DL205_FC06_in_ProgramMode_returns_ServerFailure` |
|
||||
|
||||
_Operator-reported / unconfirmed_ — covered defensively in the driver but no
|
||||
integration tests until reproduced on hardware:
|
||||
- TxId drop under load (forum rumour; not reproduced).
|
||||
- Pre-2004 firmware ABCD word order (every shipped DL205/DL260 since 2004 is CDAB).
|
||||
|
||||
### Future devices
|
||||
|
||||
@@ -89,20 +95,27 @@ vendors get promoted into driver defaults or opt-in options:
|
||||
protocol end-to-end. The in-memory `FakeTransport` from the unit test suite is
|
||||
deliberately not used here — its value is speed + determinism, which doesn't
|
||||
help reproduce device-specific issues.
|
||||
- **Don't depend on ModbusPal state between tests.** Each test resets the
|
||||
- **Don't depend on simulator state between tests.** Each test resets the
|
||||
simulator's register bank or uses a unique address range. Avoid relying on
|
||||
"previous test left value at register 10" setups that flake when tests run in
|
||||
parallel or re-order.
|
||||
parallel or re-order. Either the test mutates the scratch ranges and restores
|
||||
on finally, or it uses pymodbus's REST API to reset state between facts.
|
||||
|
||||
## Next concrete PRs
|
||||
|
||||
- **PR 30 — Integration test project + DL205 profile scaffold** — **DONE**.
|
||||
Shipped `tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests` with
|
||||
`ModbusSimulatorFixture` (TCP-probe, skips with a clear `SkipReason` when the
|
||||
endpoint is unreachable), `DL205/DL205Profile.cs` (tag map stub — one
|
||||
writable holding register at address 100), and `DL205/DL205SmokeTests.cs`
|
||||
(write-then-read round-trip). `ModbusPal/` directory holds the README
|
||||
pointing at the to-be-committed `DL205.xmpp` profile.
|
||||
- **PR 31+**: one PR per confirmed DL205 quirk, landing the named test + any
|
||||
driver-side adjustment (e.g., retry on dropped TxId) needed to pass it. Drop
|
||||
the `DL205.xmpp` profile into `ModbusPal/` alongside the first quirk PR.
|
||||
endpoint is unreachable), `DL205/DL205Profile.cs` (tag map stub), and
|
||||
`DL205/DL205SmokeTests.cs` (write-then-read round-trip).
|
||||
- **PR 41 — DL205 quirk catalog doc** — **DONE**. `docs/v2/dl205.md`
|
||||
documents every DL205/DL260 Modbus divergence with primary-source citations.
|
||||
- **PR 42 — ModbusPal `.xmpp` profiles** — **SUPERSEDED by PR 43**. Replaced
|
||||
with pymodbus JSON because ModbusPal 1.6b is abandoned, GUI-only, and only
|
||||
exposes 2 of the 4 standard tables.
|
||||
- **PR 43 — pymodbus JSON profiles** — **DONE**. `Pymodbus/standard.json` +
|
||||
`Pymodbus/dl205.json` + `Pymodbus/serve.ps1` runner. Both bind TCP 5020.
|
||||
- **PR 44+**: one PR per confirmed DL205 quirk, landing the named test + any
|
||||
driver-side adjustment (string byte order, BCD decoder, V-memory address
|
||||
helper, FC16 cap-per-device-family) needed to pass it. Each quirk's value
|
||||
is already pre-encoded in `Pymodbus/dl205.json`.
|
||||
|
||||
485
docs/v2/s7.md
Normal file
485
docs/v2/s7.md
Normal file
@@ -0,0 +1,485 @@
|
||||
# Siemens SIMATIC S7 (S7-1200 / S7-1500 / S7-300 / S7-400 / ET 200SP) — Modbus TCP quirks
|
||||
|
||||
Siemens S7 PLCs do *not* speak Modbus TCP natively at the OS/firmware level. Every
|
||||
S7 Modbus-TCP-server deployment is either (a) the **`MB_SERVER`** library block
|
||||
running on the CPU's PROFINET port (S7-1200 / S7-1500 / CPU 1510SP-series
|
||||
ET 200SP), or (b) the **`MODBUSCP`** function block running on a separate
|
||||
communication processor (**CP 343-1 / CP 343-1 Lean** on S7-300, **CP 443-1** on
|
||||
S7-400), or (c) the **`MODBUSPN`** block on an S7-1500 PN port via a licensed
|
||||
library. That means the quirks a Modbus client has to cope with are as much
|
||||
"this is how the user's PLC programmer wired the library block up" as "this is
|
||||
how the firmware behaves" — the byte-order and coil-mapping rules aren't
|
||||
hard-wired into silicon like they are on a DL260. This document catalogues the
|
||||
behaviours a driver has to handle across the supported model/CP variants, cites
|
||||
primary sources, and names the ModbusPal integration test we'd write for each
|
||||
(convention from `docs/v2/modbus-test-plan.md`: `S7_<model>_<behavior>`).
|
||||
|
||||
## Model / CP Capability Matrix
|
||||
|
||||
| PLC family | Modbus TCP server mechanism | Modbus TCP client mechanism | License required? | Typical port 502 source |
|
||||
|---------------------|------------------------------------|------------------------------------|-----------------------|-----------------------------------------------------------|
|
||||
| S7-1200 (V4.0+) | `MB_SERVER` on integrated PN port | `MB_CLIENT` | No (in TIA Portal) | CPU's onboard Ethernet [1][2] |
|
||||
| S7-1500 (all) | `MB_SERVER` on integrated PN port | `MB_CLIENT` | No (in TIA Portal) | CPU's onboard Ethernet [1][3] |
|
||||
| S7-1500 + CP 1543-1 | `MB_SERVER` on CP's IP | `MB_CLIENT` | No | Separate CP IP address [1] |
|
||||
| ET 200SP CPU (1510SP, 1512SP) | `MB_SERVER` on PN port | `MB_CLIENT` | No | CPU's onboard Ethernet [3] |
|
||||
| S7-300 + CP 343-1 / CP 343-1 Lean | `MODBUSCP` (FB `MODBUSCP`, instance DB per connection) | Same FB, client mode | **Yes — 2XV9450-1MB00** per CP | CP's Ethernet port [4][5] |
|
||||
| S7-400 + CP 443-1 | `MODBUSCP` | `MODBUSCP` client mode | **Yes — 2XV9450-1MB00** per CP | CP's Ethernet port [4] |
|
||||
| S7-400H + CP 443-1 (redundant H) | `MODBUSCP_REDUNDANT` / paired FBs | Not typical | Yes | Paired CPs in H-system [6] |
|
||||
| S7-300 / S7-400 CPU PN (e.g. CPU 315-2 PN/DP) | `MODBUSPN` library | `MODBUSPN` client mode | **Yes** — Modbus-TCP PN CPU lib | CPU's PN port [7] |
|
||||
| "CP 343-1 Lean" | **Server only** (no client mode supported by Lean) | — | Yes, but with restrictions | CP's Ethernet port [4][5] |
|
||||
|
||||
- **CP 343-1 Lean is server-only.** It can host `MODBUSCP` in server mode only;
|
||||
client calls return an immediate error. A surprising number of "Lean + client
|
||||
doesn't work" forum posts trace back to this [5].
|
||||
- **Pure OPC UA / PROFINET CPs (CP 1542SP-1, CP 1543-1)** support Modbus TCP on
|
||||
S7-1500 via the same `MB_SERVER`/`MB_CLIENT` instructions by passing the
|
||||
CP's `hw_identifier`. There is no separate "Modbus CP" license needed on
|
||||
S7-1500, unlike S7-300/400 [1].
|
||||
- **No S7 Modbus server supports function codes 20/21 (file records),
|
||||
22 (mask write), 23 (read-write multiple), or 43 (device identification).**
|
||||
Sending any of these returns exception `01` (Illegal Function) on every S7
|
||||
variant [1][4]. Our driver must not negotiate FC23 as a "bulk-read optimization"
|
||||
when the profile is S7.
|
||||
|
||||
Test names:
|
||||
`S7_1200_MBSERVER_Loads_OB1_Cyclic`,
|
||||
`S7_CP343_Lean_Client_Mode_Rejected`,
|
||||
`S7_All_FC23_Returns_IllegalFunction`.
|
||||
|
||||
## Address / DB Mapping
|
||||
|
||||
S7 Modbus servers **do not auto-expose PLC memory** — the PLC programmer has to
|
||||
wire one area per Modbus table to a DB or process-image region. This is the
|
||||
single biggest difference vs. DL205/Modicon/etc., where the memory map is
|
||||
fixed at the factory. Our driver must therefore be tolerant of "the same
|
||||
`40001` means completely different things on two S7-1200s on the same site."
|
||||
|
||||
### S7-1200 / S7-1500 `MB_SERVER`
|
||||
|
||||
The `MB_SERVER` instance exposes four Modbus tables to each connected client;
|
||||
each table's backing storage is a per-block parameter [1][8]:
|
||||
|
||||
| Modbus table | FCs | Backing parameter | Default / typical backing |
|
||||
|---------------------|-------------|-----------------------------|-----------------------------|
|
||||
| Coils (0x) | FC01, FC05, FC15 | *implicit* — Q process image | `%Q0.0`–`%Q1023.7` (→ coil addresses 0–8191) [1][9] |
|
||||
| Discrete Inputs (1x)| FC02 | *implicit* — I process image | `%I0.0`–`%I1023.7` (→ discrete addresses 0–8191) [1][9] |
|
||||
| Input Registers (3x)| FC04 | *implicit* — M memory or DB (version-dependent) | Some firmware routes FC04 through the same MB_HOLD_REG buffer [1][8] |
|
||||
| Holding Registers (4x)| FC03, FC06, FC16 | `MB_HOLD_REG` pointer | User DB (e.g. `DB10.DBW0`) or `%MW` area [1][2][8] |
|
||||
|
||||
- **`MB_HOLD_REG` is a pointer (VARIANT / ANY) into a user-defined DB** whose
|
||||
first byte is holding-register 0 (`40001` in 1-based Modicon form). Byte
|
||||
offset 2 is register 1, byte offset 4 is register 2, etc. [1][2].
|
||||
- **The DB *must* have "Optimized block access" UNCHECKED.** Optimized DBs let
|
||||
the compiler reorder fields for alignment; Modbus requires fixed byte
|
||||
offsets. With optimized access on, the compiler accepts the project but
|
||||
`MB_SERVER` returns STATUS `0x8383` (misaligned access) or silently reads
|
||||
zeros [8][10][11]. This is the #1 support-forum complaint.
|
||||
- **FC01/FC02/FC05/FC15 hit the Q and I process images directly — not the
|
||||
`MB_HOLD_REG` DB.** Coil address 0 = `%Q0.0`, coil 1 = `%Q0.1`, coil 8 =
|
||||
`%Q1.0`. The S7-1200 system manual publishes this mapping as `00001 → Q0.0`
|
||||
through `09999 → Q1023.7` and `10001 → I0.0` through `19999 → I1023.7` in
|
||||
1-based form; on the wire (0-based) that's coils 0-8191 and discrete inputs
|
||||
0-8191 [9].
|
||||
- **`%M` markers are NOT automatically exposed.** To expose `%M` over Modbus
|
||||
the programmer must either (a) copy `%M` to the `MB_HOLD_REG` DB each scan,
|
||||
or (b) define an Array\[0..n\] of Bool inside that DB and copy bits in/out
|
||||
of `%M`. Siemens has no "MB_COIL_REG" parameter analogous to
|
||||
`MB_HOLD_REG` — this confuses users migrating from Schneider [9][12].
|
||||
- **Bit ordering within a Modbus holding register sourced from an `Array of
|
||||
Bool`**: S7 stores bool\[0\] at `DBX0.0` which is bit 0 of byte 0 which is
|
||||
the **low byte, low bit** of Modbus register `40001`. A naive client that
|
||||
reads register `40001` and masks `0x0001` gets bool\[0\]. A client that
|
||||
masks `0x8000` gets bool\[15\] because the high byte of the Modbus register
|
||||
is the *second* byte of the DB. Siemens programmers routinely get this
|
||||
wrong in the DB-via-DBX form; `Array[0..n] of Bool` is the recommended
|
||||
layout because it aligns naturally [12][13].
|
||||
|
||||
### S7-300/400 + CP 343-1 / CP 443-1 `MODBUSCP`
|
||||
|
||||
Different paradigm: per-connection **parameter DB** (template
|
||||
`MODBUS_PARAM_CP`) declares a table of up to 8 register-area mappings. Each
|
||||
mapping is a tuple `(data_type, DB#, start_offset, length)` where `data_type`
|
||||
picks the Modbus table [4]:
|
||||
|
||||
- `B#16#1` = Coils
|
||||
- `B#16#2` = Discrete Inputs
|
||||
- `B#16#3` = Holding Registers
|
||||
- `B#16#4` = Input Registers
|
||||
|
||||
The `holding_register_start` and analogous `coils_start` parameters declare
|
||||
**which Modbus address range** the CP will serve, and the DB pointers say
|
||||
where in S7 memory that range lives [4][14]. Unlike `MB_SERVER`, the CP does
|
||||
not reach into `%Q`/`%I` directly — *everything* goes through a DB. If an
|
||||
address outside the declared ranges is requested, the CP returns exception
|
||||
`02` (Illegal Data Address) [4].
|
||||
|
||||
Test names:
|
||||
`S7_1200_FC03_Reg0_Reads_DB10_DBW0`,
|
||||
`S7_1200_Optimized_DB_Returns_0x8383_MisalignedAccess`,
|
||||
`S7_1200_FC01_Coil0_Reads_Q0_0`,
|
||||
`S7_CP343_FC03_Outside_ParamBlock_Range_Returns_IllegalDataAddress`.
|
||||
|
||||
## Data Types and Byte Order
|
||||
|
||||
Siemens CPUs store scalars **big-endian** internally ("Motorola format"), which
|
||||
is the same byte order Modbus specifies inside each register. So for 16-bit
|
||||
values (`Int`, `Word`, `UInt`) the on-the-wire layout is straightforward
|
||||
`AB` — high byte of the PLC value in the high byte of the Modbus register
|
||||
[15][16]. No byte-swap trap for 16-bit types.
|
||||
|
||||
The trap is 32-bit types (`DInt`, `DWord`, `Real`). Here's what actually
|
||||
happens across the S7 family:
|
||||
|
||||
### S7-1200 / S7-1500 `MB_SERVER`
|
||||
|
||||
- **The backing DB stores 32-bit values in big-endian byte order, high word
|
||||
first** — i.e. `ABCD` when viewed as two consecutive Modbus registers. A
|
||||
`Real` at `DB10.DBD0` with value `0x12345678` reads over Modbus as
|
||||
register 0 = `0x1234`, register 1 = `0x5678` [15][16][17].
|
||||
- **This is `ABCD`, *not* `CDAB`.** Clients that hard-code CDAB (common default
|
||||
for meters and VFDs) will get wildly wrong floats. Configure the S7 profile
|
||||
with `WordOrder = ABCD` (aka "big-endian word + big-endian byte" aka
|
||||
"high-word first") [15][17].
|
||||
- **`MB_SERVER` does not swap.** It's a direct memcpy from the DB bytes to
|
||||
the Modbus payload. Whatever byte order the ladder programmer stored into
|
||||
the DB is what the client receives [17]. This means a programmer who used
|
||||
`MOVE_BLK` from two separate `Word`s into `DBD` with the "wrong" order can
|
||||
produce `CDAB` without realising.
|
||||
- **`Real` is IEEE 754 single-precision** — unambiguous, no BCD trap like on
|
||||
DL series [15].
|
||||
- **Strings**: S7 `String[n]` has a 2-byte header (max length, current length)
|
||||
*before* the character bytes. A client reading a string over Modbus gets
|
||||
the header in the first register and then the characters two-per-register
|
||||
in high-byte-first order. `WString` is UTF-16 and the header is 4 bytes
|
||||
[18]. Our driver's string decoder must expose the "skip header" option for
|
||||
S7 profile.
|
||||
|
||||
### S7-300/400 `MODBUSCP` (CP 343-1 / CP 443-1)
|
||||
|
||||
- The CP writes the exact DB bytes onto the wire — again `ABCD` if the DB
|
||||
stores `DInt`/`Real` in native Siemens order [4].
|
||||
- **`MODBUSCP` has no `data_type` byte-swap knob.** (The `data_type` parameter
|
||||
names the Modbus table, not the byte order — see the Address Mapping
|
||||
section.) If the other end of the link expects `CDAB`, the programmer has
|
||||
to swap words in ladder before writing the DB [4][14].
|
||||
|
||||
### Operator-reported oddity
|
||||
|
||||
- Some S7 drivers (Kepware's "Siemens TCP/IP Ethernet" driver, Ignition's
|
||||
"Siemens S7" driver) expose a per-tag `Float Byte Order` with options
|
||||
`ABCD`/`CDAB`/`BADC`/`DCBA` because end-users have encountered every
|
||||
permutation in the field — not because the PLC natively swaps, but because
|
||||
ladder programmers have historically stored floats every which way [19].
|
||||
Our S7 Modbus profile should default to `ABCD` but expose a per-tag
|
||||
override.
|
||||
- **Unconfirmed rumour**: that S7-1500 firmware V2.0+ reverses float byte
|
||||
order for `MB_CLIENT` only. Not reproduced; the Siemens forum thread that
|
||||
launched it was a user error (the remote server was the swapper, not the
|
||||
S7) [20]. Treat as false until proven.
|
||||
|
||||
Test names:
|
||||
`S7_1200_Real_WordOrder_ABCD_Default`,
|
||||
`S7_1200_DInt_HighWord_First_At_DBD0`,
|
||||
`S7_1200_String_Header_First_Two_Bytes`,
|
||||
`S7_CP343_No_Internal_ByteSwap`.
|
||||
|
||||
## Coil / Discrete Input Mapping
|
||||
|
||||
On `MB_SERVER` the mapping from coil address → S7 bit is fixed at the
|
||||
process-image level [1][9][12]:
|
||||
|
||||
| Modbus coil / discrete input addr | S7 address | Notes |
|
||||
|-----------------------------------|---------------|-------------------------------------|
|
||||
| Coil 0 (FC01/05/15) | `%Q0.0` | bit 0 of output byte 0 |
|
||||
| Coil 7 | `%Q0.7` | bit 7 of output byte 0 |
|
||||
| Coil 8 | `%Q1.0` | bit 0 of output byte 1 |
|
||||
| Coil 8191 (max) | `%Q1023.7` | highest exposed output bit |
|
||||
| Discrete input 0 (FC02) | `%I0.0` | bit 0 of input byte 0 |
|
||||
| Discrete input 8191 | `%I1023.7` | highest exposed input bit |
|
||||
|
||||
Formulas:
|
||||
|
||||
```
|
||||
coil_addr = byte_index * 8 + bit_index (e.g. %Q5.3 → coil 43)
|
||||
discr_addr = byte_index * 8 + bit_index (e.g. %I10.2 → disc 82)
|
||||
```
|
||||
|
||||
- **1-based Modicon form adds 1:** coil 0 (wire) = `00001` (Modicon), etc.
|
||||
Our driver sends the 0-based PDU form, so `%Q0.0` writes to wire address 0.
|
||||
- **Writing FC05/FC15 to `%Q` is accepted even while the CPU is in STOP** —
|
||||
the PLC's process image doesn't care about the user program state. But the
|
||||
output won't propagate to the physical module until RUN (see STOP section
|
||||
below) [1][21].
|
||||
- **`%M` markers require a DB-backed `Array of Bool`** as described in the
|
||||
Address Mapping section. Our driver can't assume "coil N = MN.0" like it
|
||||
can on Modicon — on S7 it's always Q/I unless the programmer built a
|
||||
mapping DB [12].
|
||||
- **Bit-inside-holding-register**: for `Array of Bool` inside the
|
||||
`MB_HOLD_REG` DB, bool[0] is bit 0 of byte 0 → **low byte, low bit** of
|
||||
Modbus register 40001. Most third-party clients probe this in the low
|
||||
byte, so the common case works; the less-common case (bool[8]) is bit 0 of
|
||||
byte 1 → **high byte, low bit** of Modbus register 40001. Clients that
|
||||
test only bool[0] will pass and miss the mis-alignment on bool[8] [12][13].
|
||||
|
||||
Test names:
|
||||
`S7_1200_Coil_0_Is_Q0_0`,
|
||||
`S7_1200_Coil_8_Is_Q1_0`,
|
||||
`S7_1200_Discrete_Input_7_Is_I0_7`,
|
||||
`S7_1200_Coil_Write_In_STOP_Accepted_But_Output_Frozen`.
|
||||
|
||||
## Function Code Support & Max Registers Per Request
|
||||
|
||||
| FC | Name | S7-1200 / S7-1500 MB_SERVER | CP 343-1 / CP 443-1 MODBUSCP | Max qty per request |
|
||||
|----|----------------------------|-----------------------------|------------------------------|--------------------------------|
|
||||
| 01 | Read Coils | Yes | Yes | 2000 bits (spec) |
|
||||
| 02 | Read Discrete Inputs | Yes | Yes | 2000 bits (spec) |
|
||||
| 03 | Read Holding Registers | Yes | Yes | **125** (spec max) |
|
||||
| 04 | Read Input Registers | Yes | Yes | **125** |
|
||||
| 05 | Write Single Coil | Yes | Yes | 1 |
|
||||
| 06 | Write Single Register | Yes | Yes | 1 |
|
||||
| 15 | Write Multiple Coils | Yes | Yes | 1968 bits (spec) — *see note* |
|
||||
| 16 | Write Multiple Registers | Yes | Yes | **123** (spec max for TCP) |
|
||||
| 07 | Read Exception Status | No (RTU only) | No | — |
|
||||
| 17 | Report Server ID | No | No | — |
|
||||
| 20/21 | Read/Write File Record | No | No | — |
|
||||
| 22 | Mask Write Register | No | No | — |
|
||||
| 23 | Read/Write Multiple | No | No | — |
|
||||
| 43 | Read Device Identification | No | No | — |
|
||||
|
||||
- **S7-1200/1500 honour the full spec maxima** for FC03/04 (125) and FC16
|
||||
(123) [1][22]. No sub-spec cap like DL260's 100-register FC16 limit.
|
||||
- **FC15 (Write Multiple Coils) on `MB_SERVER`** writes into `%Q`, which maxes
|
||||
out at 1024 bytes = 8192 bits, but the spec's 1968-bit per-request limit
|
||||
caps any single call first [1][9].
|
||||
- **`MB_HOLD_REG` buffer size is bounded by DB size** — max DB size on
|
||||
S7-1200 is 64 KB, on S7-1500 is much larger (several MB depending on CPU),
|
||||
so the practical `MB_HOLD_REG` limit is 32767 16-bit registers on S7-1200
|
||||
and effectively unbounded on S7-1500 [22][23]. The *per-request* limit is
|
||||
still 125.
|
||||
- **Read past the end of `MB_HOLD_REG`** returns exception `02` (Illegal
|
||||
Data Address) at the start of the overflow register, not a partial read
|
||||
[1][8].
|
||||
- **Request larger than spec max** (e.g. FC03 quantity 126) returns exception
|
||||
`03` (Illegal Data Value). Verified on S7-1200 V4.2 [1][24].
|
||||
- **CP 343-1 `MODBUSCP` per-request maxima are spec** (125/125/123/1968/2000),
|
||||
matching the standard [4]. The CP's `MODBUS_PARAM_CP` caps the total
|
||||
*exposed* range, not the per-call quantity.
|
||||
|
||||
Test names:
|
||||
`S7_1200_FC03_126_Registers_Returns_IllegalDataValue`,
|
||||
`S7_1200_FC16_124_Registers_Returns_IllegalDataValue`,
|
||||
`S7_1200_FC03_Past_MB_HOLD_REG_End_Returns_IllegalDataAddress`,
|
||||
`S7_1200_FC17_ReportServerId_Returns_IllegalFunction`.
|
||||
|
||||
## Exception Codes
|
||||
|
||||
S7 Modbus servers return only the four standard exception codes [1][4]:
|
||||
|
||||
| Code | Name | Triggered by |
|
||||
|------|-----------------------|----------------------------------------------------------------------|
|
||||
| 01 | Illegal Function | FC not in the supported list (17, 20-23, 43, any undefined FC) |
|
||||
| 02 | Illegal Data Address | Register outside `MB_HOLD_REG` / outside `MODBUSCP` param-block range |
|
||||
| 03 | Illegal Data Value | Quantity exceeds spec (FC03/04 > 125, FC16 > 123, FC01/02 > 2000, FC15 > 1968) |
|
||||
| 04 | Server Failure | Runtime error inside MB_SERVER (DB access fault, corrupt DB header, MB_SERVER disabled mid-request) [1][24] |
|
||||
|
||||
- **No proprietary exception codes (05/06/0A/0B) are used** on any S7
|
||||
Modbus server [1][4]. Our driver's status-code mapper can treat these as
|
||||
"never observed" on the S7 profile.
|
||||
- **CPU in STOP → `MB_SERVER` keeps running if it's in OB1 of the firmware's
|
||||
communication task, but OB1 itself is not scanned.** In practice:
|
||||
- Holding-register *reads* (FC03) continue to return the last DB values
|
||||
frozen at the moment the CPU entered STOP. The `MB_SERVER` block is in
|
||||
OB1 so it isn't re-invoked; however the TCP stack keeps the socket open
|
||||
and returns cached data on subsequent polls [1][21]. **Unconfirmed**
|
||||
whether this is cached in the CP or in the CPU's communication processor;
|
||||
behaviour varies between firmware 4.0 and 4.5 [21].
|
||||
- Holding-register *writes* (FC06/FC16) during STOP return exception `04`
|
||||
(Server Failure) on S7-1200 V4.2+, and return success-but-discarded on
|
||||
older firmware [1][24]. Our driver should treat FC06/FC16 during STOP as
|
||||
non-deterministic and not rely on the response code.
|
||||
- Coil *writes* (FC05/FC15) to `%Q` are *accepted* by the process image
|
||||
during STOP, but the physical output freezes at its last RUN-mode value
|
||||
(or the configured STOP-mode substitute value) until RUN resumes [1][21].
|
||||
- **Writing a read-only address via FC06/FC16**: returns `02` (Illegal Data
|
||||
Address), not `04`. S7 does not have "write-protected" holding registers —
|
||||
the programmer either exposes a DB for read-write or doesn't expose it at
|
||||
all [1][12].
|
||||
|
||||
STATUS codes (returned in the `STATUS` output of the block, not on the wire):
|
||||
|
||||
- `0x0000` — no error.
|
||||
- `0x7001` — first call, connection being established.
|
||||
- `0x7002` — subsequent cyclic call, connection in progress.
|
||||
- `0x8383` — data access error (optimized DB, DB too small, or type mismatch)
|
||||
[10][24].
|
||||
- `0x8188` — invalid parameter combination (e.g. MB_MODE out of range) [24].
|
||||
- `0x80C8` — mismatched UNIT_ID between MB_CLIENT and `MB_SERVER` [25].
|
||||
|
||||
Test names:
|
||||
`S7_1200_FC03_Outside_HoldReg_Returns_IllegalDataAddress`,
|
||||
`S7_1200_FC16_In_STOP_Returns_ServerFailure`,
|
||||
`S7_1200_FC03_In_STOP_Returns_Cached_Values`,
|
||||
`S7_1200_No_Proprietary_ExceptionCodes_0x05_0x06_0x0A_0x0B`.
|
||||
|
||||
## Connection Behavior
|
||||
|
||||
- **Max simultaneous Modbus TCP connections**:
|
||||
- **S7-1200**: shares a pool of 8 open-communication connections across
|
||||
all TCP/UDP/Modbus use. On a CPU 1211C you get 8 total; on 1215C/1217C
|
||||
still 8 shared among PG/HMI/OUC/Modbus. Each `MB_SERVER` instance
|
||||
reserves one. A typical site with a PG + 1 HMI + 2 Modbus clients uses
|
||||
4 of the 8 [1][26].
|
||||
- **S7-1500**: up to **8 concurrent Modbus TCP server connections** per
|
||||
`MB_SERVER` port, across multiple `MB_SERVER` instance DBs each with a
|
||||
unique port. Total open-communication resources depend on CPU (e.g.
|
||||
CPU 1515-2 PN supports 128 OUC connections total; Modbus is a subset)
|
||||
[1][27].
|
||||
- **CP 343-1 Lean**: up to **8** simultaneous Modbus TCP connections on
|
||||
port 502 [4][5]. Exceeding this refuses at TCP accept.
|
||||
- **CP 443-1 Advanced**: up to **16** simultaneous Modbus TCP connections
|
||||
[4].
|
||||
- **Multi-connection model on `MB_SERVER`**: one instance DB per connection.
|
||||
An instance DB listening on port 502 serves exactly one connection at a
|
||||
time; to serve N simultaneous clients you need N instance DBs each with a
|
||||
unique port (502/503/504...). **This is a real trap** — most users expect
|
||||
port 502 to multiplex [27][28]. Our driver must not assume port 502 is the
|
||||
only listener.
|
||||
- **Keep-alive**: S7-1500's TCP stack does send TCP keepalives (default
|
||||
every ~30 s) but the interval is not exposed as a configurable. S7-1200 is
|
||||
the same. CP 343-1 keepalives are configured via HW Config → CP properties
|
||||
→ Options → "Send keepalive" (default **off** on older firmware, default
|
||||
**on** on firmware V3.0+) [1][29]. Driver-side keepalive is still
|
||||
advisable for S7-300/CP 343-1 on old firmware.
|
||||
- **Idle-timeout close**: `MB_SERVER` does *not* close idle sockets on its
|
||||
own. However, the TCP stack on S7-1500 will close a socket that fails
|
||||
three consecutive keepalive probes (~2 minutes). Forum reports describe
|
||||
`MB_SERVER` connections "dying overnight" on S7-1500 when an HMI stops
|
||||
polling — the fix is to enable driver-side periodic reads or driver-side
|
||||
TCP keepalive [29][30].
|
||||
- **Reconnect after power cycle**: MB_SERVER starts listening ~1-2 seconds
|
||||
after the CPU reaches RUN. If the client reconnects during STARTUP OB
|
||||
(OB100), the connection is refused until OB1 runs the block at least once.
|
||||
Our driver should back off and retry on `ECONNREFUSED` for the first 5
|
||||
seconds after a power-cycle detection [1][24].
|
||||
- **Unit Identifier**: `MB_SERVER` accepts **any** Unit ID by default — there
|
||||
is no configurable filter; the PLC ignores the Unit ID field entirely.
|
||||
`MB_CLIENT` defaults to Unit ID = 255 as "ignore" [25][31]. Some
|
||||
third-party Modbus-TCP gateways *require* a specific Unit ID; sending
|
||||
anything to S7 is safe. **CP 343-1 `MODBUSCP`** also accepts any Unit ID
|
||||
in server mode, but the parameter DB exposes a `single_write` / `unit_id`
|
||||
field on newer firmware to allow filtering [4].
|
||||
|
||||
Test names:
|
||||
`S7_1200_9th_TCP_Connection_Refused_On_8_Conn_Pool`,
|
||||
`S7_1500_Port_503_Required_For_Second_Instance`,
|
||||
`S7_1200_Reconnect_After_Power_Cycle_Succeeds_Within_5s`,
|
||||
`S7_1200_Unit_ID_Ignored_Any_Accepted`.
|
||||
|
||||
## Behavioral Oddities
|
||||
|
||||
- **Transaction ID echo** is reliable on all S7 variants. `MB_SERVER` copies
|
||||
the MBAP TxId verbatim. No known firmware that drops TxId under load [1][31].
|
||||
- **Request serialization**: a single `MB_SERVER` instance serializes
|
||||
requests from its one connected client — the block processes one PDU per
|
||||
call and calls happen once per OB1 scan. OB1 scan time of 5-50 ms puts an
|
||||
upper bound on throughput at ~20-200 requests/sec per connection [1][30].
|
||||
Multiple `MB_SERVER` instances (one per port) run in parallel because OB1
|
||||
calls them sequentially within the same scan.
|
||||
- **OB1 scan coupling**: `MB_SERVER` must be called cyclically from OB1 (or
|
||||
another cyclic OB). If the programmer puts it in a conditional branch
|
||||
that doesn't fire every scan, requests time out. The STATUS `0x7002`
|
||||
"in progress" is *expected* between calls, not an error [1][24].
|
||||
- **Optimized DB backing `MB_HOLD_REG`** — already covered in Address
|
||||
Mapping; STATUS becomes `0x8383`. This is the most common deployment bug
|
||||
on S7-1500 projects migrated from older S7-1200 examples [10][11].
|
||||
- **CPU STOP behaviour** — covered in Exception Codes section. The short
|
||||
version: reads may return stale data without error; writes return exception
|
||||
04 on modern firmware.
|
||||
- **Partial-frame disconnect**: S7-1200/1500 TCP stack closes the socket on
|
||||
any MBAP header where the `Length` field doesn't match the PDU length.
|
||||
Driver must detect half-close and reconnect [1][29].
|
||||
- **MBAP `Protocol ID` must be 0**. Any non-zero value causes the CP/CPU to
|
||||
drop the frame silently (no response, no RST) on S7-1500 firmware V2.0
|
||||
through V2.9; firmware V3.0+ sends an RST [1][30]. *Unconfirmed* whether
|
||||
V3.1 still sends RST or returns to silent drop.
|
||||
- **FC01/FC02 access outside `%Q`/`%I` range**: on S7-1200, requesting
|
||||
coil address 8192 (= `%Q1024.0`) returns exception `02` (Illegal Data
|
||||
Address) [1][9]. The 8192-bit hard cap is a process-image size limit on
|
||||
the CPU, not a Modbus protocol limit.
|
||||
- **`MB_CLIENT` UNIT_ID mismatch with remote `MB_SERVER`** produces STATUS
|
||||
`0x80C8` on the client side, and the server silently discards the frame
|
||||
(no response on the wire) [25]. This matters for Modbus-TCP-to-RTU
|
||||
gateway scenarios where the Unit ID picks the RTU slave.
|
||||
- **Non-IEEE REAL / BCD**: S7 does *not* use BCD like DirectLOGIC. `Real` is
|
||||
always IEEE 754 single-precision. `LReal` (8-byte double) occupies 4
|
||||
Modbus registers in `ABCDEFGH` order (big-endian byte, big-endian word)
|
||||
[15][18].
|
||||
- **`MODBUSCP` single-write** on CP 343-1: a parameter `single_write` in the
|
||||
param DB controls whether FC06 on a register in the "holding register"
|
||||
area triggers a callback to the user program vs. updates the DB directly.
|
||||
Default is direct update. If a ladder programmer enables the callback
|
||||
without implementing the callback OB, FC06 writes hang for 5 seconds then
|
||||
return exception `04` [4].
|
||||
|
||||
Test names:
|
||||
`S7_1200_TxId_Preserved_Across_Burst_Of_50_Requests`,
|
||||
`S7_1200_MBSERVER_Throughput_Capped_By_OB1_Scan`,
|
||||
`S7_1200_MBAP_ProtocolID_NonZero_Frame_Dropped`,
|
||||
`S7_1200_Partial_MBAP_Causes_Half_Close`.
|
||||
|
||||
## Model-specific Differences Worth Separate Test Coverage
|
||||
|
||||
- **S7-1200 V4.0 vs V4.4+**: Older firmware does not support `WString` over
|
||||
`MB_HOLD_REG` and returns `0x8383` if the DB contains one [18][24]. Test
|
||||
both firmware bands separately.
|
||||
- **S7-1500 vs S7-1200**: S7-1500 supports multiple `MB_SERVER` instances on
|
||||
the *same* CPU with different ports cleanly; S7-1200 can too but its
|
||||
8-connection pool is shared tighter [1][27]. Throughput per-connection is
|
||||
~5× faster on S7-1500 because the comms task runs on a dedicated core.
|
||||
- **S7-300 + CP 343-1 vs S7-1200/1500**: parameter-block mapping (not
|
||||
`MB_HOLD_REG` pointer), per-connection license, no `%Q`/`%I` direct
|
||||
access for coils (everything goes through a DB), different STATUS codes
|
||||
(`DONE`/`ERROR`/`STATUS` word pairs vs. the single STATUS word) [4][14].
|
||||
Driver-side it's a different profile.
|
||||
- **CP 343-1 Lean vs CP 343-1 Advanced**: Lean is server-only; Advanced is
|
||||
client + server. Lean's max connections = 8; Advanced = 16 [4][5].
|
||||
- **CP 443-1 in S7-400H**: uses `MODBUSCP_REDUNDANT` which presents two
|
||||
Ethernet endpoints that fail over. Our driver's redundancy support should
|
||||
recognize the S7-400H profile as "two IP addresses, same server state,
|
||||
advertise via `ServerUriArray`" [6].
|
||||
- **ET 200SP CPU (1510SP / 1512SP)**: behaves as S7-1500 from `MB_SERVER`
|
||||
perspective. No known deltas [3].
|
||||
|
||||
## References
|
||||
|
||||
1. Siemens Industry Online Support, *Modbus/TCP Communication between SIMATIC S7-1500 / S7-1200 and Modbus/TCP Controllers with Instructions `MB_CLIENT` and `MB_SERVER`*, Entry ID 102020340, V6 (Feb 2021). https://cache.industry.siemens.com/dl/files/340/102020340/att_118119/v6/net_modbus_tcp_s7-1500_s7-1200_en.pdf
|
||||
2. Siemens TIA Portal Online Docs, *MB_SERVER instruction*. https://docs.tia.siemens.cloud/r/simatic_s7_1200_manual_collection_eses_20/communication-processor-and-modbus-tcp/modbus-communication/modbus-tcp/modbus-tcp-instructions/mb_server-communicate-using-profinet-as-modbus-tcp-server-instruction
|
||||
3. Siemens, *SIMATIC S7-1500 Communication Function Manual* (covers ET 200SP CPU). http://public.eandm.com/Public_Docs/s71500_communication_function_manual_en-US_en-US.pdf
|
||||
4. Siemens Industry Online Support, *SIMATIC Modbus/TCP communication using CP 343-1 and CP 443-1 — Programming Manual*, Entry ID 103447617. https://cache.industry.siemens.com/dl/files/617/103447617/att_106971/v1/simatic_modbus_tcp_cp_en-US_en-US.pdf
|
||||
5. Siemens Industry Online Support FAQ *"Which technical data applies for the SIMATIC Modbus/TCP software for CP 343-1 / CP 443-1?"*, Entry ID 104946406. https://www.industry-mobile-support.siemens-info.com/en/article/detail/104946406
|
||||
6. Siemens Industry Online Support, *Redundant Modbus/TCP communication via CP 443-1 in S7-400H systems*, Entry ID 109739212. https://cache.industry.siemens.com/dl/files/212/109739212/att_887886/v1/SIMATIC_modbus_tcp_cp_red_e_en-US.pdf
|
||||
7. Siemens Industry Online Support, *SIMATIC MODBUS (TCP) PN CPU Library — Programming and Operating Manual 06/2014*, Entry ID 75330636. https://support.industry.siemens.com/cs/attachments/75330636/ModbusTCPPNCPUen.pdf
|
||||
8. DMC Inc., *Using an S7-1200 PLC as a Modbus TCP Slave*. https://www.dmcinfo.com/blog/27313/using-an-s7-1200-plc-as-a-modbus-tcp-slave/
|
||||
9. Siemens, *SIMATIC S7-1200 System Manual* (V4.x), "MB_SERVER" pages 736-742. https://www.manualslib.com/manual/1453610/Siemens-S7-1200.html?page=736
|
||||
10. lamaPLC, *Simatic Modbus S7 error- and statuscodes*. https://www.lamaplc.com/doku.php?id=simatic:errorcodes
|
||||
11. ScadaProtocols, *How to Configure Modbus TCP on Siemens S7-1200 (TIA Portal Step-by-Step)*. https://scadaprotocols.com/modbus-tcp-siemens-s7-1200-tia-portal/
|
||||
12. Industrial Monitor Direct, *Reading and Writing Memory Bits via Modbus TCP on S7-1200*. https://industrialmonitordirect.com/blogs/knowledgebase/reading-and-writing-memory-bits-via-modbus-tcp-on-s7-1200
|
||||
13. PLCtalk forum *"Siemens S7-1200 modbus understanding"*. https://www.plctalk.net/forums/threads/siemens-s7-1200-modbus-understanding.104119/
|
||||
14. Siemens SIMATIC S7 Manual, "Function block MODBUSCP — Functionality" (ManualsLib p29). https://www.manualslib.com/manual/1580661/Siemens-Simatic-S7.html?page=29
|
||||
15. Chipkin, *How Real (Floating Point) and 32-bit Data is Encoded in Modbus*. https://store.chipkin.com/articles/how-real-floating-point-and-32-bit-data-is-encoded-in-modbus-rtu-messages
|
||||
16. Siemens Industry Online Support forum, *MODBUS DATA conversion in S7-1200 CPU*, Entry ID 97287. https://support.industry.siemens.com/forum/WW/en/posts/modbus-data-converson-in-s7-1200-cpu/97287
|
||||
17. Industrial Monitor Direct, *Siemens S7-1500 MB_SERVER Modbus TCP Configuration Guide*. https://industrialmonitordirect.com/de/blogs/knowledgebase/siemens-s7-1500-mb-server-modbus-tcp-configuration-guide
|
||||
18. Siemens TIA Portal, *Data types in SIMATIC S7-1200/1500 — String/WString header layout* (system manual, "Elementary Data Types").
|
||||
19. Kepware / PTC, *Siemens TCP/IP Ethernet Driver Help*, "Byte / Word Order" tag property. https://www.opcturkey.com/uploads/siemens-tcp-ip-ethernet-manual.pdf
|
||||
20. Siemens SiePortal forum, *Transfer float out of words*, Entry ID 187811. https://sieportal.siemens.com/en-ww/support/forum/posts/transfer-float-out-of-words/187811 _(operator-reported "S7 swaps float" claim — traced to remote-device issue; **unconfirmed**.)_
|
||||
21. Siemens SiePortal forum, *S7-1200 communication with Modbus TCP*, Entry ID 133086. https://support.industry.siemens.com/forum/WW/en/posts/s7-1200-communication-with-modbus-tcp/133086
|
||||
22. Siemens SiePortal forum, *S7-1500 MB Server Holding Register Max Word*, Entry ID 224636. https://support.industry.siemens.com/forum/WW/en/posts/s7-1500-mb-server-holding-register-max-word/224636
|
||||
23. Siemens, *SIMATIC S7-1500 Technical Specifications* — CPU-specific DB size limits in each CPU manual's "Memory" table.
|
||||
24. Siemens TIA Portal Online Docs, *Error messages (S7-1200, S7-1500) — Modbus instructions*. https://docs.tia.siemens.cloud/r/en-us/v20/modbus-rtu-s7-1200-s7-1500/error-messages-s7-1200-s7-1500
|
||||
25. Industrial Monitor Direct, *Fix Siemens S7-1500 MB_Client UnitID Error 80C8*. https://industrialmonitordirect.com/blogs/knowledgebase/troubleshooting-mb-client-on-s7-1500-cpu-1515sp-modbus-tcp
|
||||
26. Siemens SiePortal forum, *How many TCP connections can the S7-1200 make?*, Entry ID 275570. https://support.industry.siemens.com/forum/WW/en/posts/how-many-tcp-connections-can-the-s7-1200-make/275570
|
||||
27. Siemens SiePortal forum, *Simultaneous connections of Modbus TCP*, Entry ID 189626. https://support.industry.siemens.com/forum/ww/en/posts/simultaneous-connections-of-modbus-tcp/189626
|
||||
28. Siemens SiePortal forum, *How many Modbus TCP IP clients can read simultaneously from S7-1517*, Entry ID 261569. https://support.industry.siemens.com/forum/WW/en/posts/how-many-modbus-tcp-ip-client-can-read-simultaneously-in-s7-1517/261569
|
||||
29. Industrial Monitor Direct, *Troubleshooting Intermittent Modbus TCP Connections on S7-1500 PLC*. https://industrialmonitordirect.com/blogs/knowledgebase/troubleshooting-intermittent-modbus-tcp-connections-on-s7-1500-plc
|
||||
30. PLCtalk forum *"S7-1500 modbus tcp speed?"*. https://www.plctalk.net/forums/threads/s7-1500-modbus-tcp-speed.114046/
|
||||
31. Siemens SiePortal forum, *MB_Unit_ID parameter in Modbus TCP*, Entry ID 156635. https://support.industry.siemens.com/forum/WW/en/posts/mb-unit-id-parameter-in-modbus-tcp/156635
|
||||
165
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/DirectLogicAddress.cs
Normal file
165
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/DirectLogicAddress.cs
Normal file
@@ -0,0 +1,165 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// AutomationDirect DirectLOGIC address-translation helpers. DL205 / DL260 / DL350 CPUs
|
||||
/// address V-memory in OCTAL while the Modbus wire uses DECIMAL PDU addresses — operators
|
||||
/// see "V2000" in the PLC ladder-logic editor but the Modbus client must write PDU 0x0400.
|
||||
/// The formulas differ between user V-memory (simple octal-to-decimal) and system V-memory
|
||||
/// (fixed bank mappings), so the two cases are separate methods rather than one overloaded
|
||||
/// "ToPdu" call.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// See <c>docs/v2/dl205.md</c> §V-memory for the full CPU-family matrix + rationale.
|
||||
/// References: D2-USER-M appendix (DL205/D2-260), H2-ECOM-M §6.5 (absolute vs relative
|
||||
/// addressing), AutomationDirect forum guidance on V40400 system-base.
|
||||
/// </remarks>
|
||||
public static class DirectLogicAddress
|
||||
{
|
||||
/// <summary>
|
||||
/// Convert a DirectLOGIC user V-memory address (octal) to a 0-based Modbus PDU address.
|
||||
/// Accepts bare octal (<c>"2000"</c>) or <c>V</c>-prefixed (<c>"V2000"</c>). Range
|
||||
/// depends on CPU model — DL205 D2-260 user memory is V1400-V7377 + V10000-V17777
|
||||
/// octal, DL260 extends to V77777 octal.
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentException">Input is null / empty / contains non-octal digits (8,9).</exception>
|
||||
/// <exception cref="OverflowException">Parsed value exceeds ushort.MaxValue (0xFFFF).</exception>
|
||||
public static ushort UserVMemoryToPdu(string vAddress)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(vAddress))
|
||||
throw new ArgumentException("V-memory address must not be empty", nameof(vAddress));
|
||||
var s = vAddress.Trim();
|
||||
if (s[0] == 'V' || s[0] == 'v') s = s.Substring(1);
|
||||
if (s.Length == 0)
|
||||
throw new ArgumentException($"V-memory address '{vAddress}' has no digits", nameof(vAddress));
|
||||
|
||||
// Octal conversion. Reject 8/9 digits up-front — int.Parse in the obvious base would
|
||||
// accept them silently because .NET has no built-in base-8 parser.
|
||||
uint result = 0;
|
||||
foreach (var ch in s)
|
||||
{
|
||||
if (ch < '0' || ch > '7')
|
||||
throw new ArgumentException(
|
||||
$"V-memory address '{vAddress}' contains non-octal digit '{ch}' — DirectLOGIC V-addresses are octal (0-7)",
|
||||
nameof(vAddress));
|
||||
result = result * 8 + (uint)(ch - '0');
|
||||
if (result > ushort.MaxValue)
|
||||
throw new OverflowException(
|
||||
$"V-memory address '{vAddress}' exceeds the 16-bit Modbus PDU address range");
|
||||
}
|
||||
return (ushort)result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DirectLOGIC system V-memory starts at octal V40400 on DL260 / H2-ECOM100 in factory
|
||||
/// "absolute" addressing mode. Unlike user V-memory, the mapping is NOT a simple
|
||||
/// octal-to-decimal conversion — the CPU relocates the system bank to Modbus PDU 0x2100
|
||||
/// (decimal 8448). This helper returns the CPU-family base plus a user-supplied offset
|
||||
/// within the system bank.
|
||||
/// </summary>
|
||||
public const ushort SystemVMemoryBasePdu = 0x2100;
|
||||
|
||||
/// <param name="offsetWithinSystemBank">
|
||||
/// 0-based register offset within the system bank. Pass 0 for V40400 itself; pass 1 for
|
||||
/// V40401 (octal), and so on. NOT an octal-decoded value — the system bank lives at
|
||||
/// consecutive PDU addresses, so the offset is plain decimal.
|
||||
/// </param>
|
||||
public static ushort SystemVMemoryToPdu(ushort offsetWithinSystemBank)
|
||||
{
|
||||
var pdu = SystemVMemoryBasePdu + offsetWithinSystemBank;
|
||||
if (pdu > ushort.MaxValue)
|
||||
throw new OverflowException(
|
||||
$"System V-memory offset {offsetWithinSystemBank} maps past 0xFFFF");
|
||||
return (ushort)pdu;
|
||||
}
|
||||
|
||||
// Bit-memory bases per DL260 user manual §I/O-configuration.
|
||||
// Numbers after X / Y / C / SP are OCTAL in DirectLOGIC notation. The Modbus base is
|
||||
// added to the octal-decoded offset; e.g. Y017 = Modbus coil 2048 + octal(17) = 2048 + 15 = 2063.
|
||||
|
||||
/// <summary>
|
||||
/// DL260 Y-output coil base. Y0 octal → Modbus coil address 2048 (0-based).
|
||||
/// </summary>
|
||||
public const ushort YOutputBaseCoil = 2048;
|
||||
|
||||
/// <summary>
|
||||
/// DL260 C-relay coil base. C0 octal → Modbus coil address 3072 (0-based).
|
||||
/// </summary>
|
||||
public const ushort CRelayBaseCoil = 3072;
|
||||
|
||||
/// <summary>
|
||||
/// DL260 X-input discrete-input base. X0 octal → Modbus discrete input 0.
|
||||
/// </summary>
|
||||
public const ushort XInputBaseDiscrete = 0;
|
||||
|
||||
/// <summary>
|
||||
/// DL260 SP special-relay discrete-input base. SP0 octal → Modbus discrete input 1024.
|
||||
/// Read-only; writing SP relays is rejected with Illegal Data Address.
|
||||
/// </summary>
|
||||
public const ushort SpecialBaseDiscrete = 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Translate a DirectLOGIC Y-output address (e.g. <c>"Y0"</c>, <c>"Y17"</c>) to its
|
||||
/// 0-based Modbus coil address on DL260. The trailing number is OCTAL, matching the
|
||||
/// ladder-logic editor's notation.
|
||||
/// </summary>
|
||||
public static ushort YOutputToCoil(string yAddress) =>
|
||||
AddOctalOffset(YOutputBaseCoil, StripPrefix(yAddress, 'Y'));
|
||||
|
||||
/// <summary>
|
||||
/// Translate a DirectLOGIC C-relay address (e.g. <c>"C0"</c>, <c>"C1777"</c>) to its
|
||||
/// 0-based Modbus coil address.
|
||||
/// </summary>
|
||||
public static ushort CRelayToCoil(string cAddress) =>
|
||||
AddOctalOffset(CRelayBaseCoil, StripPrefix(cAddress, 'C'));
|
||||
|
||||
/// <summary>
|
||||
/// Translate a DirectLOGIC X-input address (e.g. <c>"X0"</c>, <c>"X17"</c>) to its
|
||||
/// 0-based Modbus discrete-input address. Reading an unpopulated X returns 0, not an
|
||||
/// exception — the CPU sizes the table to configured I/O, not installed modules.
|
||||
/// </summary>
|
||||
public static ushort XInputToDiscrete(string xAddress) =>
|
||||
AddOctalOffset(XInputBaseDiscrete, StripPrefix(xAddress, 'X'));
|
||||
|
||||
/// <summary>
|
||||
/// Translate a DirectLOGIC SP-special-relay address (e.g. <c>"SP0"</c>) to its 0-based
|
||||
/// Modbus discrete-input address. Accepts <c>"SP"</c> prefix case-insensitively.
|
||||
/// </summary>
|
||||
public static ushort SpecialToDiscrete(string spAddress)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(spAddress))
|
||||
throw new ArgumentException("SP address must not be empty", nameof(spAddress));
|
||||
var s = spAddress.Trim();
|
||||
if (s.Length >= 2 && (s[0] == 'S' || s[0] == 's') && (s[1] == 'P' || s[1] == 'p'))
|
||||
s = s.Substring(2);
|
||||
return AddOctalOffset(SpecialBaseDiscrete, s);
|
||||
}
|
||||
|
||||
private static string StripPrefix(string address, char expectedPrefix)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(address))
|
||||
throw new ArgumentException("Address must not be empty", nameof(address));
|
||||
var s = address.Trim();
|
||||
if (s.Length > 0 && char.ToUpperInvariant(s[0]) == char.ToUpperInvariant(expectedPrefix))
|
||||
s = s.Substring(1);
|
||||
return s;
|
||||
}
|
||||
|
||||
private static ushort AddOctalOffset(ushort baseAddr, string octalDigits)
|
||||
{
|
||||
if (octalDigits.Length == 0)
|
||||
throw new ArgumentException("Address has no digits", nameof(octalDigits));
|
||||
uint offset = 0;
|
||||
foreach (var ch in octalDigits)
|
||||
{
|
||||
if (ch < '0' || ch > '7')
|
||||
throw new ArgumentException(
|
||||
$"Address contains non-octal digit '{ch}' — DirectLOGIC I/O addresses are octal (0-7)",
|
||||
nameof(octalDigits));
|
||||
offset = offset * 8 + (uint)(ch - '0');
|
||||
}
|
||||
var result = baseAddr + offset;
|
||||
if (result > ushort.MaxValue)
|
||||
throw new OverflowException($"Address {baseAddr}+{offset} exceeds 0xFFFF");
|
||||
return (ushort)result;
|
||||
}
|
||||
}
|
||||
161
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/MelsecAddress.cs
Normal file
161
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/MelsecAddress.cs
Normal file
@@ -0,0 +1,161 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Mitsubishi MELSEC PLC family selector for address-translation helpers. The Q/L/iQ-R
|
||||
/// families write bit-device addresses (X, Y) in <b>hexadecimal</b> in GX Works and the
|
||||
/// CPU manuals; the FX and iQ-F families write them in <b>octal</b> (same convention as
|
||||
/// AutomationDirect DirectLOGIC). Mixing the two up is the #1 MELSEC driver bug source —
|
||||
/// an operator typing <c>X20</c> into a Q-series tag config means decimal 32, but the
|
||||
/// same string on an FX3U means decimal 16, so the helper must know the family to route
|
||||
/// correctly.
|
||||
/// </summary>
|
||||
public enum MelsecFamily
|
||||
{
|
||||
/// <summary>
|
||||
/// MELSEC-Q / MELSEC-L / MELSEC iQ-R. X and Y device numbers are interpreted as
|
||||
/// <b>hexadecimal</b>; <c>X20</c> means decimal 32.
|
||||
/// </summary>
|
||||
Q_L_iQR,
|
||||
|
||||
/// <summary>
|
||||
/// MELSEC-F (FX3U / FX3GE / FX3G) and MELSEC iQ-F (FX5U). X and Y device numbers
|
||||
/// are interpreted as <b>octal</b> (same as DirectLOGIC); <c>X20</c> means decimal 16.
|
||||
/// iQ-F has a GX Works3 project toggle that can flip to decimal — if a site uses
|
||||
/// that, configure the tag's Address directly as a decimal PDU address and do not
|
||||
/// route through this helper.
|
||||
/// </summary>
|
||||
F_iQF,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mitsubishi MELSEC address-translation helpers for the QJ71MT91 / LJ71MT91 / RJ71EN71 /
|
||||
/// iQ-R built-in / iQ-F / FX3U-ENET-P502 Modbus modules. MELSEC does NOT hard-wire
|
||||
/// Modbus-to-device mappings like DL260 does — every site configures its own "Modbus
|
||||
/// Device Assignment Parameter" block of up to 16 entries. The helpers here cover only
|
||||
/// the <b>address-notation</b> portion of the translation (hex X20 vs octal X20 + adding
|
||||
/// the bank base); the caller is still responsible for knowing the assignment-block
|
||||
/// offset for their site.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// See <c>docs/v2/mitsubishi.md</c> §device-assignment + §X-Y-hex-trap for the full
|
||||
/// matrix and primary-source citations.
|
||||
/// </remarks>
|
||||
public static class MelsecAddress
|
||||
{
|
||||
/// <summary>
|
||||
/// Translate a MELSEC X-input address (e.g. <c>"X0"</c>, <c>"X10"</c>) to a 0-based
|
||||
/// Modbus discrete-input address, given the PLC family's address notation (hex or
|
||||
/// octal) and the Modbus Device Assignment block's X-range base.
|
||||
/// </summary>
|
||||
/// <param name="xAddress">MELSEC X address. <c>X</c> prefix optional, case-insensitive.</param>
|
||||
/// <param name="family">The PLC family — determines whether the trailing digits are hex or octal.</param>
|
||||
/// <param name="xBankBase">
|
||||
/// 0-based Modbus DI address the assignment-block has configured X0 to land at.
|
||||
/// Typical default on QJ71MT91 sample projects: 0. Pass the site-specific value.
|
||||
/// </param>
|
||||
public static ushort XInputToDiscrete(string xAddress, MelsecFamily family, ushort xBankBase = 0) =>
|
||||
AddFamilyOffset(xBankBase, StripPrefix(xAddress, 'X'), family);
|
||||
|
||||
/// <summary>
|
||||
/// Translate a MELSEC Y-output address to a 0-based Modbus coil address. Same rules
|
||||
/// as <see cref="XInputToDiscrete"/> for hex/octal parsing.
|
||||
/// </summary>
|
||||
public static ushort YOutputToCoil(string yAddress, MelsecFamily family, ushort yBankBase = 0) =>
|
||||
AddFamilyOffset(yBankBase, StripPrefix(yAddress, 'Y'), family);
|
||||
|
||||
/// <summary>
|
||||
/// Translate a MELSEC M-relay address (internal relay) to a 0-based Modbus coil
|
||||
/// address. M-addresses are <b>decimal</b> on every MELSEC family — unlike X/Y which
|
||||
/// are hex on Q/L/iQ-R. Includes the bank base that the assignment-block configured.
|
||||
/// </summary>
|
||||
public static ushort MRelayToCoil(string mAddress, ushort mBankBase = 0)
|
||||
{
|
||||
var digits = StripPrefix(mAddress, 'M');
|
||||
if (!ushort.TryParse(digits, out var offset))
|
||||
throw new ArgumentException(
|
||||
$"M-relay address '{mAddress}' is not a valid decimal integer", nameof(mAddress));
|
||||
var result = mBankBase + offset;
|
||||
if (result > ushort.MaxValue)
|
||||
throw new OverflowException($"M-relay {mAddress} + base {mBankBase} exceeds 0xFFFF");
|
||||
return (ushort)result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Translate a MELSEC D-register address (data register) to a 0-based Modbus holding
|
||||
/// register address. D-addresses are <b>decimal</b>. Default assignment convention is
|
||||
/// D0 → HR 0 (pass <paramref name="dBankBase"/> = 0); sites with shifted layouts pass
|
||||
/// their configured base.
|
||||
/// </summary>
|
||||
public static ushort DRegisterToHolding(string dAddress, ushort dBankBase = 0)
|
||||
{
|
||||
var digits = StripPrefix(dAddress, 'D');
|
||||
if (!ushort.TryParse(digits, out var offset))
|
||||
throw new ArgumentException(
|
||||
$"D-register address '{dAddress}' is not a valid decimal integer", nameof(dAddress));
|
||||
var result = dBankBase + offset;
|
||||
if (result > ushort.MaxValue)
|
||||
throw new OverflowException($"D-register {dAddress} + base {dBankBase} exceeds 0xFFFF");
|
||||
return (ushort)result;
|
||||
}
|
||||
|
||||
private static string StripPrefix(string address, char expectedPrefix)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(address))
|
||||
throw new ArgumentException("Address must not be empty", nameof(address));
|
||||
var s = address.Trim();
|
||||
if (s.Length > 0 && char.ToUpperInvariant(s[0]) == char.ToUpperInvariant(expectedPrefix))
|
||||
s = s.Substring(1);
|
||||
if (s.Length == 0)
|
||||
throw new ArgumentException($"Address '{address}' has no digits after prefix", nameof(address));
|
||||
return s;
|
||||
}
|
||||
|
||||
private static ushort AddFamilyOffset(ushort baseAddr, string digits, MelsecFamily family)
|
||||
{
|
||||
uint offset = family switch
|
||||
{
|
||||
MelsecFamily.Q_L_iQR => ParseHex(digits),
|
||||
MelsecFamily.F_iQF => ParseOctal(digits),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(family), family, "Unknown MELSEC family"),
|
||||
};
|
||||
var result = baseAddr + offset;
|
||||
if (result > ushort.MaxValue)
|
||||
throw new OverflowException($"Address {baseAddr}+{offset} exceeds 0xFFFF");
|
||||
return (ushort)result;
|
||||
}
|
||||
|
||||
private static uint ParseHex(string digits)
|
||||
{
|
||||
uint result = 0;
|
||||
foreach (var ch in digits)
|
||||
{
|
||||
uint nibble;
|
||||
if (ch >= '0' && ch <= '9') nibble = (uint)(ch - '0');
|
||||
else if (ch >= 'A' && ch <= 'F') nibble = (uint)(ch - 'A' + 10);
|
||||
else if (ch >= 'a' && ch <= 'f') nibble = (uint)(ch - 'a' + 10);
|
||||
else throw new ArgumentException(
|
||||
$"Address contains non-hex digit '{ch}' — Q/L/iQ-R X/Y addresses are hexadecimal",
|
||||
nameof(digits));
|
||||
result = result * 16 + nibble;
|
||||
if (result > ushort.MaxValue)
|
||||
throw new OverflowException($"Hex address exceeds 0xFFFF");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static uint ParseOctal(string digits)
|
||||
{
|
||||
uint result = 0;
|
||||
foreach (var ch in digits)
|
||||
{
|
||||
if (ch < '0' || ch > '7')
|
||||
throw new ArgumentException(
|
||||
$"Address contains non-octal digit '{ch}' — FX/iQ-F X/Y addresses are octal (0-7)",
|
||||
nameof(digits));
|
||||
result = result * 8 + (uint)(ch - '0');
|
||||
if (result > ushort.MaxValue)
|
||||
throw new OverflowException($"Octal address exceeds 0xFFFF");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
|
||||
private CancellationTokenSource? _probeCts;
|
||||
private readonly ModbusDriverOptions _options = options;
|
||||
private readonly Func<ModbusDriverOptions, IModbusTransport> _transportFactory =
|
||||
transportFactory ?? (o => new ModbusTcpTransport(o.Host, o.Port, o.Timeout));
|
||||
transportFactory ?? (o => new ModbusTcpTransport(o.Host, o.Port, o.Timeout, o.AutoReconnect));
|
||||
|
||||
private IModbusTransport? _transport;
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
@@ -141,9 +141,16 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
|
||||
results[i] = new DataValueSnapshot(value, 0u, now, now);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
}
|
||||
catch (ModbusException mex)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, MapModbusExceptionToStatus(mex.ExceptionCode), null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, mex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, StatusBadInternalError, null, now);
|
||||
// Non-Modbus-layer failure: socket dropped, timeout, malformed response. Surface
|
||||
// as communication error so callers can distinguish it from tag-level faults.
|
||||
results[i] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
@@ -171,11 +178,14 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
|
||||
{
|
||||
var quantity = RegisterCount(tag);
|
||||
var fc = tag.Region == ModbusRegion.HoldingRegisters ? (byte)0x03 : (byte)0x04;
|
||||
var pdu = new byte[] { fc, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
|
||||
(byte)(quantity >> 8), (byte)(quantity & 0xFF) };
|
||||
var resp = await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
|
||||
// resp = [fc][byte-count][data...]
|
||||
var data = new ReadOnlySpan<byte>(resp, 2, resp[1]);
|
||||
// Auto-chunk when the tag's register span exceeds the caller-configured cap.
|
||||
// Affects long strings (FC03/04 > 125 regs is spec-forbidden; DL205 caps at 128,
|
||||
// Mitsubishi Q caps at 64). Non-string tags max out at 4 regs so the cap never
|
||||
// triggers for numerics.
|
||||
var cap = _options.MaxRegistersPerRead == 0 ? (ushort)125 : _options.MaxRegistersPerRead;
|
||||
var data = quantity <= cap
|
||||
? await ReadRegisterBlockAsync(transport, fc, tag.Address, quantity, ct).ConfigureAwait(false)
|
||||
: await ReadRegisterBlockChunkedAsync(transport, fc, tag.Address, quantity, cap, ct).ConfigureAwait(false);
|
||||
return DecodeRegister(data, tag);
|
||||
}
|
||||
default:
|
||||
@@ -183,6 +193,33 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<byte[]> ReadRegisterBlockAsync(
|
||||
IModbusTransport transport, byte fc, ushort address, ushort quantity, CancellationToken ct)
|
||||
{
|
||||
var pdu = new byte[] { fc, (byte)(address >> 8), (byte)(address & 0xFF),
|
||||
(byte)(quantity >> 8), (byte)(quantity & 0xFF) };
|
||||
var resp = await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
|
||||
// resp = [fc][byte-count][data...]
|
||||
var data = new byte[resp[1]];
|
||||
Buffer.BlockCopy(resp, 2, data, 0, resp[1]);
|
||||
return data;
|
||||
}
|
||||
|
||||
private async Task<byte[]> ReadRegisterBlockChunkedAsync(
|
||||
IModbusTransport transport, byte fc, ushort address, ushort totalRegs, ushort cap, CancellationToken ct)
|
||||
{
|
||||
var assembled = new byte[totalRegs * 2];
|
||||
ushort done = 0;
|
||||
while (done < totalRegs)
|
||||
{
|
||||
var chunk = (ushort)Math.Min(cap, totalRegs - done);
|
||||
var chunkBytes = await ReadRegisterBlockAsync(transport, fc, (ushort)(address + done), chunk, ct).ConfigureAwait(false);
|
||||
Buffer.BlockCopy(chunkBytes, 0, assembled, done * 2, chunkBytes.Length);
|
||||
done += chunk;
|
||||
}
|
||||
return assembled;
|
||||
}
|
||||
|
||||
// ---- IWritable ----
|
||||
|
||||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
@@ -208,6 +245,10 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
|
||||
await WriteOneAsync(transport, tag, w.Value, cancellationToken).ConfigureAwait(false);
|
||||
results[i] = new WriteResult(0u);
|
||||
}
|
||||
catch (ModbusException mex)
|
||||
{
|
||||
results[i] = new WriteResult(MapModbusExceptionToStatus(mex.ExceptionCode));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
results[i] = new WriteResult(StatusBadInternalError);
|
||||
@@ -239,8 +280,13 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
|
||||
}
|
||||
else
|
||||
{
|
||||
// FC 16 (Write Multiple Registers) for 32-bit types
|
||||
// FC 16 (Write Multiple Registers) for 32-bit types.
|
||||
var qty = (ushort)(bytes.Length / 2);
|
||||
var writeCap = _options.MaxRegistersPerWrite == 0 ? (ushort)123 : _options.MaxRegistersPerWrite;
|
||||
if (qty > writeCap)
|
||||
throw new InvalidOperationException(
|
||||
$"Write of {qty} registers to {tag.Name} exceeds MaxRegistersPerWrite={writeCap}. " +
|
||||
$"Split the tag (e.g. shorter StringLength) — partial FC16 chunks would lose atomicity.");
|
||||
var pdu = new byte[6 + 1 + bytes.Length];
|
||||
pdu[0] = 0x10;
|
||||
pdu[1] = (byte)(tag.Address >> 8); pdu[2] = (byte)(tag.Address & 0xFF);
|
||||
@@ -404,8 +450,8 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
|
||||
/// </summary>
|
||||
internal static ushort RegisterCount(ModbusTagDefinition tag) => tag.DataType switch
|
||||
{
|
||||
ModbusDataType.Int16 or ModbusDataType.UInt16 or ModbusDataType.BitInRegister => 1,
|
||||
ModbusDataType.Int32 or ModbusDataType.UInt32 or ModbusDataType.Float32 => 2,
|
||||
ModbusDataType.Int16 or ModbusDataType.UInt16 or ModbusDataType.BitInRegister or ModbusDataType.Bcd16 => 1,
|
||||
ModbusDataType.Int32 or ModbusDataType.UInt32 or ModbusDataType.Float32 or ModbusDataType.Bcd32 => 2,
|
||||
ModbusDataType.Int64 or ModbusDataType.UInt64 or ModbusDataType.Float64 => 4,
|
||||
ModbusDataType.String => (ushort)((tag.StringLength + 1) / 2), // 2 chars per register
|
||||
_ => throw new InvalidOperationException($"Non-register data type {tag.DataType}"),
|
||||
@@ -435,6 +481,17 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
|
||||
{
|
||||
case ModbusDataType.Int16: return BinaryPrimitives.ReadInt16BigEndian(data);
|
||||
case ModbusDataType.UInt16: return BinaryPrimitives.ReadUInt16BigEndian(data);
|
||||
case ModbusDataType.Bcd16:
|
||||
{
|
||||
var raw = BinaryPrimitives.ReadUInt16BigEndian(data);
|
||||
return (int)DecodeBcd(raw, nibbles: 4);
|
||||
}
|
||||
case ModbusDataType.Bcd32:
|
||||
{
|
||||
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
||||
var raw = BinaryPrimitives.ReadUInt32BigEndian(b);
|
||||
return (int)DecodeBcd(raw, nibbles: 8);
|
||||
}
|
||||
case ModbusDataType.BitInRegister:
|
||||
{
|
||||
var raw = BinaryPrimitives.ReadUInt16BigEndian(data);
|
||||
@@ -472,13 +529,21 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
|
||||
}
|
||||
case ModbusDataType.String:
|
||||
{
|
||||
// ASCII, 2 chars per register, packed high byte = first char.
|
||||
// Respect the caller's StringLength (truncate nul-padded regions).
|
||||
// ASCII, 2 chars per register. HighByteFirst (standard) packs the first char in
|
||||
// the high byte of each register; LowByteFirst (DL205/DL260) packs the first char
|
||||
// in the low byte. Respect StringLength (truncate nul-padded regions).
|
||||
var chars = new char[tag.StringLength];
|
||||
for (var i = 0; i < tag.StringLength; i++)
|
||||
{
|
||||
var b = data[i];
|
||||
if (b == 0) { return new string(chars, 0, i); }
|
||||
var regIdx = i / 2;
|
||||
var highByte = data[regIdx * 2];
|
||||
var lowByte = data[regIdx * 2 + 1];
|
||||
byte b;
|
||||
if (tag.StringByteOrder == ModbusStringByteOrder.HighByteFirst)
|
||||
b = (i % 2 == 0) ? highByte : lowByte;
|
||||
else
|
||||
b = (i % 2 == 0) ? lowByte : highByte;
|
||||
if (b == 0) return new string(chars, 0, i);
|
||||
chars[i] = (char)b;
|
||||
}
|
||||
return new string(chars);
|
||||
@@ -502,6 +567,21 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
|
||||
var v = Convert.ToUInt16(value);
|
||||
var b = new byte[2]; BinaryPrimitives.WriteUInt16BigEndian(b, v); return b;
|
||||
}
|
||||
case ModbusDataType.Bcd16:
|
||||
{
|
||||
var v = Convert.ToUInt32(value);
|
||||
if (v > 9999) throw new OverflowException($"BCD16 value {v} exceeds 4 decimal digits");
|
||||
var raw = (ushort)EncodeBcd(v, nibbles: 4);
|
||||
var b = new byte[2]; BinaryPrimitives.WriteUInt16BigEndian(b, raw); return b;
|
||||
}
|
||||
case ModbusDataType.Bcd32:
|
||||
{
|
||||
var v = Convert.ToUInt32(value);
|
||||
if (v > 99_999_999u) throw new OverflowException($"BCD32 value {v} exceeds 8 decimal digits");
|
||||
var raw = EncodeBcd(v, nibbles: 8);
|
||||
var b = new byte[4]; BinaryPrimitives.WriteUInt32BigEndian(b, raw);
|
||||
return NormalizeWordOrder(b, tag.ByteOrder);
|
||||
}
|
||||
case ModbusDataType.Int32:
|
||||
{
|
||||
var v = Convert.ToInt32(value);
|
||||
@@ -543,7 +623,14 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
|
||||
var s = Convert.ToString(value) ?? string.Empty;
|
||||
var regs = (tag.StringLength + 1) / 2;
|
||||
var b = new byte[regs * 2];
|
||||
for (var i = 0; i < tag.StringLength && i < s.Length; i++) b[i] = (byte)s[i];
|
||||
for (var i = 0; i < tag.StringLength && i < s.Length; i++)
|
||||
{
|
||||
var regIdx = i / 2;
|
||||
var destIdx = tag.StringByteOrder == ModbusStringByteOrder.HighByteFirst
|
||||
? (i % 2 == 0 ? regIdx * 2 : regIdx * 2 + 1)
|
||||
: (i % 2 == 0 ? regIdx * 2 + 1 : regIdx * 2);
|
||||
b[destIdx] = (byte)s[i];
|
||||
}
|
||||
// remaining bytes stay 0 — nul-padded per PLC convention
|
||||
return b;
|
||||
}
|
||||
@@ -564,15 +651,77 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
|
||||
ModbusDataType.Float32 => DriverDataType.Float32,
|
||||
ModbusDataType.Float64 => DriverDataType.Float64,
|
||||
ModbusDataType.String => DriverDataType.String,
|
||||
ModbusDataType.Bcd16 or ModbusDataType.Bcd32 => DriverDataType.Int32,
|
||||
_ => DriverDataType.Int32,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Decode an N-nibble binary-coded-decimal value. Each nibble of <paramref name="raw"/>
|
||||
/// encodes one decimal digit (most-significant nibble first). Rejects nibbles > 9 —
|
||||
/// the hardware sometimes produces garbage during transitions and silent non-BCD reads
|
||||
/// would quietly corrupt the caller's data.
|
||||
/// </summary>
|
||||
internal static uint DecodeBcd(uint raw, int nibbles)
|
||||
{
|
||||
uint result = 0;
|
||||
for (var i = nibbles - 1; i >= 0; i--)
|
||||
{
|
||||
var digit = (raw >> (i * 4)) & 0xF;
|
||||
if (digit > 9)
|
||||
throw new InvalidDataException(
|
||||
$"Non-BCD nibble 0x{digit:X} at position {i} of raw=0x{raw:X}");
|
||||
result = result * 10 + digit;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encode a decimal value as N-nibble BCD. Caller is responsible for range-checking
|
||||
/// against the nibble capacity (10^nibbles - 1).
|
||||
/// </summary>
|
||||
internal static uint EncodeBcd(uint value, int nibbles)
|
||||
{
|
||||
uint result = 0;
|
||||
for (var i = 0; i < nibbles; i++)
|
||||
{
|
||||
var digit = value % 10;
|
||||
result |= digit << (i * 4);
|
||||
value /= 10;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private IModbusTransport RequireTransport() =>
|
||||
_transport ?? throw new InvalidOperationException("ModbusDriver not initialized");
|
||||
|
||||
private const uint StatusBadInternalError = 0x80020000u;
|
||||
private const uint StatusBadNodeIdUnknown = 0x80340000u;
|
||||
private const uint StatusBadNotWritable = 0x803B0000u;
|
||||
private const uint StatusBadOutOfRange = 0x803C0000u;
|
||||
private const uint StatusBadNotSupported = 0x803D0000u;
|
||||
private const uint StatusBadDeviceFailure = 0x80550000u;
|
||||
private const uint StatusBadCommunicationError = 0x80050000u;
|
||||
|
||||
/// <summary>
|
||||
/// Map a server-returned Modbus exception code to the most informative OPC UA
|
||||
/// StatusCode. Keeps the driver's outward-facing status surface aligned with what a
|
||||
/// Modbus engineer would expect when reading the spec: exception 02 (Illegal Data
|
||||
/// Address) surfaces as BadOutOfRange so clients can distinguish "tag wrong" from
|
||||
/// generic BadInternalError, exception 04 (Server Failure) as BadDeviceFailure so
|
||||
/// operators see a CPU-mode problem rather than a driver bug, etc. Per
|
||||
/// <c>docs/v2/dl205.md</c>, DL205/DL260 returns only codes 01-04 — no proprietary
|
||||
/// extensions.
|
||||
/// </summary>
|
||||
internal static uint MapModbusExceptionToStatus(byte exceptionCode) => exceptionCode switch
|
||||
{
|
||||
0x01 => StatusBadNotSupported, // Illegal Function — FC not in supported list
|
||||
0x02 => StatusBadOutOfRange, // Illegal Data Address — register outside mapped range
|
||||
0x03 => StatusBadOutOfRange, // Illegal Data Value — quantity over per-FC cap
|
||||
0x04 => StatusBadDeviceFailure, // Server Failure — CPU in PROGRAM mode during protected write
|
||||
0x05 or 0x06 => StatusBadDeviceFailure, // Acknowledge / Server Busy — long-running op / busy
|
||||
0x0A or 0x0B => StatusBadCommunicationError, // Gateway path unavailable / target failed to respond
|
||||
_ => StatusBadInternalError,
|
||||
};
|
||||
|
||||
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
public async ValueTask DisposeAsync()
|
||||
|
||||
@@ -25,6 +25,37 @@ public sealed class ModbusDriverOptions
|
||||
/// <see cref="IHostConnectivityProbe"/>.
|
||||
/// </summary>
|
||||
public ModbusProbeOptions Probe { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Maximum registers per FC03 (Read Holding Registers) / FC04 (Read Input Registers)
|
||||
/// transaction. Modbus-TCP spec allows 125; many device families impose lower caps:
|
||||
/// AutomationDirect DL205/DL260 cap at <c>128</c>, Mitsubishi Q/FX3U cap at <c>64</c>,
|
||||
/// Omron CJ/CS cap at <c>125</c>. Set to the lowest cap across the devices this driver
|
||||
/// instance talks to; the driver auto-chunks larger reads into consecutive requests.
|
||||
/// Default <c>125</c> — the spec maximum, safe against any conforming server. Setting
|
||||
/// to <c>0</c> disables the cap (discouraged — the spec upper bound still applies).
|
||||
/// </summary>
|
||||
public ushort MaxRegistersPerRead { get; init; } = 125;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum registers per FC16 (Write Multiple Registers) transaction. Spec maximum is
|
||||
/// <c>123</c>; DL205/DL260 cap at <c>100</c>. Matching caller-vs-device semantics:
|
||||
/// exceeding the cap currently throws (writes aren't auto-chunked because a partial
|
||||
/// write across two FC16 calls is no longer atomic — caller must explicitly opt in
|
||||
/// by shortening the tag's <c>StringLength</c> or splitting it into multiple tags).
|
||||
/// </summary>
|
||||
public ushort MaxRegistersPerWrite { get; init; } = 123;
|
||||
|
||||
/// <summary>
|
||||
/// When <c>true</c> (default) the built-in <see cref="ModbusTcpTransport"/> detects
|
||||
/// mid-transaction socket failures (<see cref="System.IO.EndOfStreamException"/>,
|
||||
/// <see cref="System.Net.Sockets.SocketException"/>) and transparently reconnects +
|
||||
/// retries the PDU exactly once. Required for DL205/DL260 because the H2-ECOM100
|
||||
/// does not send TCP keepalives — intermediate NAT / firewall devices silently close
|
||||
/// idle sockets and the first send after the drop would otherwise surface as a
|
||||
/// connection error to the caller even though the PLC is up.
|
||||
/// </summary>
|
||||
public bool AutoReconnect { get; init; } = true;
|
||||
}
|
||||
|
||||
public sealed class ModbusProbeOptions
|
||||
@@ -55,6 +86,12 @@ public sealed class ModbusProbeOptions
|
||||
/// <param name="ByteOrder">Word ordering for multi-register types. Ignored for Bool / Int16 / UInt16 / BitInRegister / String.</param>
|
||||
/// <param name="BitIndex">For <c>DataType = BitInRegister</c>: which bit of the holding register (0-15, LSB-first).</param>
|
||||
/// <param name="StringLength">For <c>DataType = String</c>: number of ASCII characters (2 per register, rounded up).</param>
|
||||
/// <param name="StringByteOrder">
|
||||
/// Per-register byte order for <c>DataType = String</c>. Standard Modbus packs the first
|
||||
/// character in the high byte (<see cref="ModbusStringByteOrder.HighByteFirst"/>).
|
||||
/// AutomationDirect DirectLOGIC (DL205/DL260) and a few legacy families pack the first
|
||||
/// character in the low byte instead — see <c>docs/v2/dl205.md</c> §strings.
|
||||
/// </param>
|
||||
public sealed record ModbusTagDefinition(
|
||||
string Name,
|
||||
ModbusRegion Region,
|
||||
@@ -63,7 +100,8 @@ public sealed record ModbusTagDefinition(
|
||||
bool Writable = true,
|
||||
ModbusByteOrder ByteOrder = ModbusByteOrder.BigEndian,
|
||||
byte BitIndex = 0,
|
||||
ushort StringLength = 0);
|
||||
ushort StringLength = 0,
|
||||
ModbusStringByteOrder StringByteOrder = ModbusStringByteOrder.HighByteFirst);
|
||||
|
||||
public enum ModbusRegion { Coils, DiscreteInputs, InputRegisters, HoldingRegisters }
|
||||
|
||||
@@ -82,6 +120,18 @@ public enum ModbusDataType
|
||||
BitInRegister,
|
||||
/// <summary>ASCII string packed 2 chars per register, <see cref="ModbusTagDefinition.StringLength"/> characters long.</summary>
|
||||
String,
|
||||
/// <summary>
|
||||
/// 16-bit binary-coded decimal. Each nibble encodes one decimal digit (0-9). Register
|
||||
/// value <c>0x1234</c> decodes as decimal <c>1234</c> — NOT binary <c>0x04D2 = 4660</c>.
|
||||
/// DL205/DL260 and several Mitsubishi / Omron families store timers, counters, and
|
||||
/// operator-facing numerics as BCD by default.
|
||||
/// </summary>
|
||||
Bcd16,
|
||||
/// <summary>
|
||||
/// 32-bit (two-register) BCD. Decodes 8 decimal digits. Word ordering follows
|
||||
/// <see cref="ModbusTagDefinition.ByteOrder"/> the same way <see cref="Int32"/> does.
|
||||
/// </summary>
|
||||
Bcd32,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -95,3 +145,17 @@ public enum ModbusByteOrder
|
||||
BigEndian,
|
||||
WordSwap,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-register byte order for ASCII strings packed 2 chars per register. Standard Modbus
|
||||
/// convention is <see cref="HighByteFirst"/> — the first character of each pair occupies
|
||||
/// the high byte of the register. AutomationDirect DirectLOGIC (DL205, DL260, DL350) and a
|
||||
/// handful of legacy controllers pack <see cref="LowByteFirst"/>, which inverts that within
|
||||
/// each register. Word ordering across multiple registers is always ascending address for
|
||||
/// strings — only the byte order inside each register flips.
|
||||
/// </summary>
|
||||
public enum ModbusStringByteOrder
|
||||
{
|
||||
HighByteFirst,
|
||||
LowByteFirst,
|
||||
}
|
||||
|
||||
@@ -8,33 +8,83 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
/// support concurrent transactions, but the single-flight model keeps the wire trace
|
||||
/// easy to diagnose and avoids interleaved-response correlation bugs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Survives mid-transaction socket drops: when a send/read fails with a socket-level
|
||||
/// error (<see cref="IOException"/>, <see cref="SocketException"/>, <see cref="EndOfStreamException"/>)
|
||||
/// the transport disposes the dead socket, reconnects, and retries the PDU exactly
|
||||
/// once. Deliberately limited to a single retry — further failures bubble up so the
|
||||
/// driver's health surface reflects the real state instead of masking a dead PLC.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Why this matters for DL205/DL260: the AutomationDirect H2-ECOM100 does NOT send
|
||||
/// TCP keepalives per <c>docs/v2/dl205.md</c> §behavioral-oddities, so any NAT/firewall
|
||||
/// between the gateway and PLC can silently close an idle socket after 2-5 minutes.
|
||||
/// Also enables OS-level <c>SO_KEEPALIVE</c> so the driver's own side detects a stuck
|
||||
/// socket in reasonable time even when the application is mostly idle.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ModbusTcpTransport : IModbusTransport
|
||||
{
|
||||
private readonly string _host;
|
||||
private readonly int _port;
|
||||
private readonly TimeSpan _timeout;
|
||||
private readonly bool _autoReconnect;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private TcpClient? _client;
|
||||
private NetworkStream? _stream;
|
||||
private ushort _nextTx;
|
||||
private bool _disposed;
|
||||
|
||||
public ModbusTcpTransport(string host, int port, TimeSpan timeout)
|
||||
public ModbusTcpTransport(string host, int port, TimeSpan timeout, bool autoReconnect = true)
|
||||
{
|
||||
_host = host;
|
||||
_port = port;
|
||||
_timeout = timeout;
|
||||
_autoReconnect = autoReconnect;
|
||||
}
|
||||
|
||||
public async Task ConnectAsync(CancellationToken ct)
|
||||
{
|
||||
_client = new TcpClient();
|
||||
// Resolve the host explicitly + prefer IPv4. .NET's TcpClient default-constructor is
|
||||
// dual-stack (IPv6 first, fallback to IPv4) — but most Modbus TCP devices (PLCs and
|
||||
// simulators like pymodbus) bind 0.0.0.0 only, so the IPv6 attempt times out and we
|
||||
// burn the entire ConnectAsync budget before even trying IPv4. Resolving first +
|
||||
// dialing the IPv4 address directly sidesteps that.
|
||||
var addresses = await System.Net.Dns.GetHostAddressesAsync(_host, ct).ConfigureAwait(false);
|
||||
var ipv4 = System.Linq.Enumerable.FirstOrDefault(addresses,
|
||||
a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork);
|
||||
var target = ipv4 ?? (addresses.Length > 0 ? addresses[0] : System.Net.IPAddress.Loopback);
|
||||
|
||||
_client = new TcpClient(target.AddressFamily);
|
||||
EnableKeepAlive(_client);
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(_timeout);
|
||||
await _client.ConnectAsync(_host, _port, cts.Token).ConfigureAwait(false);
|
||||
await _client.ConnectAsync(target, _port, cts.Token).ConfigureAwait(false);
|
||||
_stream = _client.GetStream();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enable SO_KEEPALIVE with aggressive probe timing. DL205/DL260 doesn't send keepalives
|
||||
/// itself; having the OS probe the socket every ~30s lets the driver notice a dead PLC
|
||||
/// or broken NAT path long before the default 2-hour Windows idle timeout fires.
|
||||
/// Non-fatal if the underlying OS rejects the option (some older Linux / container
|
||||
/// sandboxes don't expose the fine-grained timing levers — the driver still works,
|
||||
/// application-level probe still detects problems).
|
||||
/// </summary>
|
||||
private static void EnableKeepAlive(TcpClient client)
|
||||
{
|
||||
try
|
||||
{
|
||||
client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);
|
||||
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, 30);
|
||||
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, 10);
|
||||
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, 3);
|
||||
}
|
||||
catch { /* best-effort; older OSes may not expose the granular knobs */ }
|
||||
}
|
||||
|
||||
public async Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(ModbusTcpTransport));
|
||||
@@ -43,6 +93,28 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
return await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (_autoReconnect && IsSocketLevelFailure(ex))
|
||||
{
|
||||
// Mid-transaction drop: tear down the dead socket, reconnect, resend. Single
|
||||
// retry — if it fails again, let it propagate so health/status reflect reality.
|
||||
await TearDownAsync().ConfigureAwait(false);
|
||||
await ConnectAsync(ct).ConfigureAwait(false);
|
||||
return await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<byte[]> SendOnceAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
if (_stream is null) throw new InvalidOperationException("Transport not connected");
|
||||
var txId = ++_nextTx;
|
||||
|
||||
// MBAP: [TxId(2)][Proto=0(2)][Length(2)][UnitId(1)] + PDU
|
||||
@@ -81,10 +153,25 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
|
||||
return respPdu;
|
||||
}
|
||||
finally
|
||||
|
||||
/// <summary>
|
||||
/// Distinguish socket-layer failures (eligible for reconnect-and-retry) from
|
||||
/// protocol-layer failures (must propagate — retrying the same PDU won't help if the
|
||||
/// PLC just returned exception 02 Illegal Data Address).
|
||||
/// </summary>
|
||||
private static bool IsSocketLevelFailure(Exception ex) =>
|
||||
ex is EndOfStreamException
|
||||
|| ex is IOException
|
||||
|| ex is SocketException
|
||||
|| ex is ObjectDisposedException;
|
||||
|
||||
private async Task TearDownAsync()
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
try { if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false); }
|
||||
catch { /* best-effort */ }
|
||||
_stream = null;
|
||||
try { _client?.Dispose(); } catch { }
|
||||
_client = null;
|
||||
}
|
||||
|
||||
private static async Task ReadExactlyAsync(Stream s, byte[] buf, CancellationToken ct)
|
||||
|
||||
817
src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs
Normal file
817
src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs
Normal file
@@ -0,0 +1,817 @@
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Opc.Ua.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA Client (gateway) driver. Opens a <see cref="Session"/> against a remote OPC UA
|
||||
/// server and re-exposes its address space through the local OtOpcUa server. PR 66 ships
|
||||
/// the scaffold: <see cref="IDriver"/> only (connect / close / health). Browse, read,
|
||||
/// write, subscribe, and probe land in PRs 67-69.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Builds its own <see cref="ApplicationConfiguration"/> rather than reusing
|
||||
/// <c>Client.Shared</c> — Client.Shared is oriented at the interactive CLI; this
|
||||
/// driver is an always-on service component with different session-lifetime needs
|
||||
/// (keep-alive monitor, session transfer on reconnect, multi-year uptime).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Session lifetime</b>: a single <see cref="Session"/> per driver instance.
|
||||
/// Subscriptions multiplex onto that session; SDK reconnect handler takes the session
|
||||
/// down and brings it back up on remote-server restart — the driver must re-send
|
||||
/// subscriptions + TransferSubscriptions on reconnect to avoid dangling
|
||||
/// monitored-item handles. That mechanic lands in PR 69.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string driverInstanceId)
|
||||
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IDisposable, IAsyncDisposable
|
||||
{
|
||||
// ---- ISubscribable + IHostConnectivityProbe state ----
|
||||
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<long, RemoteSubscription> _subscriptions = new();
|
||||
private long _nextSubscriptionId;
|
||||
private readonly object _probeLock = new();
|
||||
private HostState _hostState = HostState.Unknown;
|
||||
private DateTime _hostStateChangedUtc = DateTime.UtcNow;
|
||||
private KeepAliveEventHandler? _keepAliveHandler;
|
||||
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
|
||||
// OPC UA StatusCode constants the driver surfaces for local-side faults. Upstream-server
|
||||
// StatusCodes are passed through verbatim per driver-specs.md §8 "cascading quality" —
|
||||
// downstream clients need to distinguish 'remote source down' from 'local driver failure'.
|
||||
private const uint StatusBadNodeIdInvalid = 0x80330000u;
|
||||
private const uint StatusBadInternalError = 0x80020000u;
|
||||
private const uint StatusBadCommunicationError = 0x80050000u;
|
||||
|
||||
private readonly OpcUaClientDriverOptions _options = options;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
/// <summary>Active OPC UA session. Null until <see cref="InitializeAsync"/> returns cleanly.</summary>
|
||||
internal ISession? Session { get; private set; }
|
||||
|
||||
/// <summary>Per-connection gate. PRs 67+ serialize read/write/browse on this.</summary>
|
||||
internal SemaphoreSlim Gate => _gate;
|
||||
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
private bool _disposed;
|
||||
/// <summary>URL of the endpoint the driver actually connected to. Exposed via <see cref="HostName"/>.</summary>
|
||||
private string? _connectedEndpointUrl;
|
||||
|
||||
public string DriverInstanceId => driverInstanceId;
|
||||
public string DriverType => "OpcUaClient";
|
||||
|
||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
try
|
||||
{
|
||||
var appConfig = await BuildApplicationConfigurationAsync(cancellationToken).ConfigureAwait(false);
|
||||
var candidates = ResolveEndpointCandidates(_options);
|
||||
|
||||
var identity = BuildUserIdentity(_options);
|
||||
|
||||
// Failover sweep: try each endpoint in order, return the session from the first
|
||||
// one that successfully connects. Per-endpoint failures are captured so the final
|
||||
// aggregate exception names every URL that was tried and why — critical diag for
|
||||
// operators debugging 'why did the failover pick #3?'.
|
||||
var attemptErrors = new List<string>(candidates.Count);
|
||||
ISession? session = null;
|
||||
string? connectedUrl = null;
|
||||
foreach (var url in candidates)
|
||||
{
|
||||
try
|
||||
{
|
||||
session = await OpenSessionOnEndpointAsync(
|
||||
appConfig, url, _options.SecurityPolicy, _options.SecurityMode,
|
||||
identity, cancellationToken).ConfigureAwait(false);
|
||||
connectedUrl = url;
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
attemptErrors.Add($"{url} -> {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (session is null)
|
||||
throw new AggregateException(
|
||||
"OPC UA Client failed to connect to any of the configured endpoints. " +
|
||||
"Tried:\n " + string.Join("\n ", attemptErrors),
|
||||
attemptErrors.Select(e => new InvalidOperationException(e)));
|
||||
|
||||
// Wire the session's keep-alive channel into HostState. OPC UA keep-alives are
|
||||
// authoritative for session liveness: the SDK pings on KeepAliveInterval and sets
|
||||
// KeepAliveStopped when N intervals elapse without a response. That's strictly
|
||||
// better than a driver-side polling probe — no extra round-trip, no duplicate
|
||||
// semantic.
|
||||
_keepAliveHandler = (_, e) =>
|
||||
{
|
||||
var healthy = !ServiceResult.IsBad(e.Status);
|
||||
TransitionTo(healthy ? HostState.Running : HostState.Stopped);
|
||||
};
|
||||
session.KeepAlive += _keepAliveHandler;
|
||||
|
||||
Session = session;
|
||||
_connectedEndpointUrl = connectedUrl;
|
||||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
TransitionTo(HostState.Running);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
try { if (Session is Session s) await s.CloseAsync().ConfigureAwait(false); } catch { }
|
||||
Session = null;
|
||||
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a minimal in-memory <see cref="ApplicationConfiguration"/>. Certificates live
|
||||
/// under the OS user profile — on Windows that's <c>%LocalAppData%\OtOpcUa\pki</c>
|
||||
/// — so multiple driver instances in the same OtOpcUa server process share one
|
||||
/// certificate store without extra config.
|
||||
/// </summary>
|
||||
private async Task<ApplicationConfiguration> BuildApplicationConfigurationAsync(CancellationToken ct)
|
||||
{
|
||||
// The default ctor is obsolete in favour of the ITelemetryContext overload; suppress
|
||||
// locally rather than plumbing a telemetry context all the way through the driver
|
||||
// surface — the driver emits no per-request telemetry of its own and the SDK's
|
||||
// internal fallback is fine for a gateway use case.
|
||||
#pragma warning disable CS0618
|
||||
var app = new ApplicationInstance
|
||||
{
|
||||
ApplicationName = _options.SessionName,
|
||||
ApplicationType = ApplicationType.Client,
|
||||
};
|
||||
#pragma warning restore CS0618
|
||||
|
||||
var pkiRoot = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"OtOpcUa", "pki");
|
||||
|
||||
var config = new ApplicationConfiguration
|
||||
{
|
||||
ApplicationName = _options.SessionName,
|
||||
ApplicationType = ApplicationType.Client,
|
||||
ApplicationUri = _options.ApplicationUri,
|
||||
SecurityConfiguration = new SecurityConfiguration
|
||||
{
|
||||
ApplicationCertificate = new CertificateIdentifier
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = Path.Combine(pkiRoot, "own"),
|
||||
SubjectName = $"CN={_options.SessionName}",
|
||||
},
|
||||
TrustedPeerCertificates = new CertificateTrustList
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = Path.Combine(pkiRoot, "trusted"),
|
||||
},
|
||||
TrustedIssuerCertificates = new CertificateTrustList
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = Path.Combine(pkiRoot, "issuers"),
|
||||
},
|
||||
RejectedCertificateStore = new CertificateTrustList
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = Path.Combine(pkiRoot, "rejected"),
|
||||
},
|
||||
AutoAcceptUntrustedCertificates = _options.AutoAcceptCertificates,
|
||||
},
|
||||
TransportQuotas = new TransportQuotas { OperationTimeout = (int)_options.Timeout.TotalMilliseconds },
|
||||
ClientConfiguration = new ClientConfiguration
|
||||
{
|
||||
DefaultSessionTimeout = (int)_options.SessionTimeout.TotalMilliseconds,
|
||||
},
|
||||
DisableHiResClock = true,
|
||||
};
|
||||
|
||||
await config.ValidateAsync(ApplicationType.Client, ct).ConfigureAwait(false);
|
||||
|
||||
// Attach a cert-validator handler that honours the AutoAccept flag. Without this,
|
||||
// AutoAcceptUntrustedCertificates on the config alone isn't always enough in newer
|
||||
// SDK versions — the validator raises an event the app has to handle.
|
||||
if (_options.AutoAcceptCertificates)
|
||||
{
|
||||
config.CertificateValidator.CertificateValidation += (s, e) =>
|
||||
{
|
||||
if (e.Error.StatusCode == StatusCodes.BadCertificateUntrusted)
|
||||
e.Accept = true;
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure an application certificate exists. The SDK auto-generates one if missing.
|
||||
app.ApplicationConfiguration = config;
|
||||
await app.CheckApplicationInstanceCertificatesAsync(silent: true, lifeTimeInMonths: null, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the ordered failover candidate list. <c>EndpointUrls</c> wins when
|
||||
/// non-empty; otherwise fall back to <c>EndpointUrl</c> as a single-URL shortcut so
|
||||
/// existing single-endpoint configs keep working without migration.
|
||||
/// </summary>
|
||||
internal static IReadOnlyList<string> ResolveEndpointCandidates(OpcUaClientDriverOptions opts)
|
||||
{
|
||||
if (opts.EndpointUrls is { Count: > 0 }) return opts.EndpointUrls;
|
||||
return [opts.EndpointUrl];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the user-identity token from the driver options. Split out of
|
||||
/// <see cref="InitializeAsync"/> so the failover sweep reuses one identity across
|
||||
/// every endpoint attempt — generating it N times would re-unlock the user cert's
|
||||
/// private key N times, wasteful + keeps the password in memory longer.
|
||||
/// </summary>
|
||||
internal static UserIdentity BuildUserIdentity(OpcUaClientDriverOptions options) =>
|
||||
options.AuthType switch
|
||||
{
|
||||
OpcUaAuthType.Anonymous => new UserIdentity(new AnonymousIdentityToken()),
|
||||
OpcUaAuthType.Username => new UserIdentity(
|
||||
options.Username ?? string.Empty,
|
||||
System.Text.Encoding.UTF8.GetBytes(options.Password ?? string.Empty)),
|
||||
OpcUaAuthType.Certificate => BuildCertificateIdentity(options),
|
||||
_ => new UserIdentity(new AnonymousIdentityToken()),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Open a session against a single endpoint URL. Bounded by
|
||||
/// <see cref="OpcUaClientDriverOptions.PerEndpointConnectTimeout"/> so the failover
|
||||
/// sweep doesn't spend its full budget on one dead server. Moved out of
|
||||
/// <see cref="InitializeAsync"/> so the failover loop body stays readable.
|
||||
/// </summary>
|
||||
private async Task<ISession> OpenSessionOnEndpointAsync(
|
||||
ApplicationConfiguration appConfig,
|
||||
string endpointUrl,
|
||||
OpcUaSecurityPolicy policy,
|
||||
OpcUaSecurityMode mode,
|
||||
UserIdentity identity,
|
||||
CancellationToken ct)
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(_options.PerEndpointConnectTimeout);
|
||||
|
||||
var selected = await SelectMatchingEndpointAsync(
|
||||
appConfig, endpointUrl, policy, mode, cts.Token).ConfigureAwait(false);
|
||||
var endpointConfig = EndpointConfiguration.Create(appConfig);
|
||||
endpointConfig.OperationTimeout = (int)_options.Timeout.TotalMilliseconds;
|
||||
var endpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
|
||||
|
||||
var session = await new DefaultSessionFactory(telemetry: null!).CreateAsync(
|
||||
appConfig,
|
||||
endpoint,
|
||||
false, // updateBeforeConnect
|
||||
_options.SessionName,
|
||||
(uint)_options.SessionTimeout.TotalMilliseconds,
|
||||
identity,
|
||||
null, // preferredLocales
|
||||
cts.Token).ConfigureAwait(false);
|
||||
|
||||
session.KeepAliveInterval = (int)_options.KeepAliveInterval.TotalMilliseconds;
|
||||
return session;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Select the remote endpoint matching both the requested <paramref name="policy"/>
|
||||
/// and <paramref name="mode"/>. The SDK's <c>CoreClientUtils.SelectEndpointAsync</c>
|
||||
/// only honours a boolean "use security" flag; we need policy-aware matching so an
|
||||
/// operator asking for <c>Basic256Sha256</c> against a server that also offers
|
||||
/// <c>Basic128Rsa15</c> doesn't silently end up on the weaker cipher.
|
||||
/// </summary>
|
||||
private static async Task<EndpointDescription> SelectMatchingEndpointAsync(
|
||||
ApplicationConfiguration appConfig,
|
||||
string endpointUrl,
|
||||
OpcUaSecurityPolicy policy,
|
||||
OpcUaSecurityMode mode,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// GetEndpoints returns everything the server advertises; policy + mode filter is
|
||||
// applied client-side so the selection is explicit and fails loudly if the operator
|
||||
// asks for a combination the server doesn't publish. DiscoveryClient.CreateAsync
|
||||
// is the non-obsolete path in SDK 1.5.378; the synchronous Create(..) variants are
|
||||
// all deprecated.
|
||||
using var client = await DiscoveryClient.CreateAsync(
|
||||
appConfig, new Uri(endpointUrl), Opc.Ua.DiagnosticsMasks.None, ct).ConfigureAwait(false);
|
||||
var all = await client.GetEndpointsAsync(null, ct).ConfigureAwait(false);
|
||||
|
||||
var wantedPolicyUri = MapSecurityPolicy(policy);
|
||||
var wantedMode = mode switch
|
||||
{
|
||||
OpcUaSecurityMode.None => MessageSecurityMode.None,
|
||||
OpcUaSecurityMode.Sign => MessageSecurityMode.Sign,
|
||||
OpcUaSecurityMode.SignAndEncrypt => MessageSecurityMode.SignAndEncrypt,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mode)),
|
||||
};
|
||||
|
||||
var match = all.FirstOrDefault(e =>
|
||||
e.SecurityPolicyUri == wantedPolicyUri && e.SecurityMode == wantedMode);
|
||||
|
||||
if (match is null)
|
||||
{
|
||||
var advertised = string.Join(", ", all
|
||||
.Select(e => $"{ShortPolicyName(e.SecurityPolicyUri)}/{e.SecurityMode}"));
|
||||
throw new InvalidOperationException(
|
||||
$"No endpoint at '{endpointUrl}' matches SecurityPolicy={policy} + SecurityMode={mode}. " +
|
||||
$"Server advertises: {advertised}");
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a <see cref="UserIdentity"/> carrying a client user-authentication
|
||||
/// certificate loaded from <see cref="OpcUaClientDriverOptions.UserCertificatePath"/>.
|
||||
/// Used when the remote server's endpoint advertises Certificate-type user tokens.
|
||||
/// Fails fast if the path is missing, the file doesn't exist, or the certificate
|
||||
/// lacks a private key (the private key is required to sign the user-token
|
||||
/// challenge during session activation).
|
||||
/// </summary>
|
||||
internal static UserIdentity BuildCertificateIdentity(OpcUaClientDriverOptions options)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.UserCertificatePath))
|
||||
throw new InvalidOperationException(
|
||||
"OpcUaAuthType.Certificate requires OpcUaClientDriverOptions.UserCertificatePath to be set.");
|
||||
if (!System.IO.File.Exists(options.UserCertificatePath))
|
||||
throw new System.IO.FileNotFoundException(
|
||||
$"User certificate not found at '{options.UserCertificatePath}'.",
|
||||
options.UserCertificatePath);
|
||||
|
||||
// X509CertificateLoader (new in .NET 9) is the only non-obsolete way to load a PFX
|
||||
// since the legacy X509Certificate2 ctors are marked obsolete on net10. Passes the
|
||||
// password through verbatim; PEM files with external keys fall back to
|
||||
// LoadCertificateFromFile which picks up the adjacent .key if present.
|
||||
var cert = System.Security.Cryptography.X509Certificates.X509CertificateLoader
|
||||
.LoadPkcs12FromFile(options.UserCertificatePath, options.UserCertificatePassword);
|
||||
|
||||
if (!cert.HasPrivateKey)
|
||||
throw new InvalidOperationException(
|
||||
$"User certificate at '{options.UserCertificatePath}' has no private key — " +
|
||||
"the private key is required to sign the OPC UA user-token challenge at session activation.");
|
||||
|
||||
return new UserIdentity(cert);
|
||||
}
|
||||
|
||||
/// <summary>Convert a driver <see cref="OpcUaSecurityPolicy"/> to the OPC UA policy URI.</summary>
|
||||
internal static string MapSecurityPolicy(OpcUaSecurityPolicy policy) => policy switch
|
||||
{
|
||||
OpcUaSecurityPolicy.None => SecurityPolicies.None,
|
||||
OpcUaSecurityPolicy.Basic128Rsa15 => SecurityPolicies.Basic128Rsa15,
|
||||
OpcUaSecurityPolicy.Basic256 => SecurityPolicies.Basic256,
|
||||
OpcUaSecurityPolicy.Basic256Sha256 => SecurityPolicies.Basic256Sha256,
|
||||
OpcUaSecurityPolicy.Aes128_Sha256_RsaOaep => SecurityPolicies.Aes128_Sha256_RsaOaep,
|
||||
OpcUaSecurityPolicy.Aes256_Sha256_RsaPss => SecurityPolicies.Aes256_Sha256_RsaPss,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(policy), policy, null),
|
||||
};
|
||||
|
||||
private static string ShortPolicyName(string policyUri) =>
|
||||
policyUri?.Substring(policyUri.LastIndexOf('#') + 1) ?? "(null)";
|
||||
|
||||
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
|
||||
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Tear down remote subscriptions first — otherwise Session.Close will try and may fail
|
||||
// with BadSubscriptionIdInvalid noise in the upstream log. _subscriptions is cleared
|
||||
// whether or not the wire-side delete succeeds since the local handles are useless
|
||||
// after close anyway.
|
||||
foreach (var rs in _subscriptions.Values)
|
||||
{
|
||||
try { await rs.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); }
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
_subscriptions.Clear();
|
||||
|
||||
if (_keepAliveHandler is not null && Session is not null)
|
||||
{
|
||||
try { Session.KeepAlive -= _keepAliveHandler; } catch { }
|
||||
}
|
||||
_keepAliveHandler = null;
|
||||
|
||||
try { if (Session is Session s) await s.CloseAsync(cancellationToken).ConfigureAwait(false); }
|
||||
catch { /* best-effort */ }
|
||||
try { Session?.Dispose(); } catch { }
|
||||
Session = null;
|
||||
_connectedEndpointUrl = null;
|
||||
|
||||
TransitionTo(HostState.Unknown);
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
}
|
||||
|
||||
public DriverHealth GetHealth() => _health;
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
// ---- IReadable ----
|
||||
|
||||
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
var session = RequireSession();
|
||||
var results = new DataValueSnapshot[fullReferences.Count];
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Parse NodeIds up-front. Tags whose reference doesn't parse get BadNodeIdInvalid
|
||||
// and are omitted from the wire request — saves a round-trip against the upstream
|
||||
// server for a fault the driver can detect locally.
|
||||
var toSend = new ReadValueIdCollection();
|
||||
var indexMap = new List<int>(fullReferences.Count); // maps wire-index -> results-index
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
if (!TryParseNodeId(session, fullReferences[i], out var nodeId))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, StatusBadNodeIdInvalid, null, now);
|
||||
continue;
|
||||
}
|
||||
toSend.Add(new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value });
|
||||
indexMap.Add(i);
|
||||
}
|
||||
|
||||
if (toSend.Count == 0) return results;
|
||||
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
var resp = await session.ReadAsync(
|
||||
requestHeader: null,
|
||||
maxAge: 0,
|
||||
timestampsToReturn: TimestampsToReturn.Both,
|
||||
nodesToRead: toSend,
|
||||
ct: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var values = resp.Results;
|
||||
for (var w = 0; w < values.Count; w++)
|
||||
{
|
||||
var r = indexMap[w];
|
||||
var dv = values[w];
|
||||
// Preserve the upstream StatusCode verbatim — including Bad codes per
|
||||
// §8's cascading-quality rule. Also preserve SourceTimestamp so downstream
|
||||
// clients can detect stale upstream data.
|
||||
results[r] = new DataValueSnapshot(
|
||||
Value: dv.Value,
|
||||
StatusCode: dv.StatusCode.Code,
|
||||
SourceTimestampUtc: dv.SourceTimestamp == DateTime.MinValue ? null : dv.SourceTimestamp,
|
||||
ServerTimestampUtc: dv.ServerTimestamp == DateTime.MinValue ? now : dv.ServerTimestamp);
|
||||
}
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Transport / timeout / session-dropped — fan out the same fault across every
|
||||
// tag in this batch. Per-tag StatusCode stays BadCommunicationError (not
|
||||
// BadInternalError) so operators distinguish "upstream unreachable" from
|
||||
// "driver bug".
|
||||
for (var w = 0; w < indexMap.Count; w++)
|
||||
{
|
||||
var r = indexMap[w];
|
||||
results[r] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
|
||||
}
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---- IWritable ----
|
||||
|
||||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
IReadOnlyList<Core.Abstractions.WriteRequest> writes, CancellationToken cancellationToken)
|
||||
{
|
||||
var session = RequireSession();
|
||||
var results = new WriteResult[writes.Count];
|
||||
|
||||
var toSend = new WriteValueCollection();
|
||||
var indexMap = new List<int>(writes.Count);
|
||||
for (var i = 0; i < writes.Count; i++)
|
||||
{
|
||||
if (!TryParseNodeId(session, writes[i].FullReference, out var nodeId))
|
||||
{
|
||||
results[i] = new WriteResult(StatusBadNodeIdInvalid);
|
||||
continue;
|
||||
}
|
||||
toSend.Add(new WriteValue
|
||||
{
|
||||
NodeId = nodeId,
|
||||
AttributeId = Attributes.Value,
|
||||
Value = new DataValue(new Variant(writes[i].Value)),
|
||||
});
|
||||
indexMap.Add(i);
|
||||
}
|
||||
|
||||
if (toSend.Count == 0) return results;
|
||||
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
var resp = await session.WriteAsync(
|
||||
requestHeader: null,
|
||||
nodesToWrite: toSend,
|
||||
ct: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var codes = resp.Results;
|
||||
for (var w = 0; w < codes.Count; w++)
|
||||
{
|
||||
var r = indexMap[w];
|
||||
// Pass upstream WriteResult StatusCode through verbatim. Success codes
|
||||
// include Good (0) and any warning-level Good* variants; anything with
|
||||
// the severity bits set is a Bad.
|
||||
results[r] = new WriteResult(codes[w].Code);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
for (var w = 0; w < indexMap.Count; w++)
|
||||
results[indexMap[w]] = new WriteResult(StatusBadCommunicationError);
|
||||
}
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse a tag's full-reference string as a NodeId. Accepts the standard OPC UA
|
||||
/// serialized forms (<c>ns=2;s=…</c>, <c>i=2253</c>, <c>ns=4;g=…</c>, <c>ns=3;b=…</c>).
|
||||
/// Empty + malformed strings return false; the driver surfaces that as
|
||||
/// <see cref="StatusBadNodeIdInvalid"/> without a wire round-trip.
|
||||
/// </summary>
|
||||
internal static bool TryParseNodeId(ISession session, string fullReference, out NodeId nodeId)
|
||||
{
|
||||
nodeId = NodeId.Null;
|
||||
if (string.IsNullOrWhiteSpace(fullReference)) return false;
|
||||
try
|
||||
{
|
||||
nodeId = NodeId.Parse(session.MessageContext, fullReference);
|
||||
return !NodeId.IsNull(nodeId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private ISession RequireSession() =>
|
||||
Session ?? throw new InvalidOperationException("OpcUaClientDriver not initialized");
|
||||
|
||||
// ---- ITagDiscovery ----
|
||||
|
||||
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
var session = RequireSession();
|
||||
|
||||
var root = !string.IsNullOrEmpty(_options.BrowseRoot)
|
||||
? NodeId.Parse(session.MessageContext, _options.BrowseRoot)
|
||||
: ObjectIds.ObjectsFolder;
|
||||
|
||||
var rootFolder = builder.Folder("Remote", "Remote");
|
||||
var visited = new HashSet<NodeId>();
|
||||
var discovered = 0;
|
||||
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await BrowseRecursiveAsync(session, root, rootFolder, visited,
|
||||
depth: 0, discovered: () => discovered, increment: () => discovered++,
|
||||
ct: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
private async Task BrowseRecursiveAsync(
|
||||
ISession session, NodeId node, IAddressSpaceBuilder folder, HashSet<NodeId> visited,
|
||||
int depth, Func<int> discovered, Action increment, CancellationToken ct)
|
||||
{
|
||||
if (depth >= _options.MaxBrowseDepth) return;
|
||||
if (discovered() >= _options.MaxDiscoveredNodes) return;
|
||||
if (!visited.Add(node)) return;
|
||||
|
||||
var browseDescriptions = new BrowseDescriptionCollection
|
||||
{
|
||||
new()
|
||||
{
|
||||
NodeId = node,
|
||||
BrowseDirection = BrowseDirection.Forward,
|
||||
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
|
||||
IncludeSubtypes = true,
|
||||
NodeClassMask = (uint)(NodeClass.Object | NodeClass.Variable),
|
||||
ResultMask = (uint)(BrowseResultMask.BrowseName | BrowseResultMask.DisplayName
|
||||
| BrowseResultMask.NodeClass | BrowseResultMask.TypeDefinition),
|
||||
}
|
||||
};
|
||||
|
||||
BrowseResponse resp;
|
||||
try
|
||||
{
|
||||
resp = await session.BrowseAsync(
|
||||
requestHeader: null,
|
||||
view: null,
|
||||
requestedMaxReferencesPerNode: 0,
|
||||
nodesToBrowse: browseDescriptions,
|
||||
ct: ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Transient browse failure on a sub-tree — don't kill the whole discovery, just
|
||||
// skip this branch. The driver's health surface will reflect the cascade via the
|
||||
// probe loop (PR 69).
|
||||
return;
|
||||
}
|
||||
|
||||
if (resp.Results.Count == 0) return;
|
||||
var refs = resp.Results[0].References;
|
||||
|
||||
foreach (var rf in refs)
|
||||
{
|
||||
if (discovered() >= _options.MaxDiscoveredNodes) break;
|
||||
|
||||
var childId = ExpandedNodeId.ToNodeId(rf.NodeId, session.NamespaceUris);
|
||||
if (NodeId.IsNull(childId)) continue;
|
||||
|
||||
var browseName = rf.BrowseName?.Name ?? childId.ToString();
|
||||
var displayName = rf.DisplayName?.Text ?? browseName;
|
||||
|
||||
if (rf.NodeClass == NodeClass.Object)
|
||||
{
|
||||
var subFolder = folder.Folder(browseName, displayName);
|
||||
increment();
|
||||
await BrowseRecursiveAsync(session, childId, subFolder, visited,
|
||||
depth + 1, discovered, increment, ct).ConfigureAwait(false);
|
||||
}
|
||||
else if (rf.NodeClass == NodeClass.Variable)
|
||||
{
|
||||
// Serialize the NodeId so the IReadable/IWritable surface receives a
|
||||
// round-trippable string. Deferring the DataType + AccessLevel fetch to a
|
||||
// follow-up PR — initial browse uses a conservative ViewOnly + Int32 default.
|
||||
var nodeIdString = childId.ToString() ?? string.Empty;
|
||||
folder.Variable(browseName, displayName, new DriverAttributeInfo(
|
||||
FullName: nodeIdString,
|
||||
DriverDataType: DriverDataType.Int32,
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false));
|
||||
increment();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ISubscribable ----
|
||||
|
||||
public async Task<ISubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
|
||||
{
|
||||
var session = RequireSession();
|
||||
var id = Interlocked.Increment(ref _nextSubscriptionId);
|
||||
var handle = new OpcUaSubscriptionHandle(id);
|
||||
|
||||
// Floor the publishing interval at 50ms — OPC UA servers routinely negotiate
|
||||
// minimum-supported intervals up anyway, but sending sub-50ms wastes negotiation
|
||||
// bandwidth on every subscription create.
|
||||
var intervalMs = publishingInterval < TimeSpan.FromMilliseconds(50)
|
||||
? 50
|
||||
: (int)publishingInterval.TotalMilliseconds;
|
||||
|
||||
var subscription = new Subscription(telemetry: null!, new SubscriptionOptions
|
||||
{
|
||||
DisplayName = $"opcua-sub-{id}",
|
||||
PublishingInterval = intervalMs,
|
||||
KeepAliveCount = 10,
|
||||
LifetimeCount = 1000,
|
||||
MaxNotificationsPerPublish = 0,
|
||||
PublishingEnabled = true,
|
||||
Priority = 0,
|
||||
TimestampsToReturn = TimestampsToReturn.Both,
|
||||
});
|
||||
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
session.AddSubscription(subscription);
|
||||
await subscription.CreateAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var fullRef in fullReferences)
|
||||
{
|
||||
if (!TryParseNodeId(session, fullRef, out var nodeId)) continue;
|
||||
// The tag string is routed through MonitoredItem.Handle so the Notification
|
||||
// handler can identify which tag changed without an extra lookup.
|
||||
var item = new MonitoredItem(telemetry: null!, new MonitoredItemOptions
|
||||
{
|
||||
DisplayName = fullRef,
|
||||
StartNodeId = nodeId,
|
||||
AttributeId = Attributes.Value,
|
||||
MonitoringMode = MonitoringMode.Reporting,
|
||||
SamplingInterval = intervalMs,
|
||||
QueueSize = 1,
|
||||
DiscardOldest = true,
|
||||
})
|
||||
{
|
||||
Handle = fullRef,
|
||||
};
|
||||
item.Notification += (mi, args) => OnMonitoredItemNotification(handle, mi, args);
|
||||
subscription.AddItem(item);
|
||||
}
|
||||
|
||||
await subscription.CreateItemsAsync(cancellationToken).ConfigureAwait(false);
|
||||
_subscriptions[id] = new RemoteSubscription(subscription, handle);
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
public async Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
if (handle is not OpcUaSubscriptionHandle h) return;
|
||||
if (!_subscriptions.TryRemove(h.Id, out var rs)) return;
|
||||
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
try { await rs.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); }
|
||||
catch { /* best-effort — the subscription may already be gone on reconnect */ }
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
private void OnMonitoredItemNotification(OpcUaSubscriptionHandle handle, MonitoredItem item, MonitoredItemNotificationEventArgs args)
|
||||
{
|
||||
// args.NotificationValue arrives as a MonitoredItemNotification for value-change
|
||||
// subscriptions; extract its DataValue. The Handle property carries our tag string.
|
||||
if (args.NotificationValue is not MonitoredItemNotification mn) return;
|
||||
var dv = mn.Value;
|
||||
if (dv is null) return;
|
||||
var fullRef = (item.Handle as string) ?? item.DisplayName ?? string.Empty;
|
||||
var snapshot = new DataValueSnapshot(
|
||||
Value: dv.Value,
|
||||
StatusCode: dv.StatusCode.Code,
|
||||
SourceTimestampUtc: dv.SourceTimestamp == DateTime.MinValue ? null : dv.SourceTimestamp,
|
||||
ServerTimestampUtc: dv.ServerTimestamp == DateTime.MinValue ? DateTime.UtcNow : dv.ServerTimestamp);
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, fullRef, snapshot));
|
||||
}
|
||||
|
||||
private sealed record RemoteSubscription(Subscription Subscription, OpcUaSubscriptionHandle Handle);
|
||||
|
||||
private sealed record OpcUaSubscriptionHandle(long Id) : ISubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId => $"opcua-sub-{Id}";
|
||||
}
|
||||
|
||||
// ---- IHostConnectivityProbe ----
|
||||
|
||||
/// <summary>
|
||||
/// Endpoint-URL-keyed host identity for the Admin /hosts dashboard. Reflects the
|
||||
/// endpoint the driver actually connected to after the failover sweep — not the
|
||||
/// first URL in the candidate list — so operators see which of the configured
|
||||
/// endpoints is currently serving traffic. Falls back to the first configured URL
|
||||
/// pre-init so the dashboard has something to render before the first connect.
|
||||
/// </summary>
|
||||
public string HostName => _connectedEndpointUrl
|
||||
?? ResolveEndpointCandidates(_options).FirstOrDefault()
|
||||
?? _options.EndpointUrl;
|
||||
|
||||
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses()
|
||||
{
|
||||
lock (_probeLock)
|
||||
return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)];
|
||||
}
|
||||
|
||||
private void TransitionTo(HostState newState)
|
||||
{
|
||||
HostState old;
|
||||
lock (_probeLock)
|
||||
{
|
||||
old = _hostState;
|
||||
if (old == newState) return;
|
||||
_hostState = newState;
|
||||
_hostStateChangedUtc = DateTime.UtcNow;
|
||||
}
|
||||
OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, old, newState));
|
||||
}
|
||||
|
||||
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
try { await ShutdownAsync(CancellationToken.None).ConfigureAwait(false); }
|
||||
catch { /* disposal is best-effort */ }
|
||||
_gate.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA Client (gateway) driver configuration. Bound from <c>DriverConfig</c> JSON at
|
||||
/// driver-host registration time. Models the settings documented in
|
||||
/// <c>docs/v2/driver-specs.md</c> §8.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This driver connects to a REMOTE OPC UA server and re-exposes its address space
|
||||
/// through the local OtOpcUa server — the opposite direction from the usual "server
|
||||
/// exposes PLC data" flow. Tier A (pure managed, OPC Foundation reference SDK); universal
|
||||
/// protections cover it.
|
||||
/// </remarks>
|
||||
public sealed class OpcUaClientDriverOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Remote OPC UA endpoint URL, e.g. <c>opc.tcp://plc.internal:4840</c>. Convenience
|
||||
/// shortcut for a single-endpoint deployment — equivalent to setting
|
||||
/// <see cref="EndpointUrls"/> to a list with this one URL. When both are provided,
|
||||
/// the list wins and <see cref="EndpointUrl"/> is ignored.
|
||||
/// </summary>
|
||||
public string EndpointUrl { get; init; } = "opc.tcp://localhost:4840";
|
||||
|
||||
/// <summary>
|
||||
/// Ordered list of candidate endpoint URLs for failover. The driver tries each in
|
||||
/// order at <see cref="OpcUaClientDriver.InitializeAsync"/> and on session drop;
|
||||
/// the first URL that successfully connects wins. Typical use-case: an OPC UA server
|
||||
/// pair running in hot-standby (primary 4840 + backup 4841) where either can serve
|
||||
/// the same address space. Leave unset (or empty) to use <see cref="EndpointUrl"/>
|
||||
/// as a single-URL shortcut.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> EndpointUrls { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Per-endpoint connect-attempt timeout during the failover sweep. Short enough that
|
||||
/// cycling through several dead servers doesn't blow the overall init budget, long
|
||||
/// enough to tolerate a slow TLS handshake on a healthy server. Applied independently
|
||||
/// of <see cref="Timeout"/> which governs steady-state operations.
|
||||
/// </summary>
|
||||
public TimeSpan PerEndpointConnectTimeout { get; init; } = TimeSpan.FromSeconds(3);
|
||||
|
||||
/// <summary>
|
||||
/// Security policy to require when selecting an endpoint. Either a
|
||||
/// <see cref="OpcUaSecurityPolicy"/> enum constant or a free-form string (for
|
||||
/// forward-compatibility with future OPC UA policies not yet in the enum).
|
||||
/// Matched against <c>EndpointDescription.SecurityPolicyUri</c> suffix — the driver
|
||||
/// connects to the first endpoint whose policy name matches AND whose mode matches
|
||||
/// <see cref="SecurityMode"/>. When set to <see cref="OpcUaSecurityPolicy.None"/>
|
||||
/// the driver picks any unsecured endpoint regardless of policy string.
|
||||
/// </summary>
|
||||
public OpcUaSecurityPolicy SecurityPolicy { get; init; } = OpcUaSecurityPolicy.None;
|
||||
|
||||
/// <summary>Security mode.</summary>
|
||||
public OpcUaSecurityMode SecurityMode { get; init; } = OpcUaSecurityMode.None;
|
||||
|
||||
/// <summary>Authentication type.</summary>
|
||||
public OpcUaAuthType AuthType { get; init; } = OpcUaAuthType.Anonymous;
|
||||
|
||||
/// <summary>User name (required only for <see cref="OpcUaAuthType.Username"/>).</summary>
|
||||
public string? Username { get; init; }
|
||||
|
||||
/// <summary>Password (required only for <see cref="OpcUaAuthType.Username"/>).</summary>
|
||||
public string? Password { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filesystem path to the user-identity certificate (PFX/PEM). Required when
|
||||
/// <see cref="AuthType"/> is <see cref="OpcUaAuthType.Certificate"/>. The driver
|
||||
/// loads the cert + private key, which the remote server validates against its
|
||||
/// <c>TrustedUserCertificates</c> store to authenticate the session's user token.
|
||||
/// Leave unset to use the driver's application-instance certificate as the user
|
||||
/// token (not typical — most deployments have a separate user cert).
|
||||
/// </summary>
|
||||
public string? UserCertificatePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional password that unlocks <see cref="UserCertificatePath"/> when the PFX is
|
||||
/// protected. PEM files generally have their password on the adjacent key file; this
|
||||
/// knob only applies to password-locked PFX.
|
||||
/// </summary>
|
||||
public string? UserCertificatePassword { get; init; }
|
||||
|
||||
/// <summary>Server-negotiated session timeout. Default 120s per driver-specs.md §8.</summary>
|
||||
public TimeSpan SessionTimeout { get; init; } = TimeSpan.FromSeconds(120);
|
||||
|
||||
/// <summary>Client-side keep-alive interval.</summary>
|
||||
public TimeSpan KeepAliveInterval { get; init; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>Initial reconnect delay after a session drop.</summary>
|
||||
public TimeSpan ReconnectPeriod { get; init; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// When <c>true</c>, the driver accepts any self-signed / untrusted server certificate.
|
||||
/// Dev-only — must be <c>false</c> in production so MITM attacks against the opc.tcp
|
||||
/// channel fail closed.
|
||||
/// </summary>
|
||||
public bool AutoAcceptCertificates { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Application URI the driver reports during session creation. Must match the
|
||||
/// subject-alt-name on the client certificate if one is used, which is why it's a
|
||||
/// config knob rather than hard-coded.
|
||||
/// </summary>
|
||||
public string ApplicationUri { get; init; } = "urn:localhost:OtOpcUa:GatewayClient";
|
||||
|
||||
/// <summary>
|
||||
/// Friendly name sent to the remote server for diagnostics. Shows up in the remote
|
||||
/// server's session-list so operators can identify which gateway instance is calling.
|
||||
/// </summary>
|
||||
public string SessionName { get; init; } = "OtOpcUa-Gateway";
|
||||
|
||||
/// <summary>Connect + per-operation timeout.</summary>
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>
|
||||
/// Root NodeId to mirror. Default <c>null</c> = <c>ObjectsFolder</c> (i=85). Set to
|
||||
/// a scoped root to restrict the address space the driver exposes locally — useful
|
||||
/// when the remote server has tens of thousands of nodes and only a subset is
|
||||
/// needed downstream.
|
||||
/// </summary>
|
||||
public string? BrowseRoot { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Cap on total nodes discovered during <c>DiscoverAsync</c>. Default 10_000 —
|
||||
/// bounds memory on runaway remote servers without being so low that normal
|
||||
/// deployments hit it. When the cap is reached discovery stops and a warning is
|
||||
/// written to the driver health surface; the partially-discovered tree is still
|
||||
/// projected into the local address space.
|
||||
/// </summary>
|
||||
public int MaxDiscoveredNodes { get; init; } = 10_000;
|
||||
|
||||
/// <summary>
|
||||
/// Max hierarchical depth of the browse. Default 10 — deep enough for realistic
|
||||
/// OPC UA information models, shallow enough that cyclic graphs can't spin the
|
||||
/// browse forever.
|
||||
/// </summary>
|
||||
public int MaxBrowseDepth { get; init; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>OPC UA message security mode.</summary>
|
||||
public enum OpcUaSecurityMode
|
||||
{
|
||||
None,
|
||||
Sign,
|
||||
SignAndEncrypt,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA security policies recognized by the driver. Maps to the standard
|
||||
/// <c>http://opcfoundation.org/UA/SecurityPolicy#</c> URI suffixes the SDK uses for
|
||||
/// endpoint matching.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="Basic128Rsa15"/> and <see cref="Basic256"/> are <b>deprecated</b> per OPC UA
|
||||
/// spec v1.04 — they remain in the enum only for brownfield interop with older servers.
|
||||
/// Prefer <see cref="Basic256Sha256"/>, <see cref="Aes128_Sha256_RsaOaep"/>, or
|
||||
/// <see cref="Aes256_Sha256_RsaPss"/> for new deployments.
|
||||
/// </remarks>
|
||||
public enum OpcUaSecurityPolicy
|
||||
{
|
||||
/// <summary>No security. Unsigned, unencrypted wire.</summary>
|
||||
None,
|
||||
/// <summary>Deprecated (OPC UA 1.04). Retained for legacy server interop.</summary>
|
||||
Basic128Rsa15,
|
||||
/// <summary>Deprecated (OPC UA 1.04). Retained for legacy server interop.</summary>
|
||||
Basic256,
|
||||
/// <summary>Recommended baseline for current deployments.</summary>
|
||||
Basic256Sha256,
|
||||
/// <summary>Current OPC UA policy; AES-128 + SHA-256 + RSA-OAEP.</summary>
|
||||
Aes128_Sha256_RsaOaep,
|
||||
/// <summary>Current OPC UA policy; AES-256 + SHA-256 + RSA-PSS.</summary>
|
||||
Aes256_Sha256_RsaPss,
|
||||
}
|
||||
|
||||
/// <summary>User authentication type sent to the remote server.</summary>
|
||||
public enum OpcUaAuthType
|
||||
{
|
||||
Anonymous,
|
||||
Username,
|
||||
Certificate,
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<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.OpcUaClient</RootNamespace>
|
||||
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.106"/>
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" Version="1.5.378.106"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
216
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7AddressParser.cs
Normal file
216
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7AddressParser.cs
Normal file
@@ -0,0 +1,216 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
|
||||
/// <summary>
|
||||
/// Siemens S7 memory area. The driver's tag-address parser maps every S7 tag string into
|
||||
/// exactly one of these + an offset. Values match the on-wire S7 area codes only
|
||||
/// incidentally — S7.Net uses its own <c>DataType</c> enum (<c>DataBlock</c>, <c>Memory</c>,
|
||||
/// <c>Input</c>, <c>Output</c>, <c>Timer</c>, <c>Counter</c>) so the adapter layer translates.
|
||||
/// </summary>
|
||||
public enum S7Area
|
||||
{
|
||||
DataBlock,
|
||||
Memory, // M (Merker / marker byte)
|
||||
Input, // I (process-image input)
|
||||
Output, // Q (process-image output)
|
||||
Timer,
|
||||
Counter,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Access width for a DB / M / I / Q address. Timers and counters are always 16-bit
|
||||
/// opaque (not user-addressable via size suffixes).
|
||||
/// </summary>
|
||||
public enum S7Size
|
||||
{
|
||||
Bit, // X
|
||||
Byte, // B
|
||||
Word, // W — 16-bit
|
||||
DWord, // D — 32-bit
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parsed form of an S7 tag-address string. Produced by <see cref="S7AddressParser.Parse"/>.
|
||||
/// </summary>
|
||||
/// <param name="Area">Memory area (DB, M, I, Q, T, C).</param>
|
||||
/// <param name="DbNumber">Data block number; only meaningful when <paramref name="Area"/> is <see cref="S7Area.DataBlock"/>.</param>
|
||||
/// <param name="Size">Access width. Always <see cref="S7Size.Word"/> for Timer and Counter.</param>
|
||||
/// <param name="ByteOffset">Byte offset into the area (for DB/M/I/Q) or the timer/counter number.</param>
|
||||
/// <param name="BitOffset">Bit position 0-7 when <paramref name="Size"/> is <see cref="S7Size.Bit"/>; 0 otherwise.</param>
|
||||
public readonly record struct S7ParsedAddress(
|
||||
S7Area Area,
|
||||
int DbNumber,
|
||||
S7Size Size,
|
||||
int ByteOffset,
|
||||
int BitOffset);
|
||||
|
||||
/// <summary>
|
||||
/// Parses Siemens S7 address strings into <see cref="S7ParsedAddress"/>. Accepts the
|
||||
/// Siemens TIA-Portal / STEP 7 Classic syntax documented in <c>docs/v2/driver-specs.md</c> §5:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>DB{n}.DB{X|B|W|D}{offset}[.bit]</c> — e.g. <c>DB1.DBX0.0</c>, <c>DB1.DBW0</c>, <c>DB1.DBD4</c></item>
|
||||
/// <item><c>M{B|W|D}{offset}</c> or <c>M{offset}.{bit}</c> — e.g. <c>MB0</c>, <c>MW0</c>, <c>MD4</c>, <c>M0.0</c></item>
|
||||
/// <item><c>I{B|W|D}{offset}</c> or <c>I{offset}.{bit}</c> — e.g. <c>IB0</c>, <c>IW0</c>, <c>ID0</c>, <c>I0.0</c></item>
|
||||
/// <item><c>Q{B|W|D}{offset}</c> or <c>Q{offset}.{bit}</c> — e.g. <c>QB0</c>, <c>QW0</c>, <c>QD0</c>, <c>Q0.0</c></item>
|
||||
/// <item><c>T{n}</c> — e.g. <c>T0</c>, <c>T15</c></item>
|
||||
/// <item><c>C{n}</c> — e.g. <c>C0</c>, <c>C10</c></item>
|
||||
/// </list>
|
||||
/// Grammar is case-insensitive. Leading/trailing whitespace tolerated. Bit specifiers
|
||||
/// must be 0-7; byte offsets must be non-negative; DB numbers must be >= 1.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Parse is deliberately strict — the parser rejects syntactic garbage up-front so a bad
|
||||
/// tag config fails at driver init time instead of surfacing as a misleading
|
||||
/// <c>BadInternalError</c> on every Read against that tag.
|
||||
/// </remarks>
|
||||
public static class S7AddressParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Parse an S7 address. Throws <see cref="FormatException"/> on any syntax error with
|
||||
/// the offending input echoed in the message so operators can correlate to the tag
|
||||
/// config that produced the fault.
|
||||
/// </summary>
|
||||
public static S7ParsedAddress Parse(string address)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(address))
|
||||
throw new FormatException("S7 address must not be empty");
|
||||
var s = address.Trim().ToUpperInvariant();
|
||||
|
||||
// --- DB{n}.DB{X|B|W|D}{offset}[.bit] ---
|
||||
if (s.StartsWith("DB") && TryParseDataBlock(s, out var dbResult))
|
||||
return dbResult;
|
||||
|
||||
if (s.Length < 2)
|
||||
throw new FormatException($"S7 address '{address}' is too short to parse");
|
||||
|
||||
var areaChar = s[0];
|
||||
var rest = s.Substring(1);
|
||||
|
||||
switch (areaChar)
|
||||
{
|
||||
case 'M': return ParseMIQ(S7Area.Memory, rest, address);
|
||||
case 'I': return ParseMIQ(S7Area.Input, rest, address);
|
||||
case 'Q': return ParseMIQ(S7Area.Output, rest, address);
|
||||
case 'T': return ParseTimerOrCounter(S7Area.Timer, rest, address);
|
||||
case 'C': return ParseTimerOrCounter(S7Area.Counter, rest, address);
|
||||
default:
|
||||
throw new FormatException($"S7 address '{address}' starts with unknown area '{areaChar}' (expected DB/M/I/Q/T/C)");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Try-parse variant for callers that can't afford an exception on bad input (e.g.
|
||||
/// config validation pages in the Admin UI). Returns <c>false</c> for any input that
|
||||
/// would throw from <see cref="Parse"/>.
|
||||
/// </summary>
|
||||
public static bool TryParse(string address, out S7ParsedAddress result)
|
||||
{
|
||||
try
|
||||
{
|
||||
result = Parse(address);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
result = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseDataBlock(string s, out S7ParsedAddress result)
|
||||
{
|
||||
result = default;
|
||||
// Split on first '.': left side must be DB{n}, right side DB{X|B|W|D}{offset}[.bit]
|
||||
var dot = s.IndexOf('.');
|
||||
if (dot < 0) return false;
|
||||
var head = s.Substring(0, dot); // DB{n}
|
||||
var tail = s.Substring(dot + 1); // DB{X|B|W|D}{offset}[.bit]
|
||||
|
||||
if (head.Length < 3) return false;
|
||||
if (!int.TryParse(head.AsSpan(2), out var dbNumber) || dbNumber < 1)
|
||||
throw new FormatException($"S7 DB number in '{s}' must be a positive integer");
|
||||
|
||||
if (!tail.StartsWith("DB") || tail.Length < 4)
|
||||
throw new FormatException($"S7 DB address tail '{tail}' must start with DB{{X|B|W|D}}");
|
||||
|
||||
var sizeChar = tail[2];
|
||||
var offsetStart = 3;
|
||||
var size = sizeChar switch
|
||||
{
|
||||
'X' => S7Size.Bit,
|
||||
'B' => S7Size.Byte,
|
||||
'W' => S7Size.Word,
|
||||
'D' => S7Size.DWord,
|
||||
_ => throw new FormatException($"S7 DB size '{sizeChar}' in '{s}' must be X/B/W/D"),
|
||||
};
|
||||
|
||||
var (byteOffset, bitOffset) = ParseOffsetAndOptionalBit(tail, offsetStart, size, s);
|
||||
result = new S7ParsedAddress(S7Area.DataBlock, dbNumber, size, byteOffset, bitOffset);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static S7ParsedAddress ParseMIQ(S7Area area, string rest, string original)
|
||||
{
|
||||
if (rest.Length == 0)
|
||||
throw new FormatException($"S7 address '{original}' has no offset");
|
||||
|
||||
var first = rest[0];
|
||||
S7Size size;
|
||||
int offsetStart;
|
||||
switch (first)
|
||||
{
|
||||
case 'B': size = S7Size.Byte; offsetStart = 1; break;
|
||||
case 'W': size = S7Size.Word; offsetStart = 1; break;
|
||||
case 'D': size = S7Size.DWord; offsetStart = 1; break;
|
||||
default:
|
||||
// No size prefix => bit-level address requires explicit .bit. Size stays Bit;
|
||||
// ParseOffsetAndOptionalBit will demand the dot.
|
||||
size = S7Size.Bit;
|
||||
offsetStart = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
var (byteOffset, bitOffset) = ParseOffsetAndOptionalBit(rest, offsetStart, size, original);
|
||||
return new S7ParsedAddress(area, DbNumber: 0, size, byteOffset, bitOffset);
|
||||
}
|
||||
|
||||
private static S7ParsedAddress ParseTimerOrCounter(S7Area area, string rest, string original)
|
||||
{
|
||||
if (rest.Length == 0)
|
||||
throw new FormatException($"S7 address '{original}' has no {area} number");
|
||||
if (!int.TryParse(rest, out var number) || number < 0)
|
||||
throw new FormatException($"S7 {area} number in '{original}' must be a non-negative integer");
|
||||
return new S7ParsedAddress(area, DbNumber: 0, S7Size.Word, number, BitOffset: 0);
|
||||
}
|
||||
|
||||
private static (int byteOffset, int bitOffset) ParseOffsetAndOptionalBit(
|
||||
string s, int start, S7Size size, string original)
|
||||
{
|
||||
var offsetEnd = start;
|
||||
while (offsetEnd < s.Length && s[offsetEnd] >= '0' && s[offsetEnd] <= '9')
|
||||
offsetEnd++;
|
||||
if (offsetEnd == start)
|
||||
throw new FormatException($"S7 address '{original}' has no byte-offset digits");
|
||||
|
||||
if (!int.TryParse(s.AsSpan(start, offsetEnd - start), out var byteOffset) || byteOffset < 0)
|
||||
throw new FormatException($"S7 byte offset in '{original}' must be non-negative");
|
||||
|
||||
// No bit-suffix: done unless size is Bit with no prefix, which requires one.
|
||||
if (offsetEnd == s.Length)
|
||||
{
|
||||
if (size == S7Size.Bit)
|
||||
throw new FormatException($"S7 address '{original}' needs a .{{bit}} suffix for bit access");
|
||||
return (byteOffset, 0);
|
||||
}
|
||||
|
||||
if (s[offsetEnd] != '.')
|
||||
throw new FormatException($"S7 address '{original}' has unexpected character after offset");
|
||||
|
||||
if (size != S7Size.Bit)
|
||||
throw new FormatException($"S7 address '{original}' has a bit suffix but the size is {size} — bit access needs X (DB) or no size prefix (M/I/Q)");
|
||||
|
||||
if (!int.TryParse(s.AsSpan(offsetEnd + 1), out var bitOffset) || bitOffset is < 0 or > 7)
|
||||
throw new FormatException($"S7 bit offset in '{original}' must be 0-7");
|
||||
|
||||
return (byteOffset, bitOffset);
|
||||
}
|
||||
}
|
||||
513
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
Normal file
513
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
Normal file
@@ -0,0 +1,513 @@
|
||||
using S7.Net;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
|
||||
/// <summary>
|
||||
/// Siemens S7 native driver — speaks S7comm over ISO-on-TCP (port 102) via the S7netplus
|
||||
/// library. First implementation of <see cref="IDriver"/> for an in-process .NET Standard
|
||||
/// PLC protocol that is NOT Modbus, validating that the v2 driver-capability interfaces
|
||||
/// generalize beyond Modbus + Galaxy.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// PR 62 ships the scaffold: <see cref="IDriver"/> only (Initialize / Reinitialize /
|
||||
/// Shutdown / GetHealth). <see cref="ITagDiscovery"/>, <see cref="IReadable"/>,
|
||||
/// <see cref="IWritable"/>, <see cref="ISubscribable"/>, <see cref="IHostConnectivityProbe"/>
|
||||
/// land in PRs 63-65 once the address parser (PR 63) is in place.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Single-connection policy</b>: S7netplus documented pattern is one
|
||||
/// <c>Plc</c> instance per PLC, serialized with a <see cref="SemaphoreSlim"/>.
|
||||
/// Parallelising reads against a single S7 CPU doesn't help — the CPU scans the
|
||||
/// communication mailbox at most once per cycle (2-10 ms) and queues concurrent
|
||||
/// requests wire-side anyway. Multiple client-side connections just waste the CPU's
|
||||
/// 8-64 connection-resource budget.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IDisposable, IAsyncDisposable
|
||||
{
|
||||
// ---- ISubscribable + IHostConnectivityProbe state ----
|
||||
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<long, SubscriptionState> _subscriptions = new();
|
||||
private long _nextSubscriptionId;
|
||||
private readonly object _probeLock = new();
|
||||
private HostState _hostState = HostState.Unknown;
|
||||
private DateTime _hostStateChangedUtc = DateTime.UtcNow;
|
||||
private CancellationTokenSource? _probeCts;
|
||||
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
|
||||
/// <summary>OPC UA StatusCode used when the tag name isn't in the driver's tag map.</summary>
|
||||
private const uint StatusBadNodeIdUnknown = 0x80340000u;
|
||||
/// <summary>OPC UA StatusCode used when the tag's data type isn't implemented yet.</summary>
|
||||
private const uint StatusBadNotSupported = 0x803D0000u;
|
||||
/// <summary>OPC UA StatusCode used when the tag is declared read-only.</summary>
|
||||
private const uint StatusBadNotWritable = 0x803B0000u;
|
||||
/// <summary>OPC UA StatusCode used when write fails validation (e.g. out-of-range value).</summary>
|
||||
private const uint StatusBadInternalError = 0x80020000u;
|
||||
/// <summary>OPC UA StatusCode used for socket / timeout / protocol-layer faults.</summary>
|
||||
private const uint StatusBadCommunicationError = 0x80050000u;
|
||||
/// <summary>OPC UA StatusCode used when S7 returns <c>ErrorCode.WrongCPU</c> / PUT/GET disabled.</summary>
|
||||
private const uint StatusBadDeviceFailure = 0x80550000u;
|
||||
|
||||
private readonly Dictionary<string, S7TagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, S7ParsedAddress> _parsedByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly S7DriverOptions _options = options;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
/// <summary>
|
||||
/// Per-connection gate. Internal so PRs 63-65 (read/write/subscribe) can serialize on
|
||||
/// the same semaphore without exposing it publicly. Single-connection-per-PLC is a
|
||||
/// hard requirement of S7netplus — see class remarks.
|
||||
/// </summary>
|
||||
internal SemaphoreSlim Gate => _gate;
|
||||
|
||||
/// <summary>
|
||||
/// Active S7.Net PLC connection. Null until <see cref="InitializeAsync"/> returns; null
|
||||
/// after <see cref="ShutdownAsync"/>. Read-only outside this class; PR 64's Read/Write
|
||||
/// will take the <see cref="_gate"/> before touching it.
|
||||
/// </summary>
|
||||
internal Plc? Plc { get; private set; }
|
||||
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
private bool _disposed;
|
||||
|
||||
public string DriverInstanceId => driverInstanceId;
|
||||
public string DriverType => "S7";
|
||||
|
||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
try
|
||||
{
|
||||
var plc = new Plc(_options.CpuType, _options.Host, _options.Rack, _options.Slot);
|
||||
// S7netplus writes timeouts into the underlying TcpClient via Plc.WriteTimeout /
|
||||
// Plc.ReadTimeout (milliseconds). Set before OpenAsync so the handshake itself
|
||||
// honours the bound.
|
||||
plc.WriteTimeout = (int)_options.Timeout.TotalMilliseconds;
|
||||
plc.ReadTimeout = (int)_options.Timeout.TotalMilliseconds;
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(_options.Timeout);
|
||||
await plc.OpenAsync(cts.Token).ConfigureAwait(false);
|
||||
|
||||
Plc = plc;
|
||||
|
||||
// Parse every tag's address once at init so config typos fail fast here instead
|
||||
// of surfacing as BadInternalError on every Read against the bad tag. The parser
|
||||
// also rejects bit-offset > 7, DB 0, unknown area letters, etc.
|
||||
_tagsByName.Clear();
|
||||
_parsedByName.Clear();
|
||||
foreach (var t in _options.Tags)
|
||||
{
|
||||
var parsed = S7AddressParser.Parse(t.Address); // throws FormatException
|
||||
_tagsByName[t.Name] = t;
|
||||
_parsedByName[t.Name] = parsed;
|
||||
}
|
||||
|
||||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
|
||||
// Kick off the probe loop once the connection is up. Initial HostState stays
|
||||
// Unknown until the first probe tick succeeds — avoids broadcasting a premature
|
||||
// Running transition before any PDU round-trip has happened.
|
||||
if (_options.Probe.Enabled)
|
||||
{
|
||||
_probeCts = new CancellationTokenSource();
|
||||
_ = Task.Run(() => ProbeLoopAsync(_probeCts.Token), _probeCts.Token);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Clean up a partially-constructed Plc so a retry from the caller doesn't leak
|
||||
// the TcpClient. S7netplus's Close() is best-effort and idempotent.
|
||||
try { Plc?.Close(); } catch { }
|
||||
Plc = null;
|
||||
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
|
||||
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try { _probeCts?.Cancel(); } catch { }
|
||||
_probeCts?.Dispose();
|
||||
_probeCts = null;
|
||||
|
||||
foreach (var state in _subscriptions.Values)
|
||||
{
|
||||
try { state.Cts.Cancel(); } catch { }
|
||||
state.Cts.Dispose();
|
||||
}
|
||||
_subscriptions.Clear();
|
||||
|
||||
try { Plc?.Close(); } catch { /* best-effort — tearing down anyway */ }
|
||||
Plc = null;
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public DriverHealth GetHealth() => _health;
|
||||
|
||||
/// <summary>
|
||||
/// Approximate memory footprint. The Plc instance + one 240-960 byte PDU buffer is
|
||||
/// under 4 KB; return 0 because the <see cref="IDriver"/> contract asks for a
|
||||
/// driver-attributable growth number and S7.Net doesn't expose one.
|
||||
/// </summary>
|
||||
public long GetMemoryFootprint() => 0;
|
||||
|
||||
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
// ---- IReadable ----
|
||||
|
||||
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
var plc = RequirePlc();
|
||||
var now = DateTime.UtcNow;
|
||||
var results = new DataValueSnapshot[fullReferences.Count];
|
||||
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
var name = fullReferences[i];
|
||||
if (!_tagsByName.TryGetValue(name, out var tag))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, StatusBadNodeIdUnknown, null, now);
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
var value = await ReadOneAsync(plc, tag, cancellationToken).ConfigureAwait(false);
|
||||
results[i] = new DataValueSnapshot(value, 0u, now, now);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, StatusBadNotSupported, null, now);
|
||||
}
|
||||
catch (global::S7.Net.PlcException pex)
|
||||
{
|
||||
// S7.Net's PlcException carries an ErrorCode; PUT/GET-disabled on
|
||||
// S7-1200/1500 surfaces here. Map to BadDeviceFailure so operators see a
|
||||
// device-config problem (toggle PUT/GET in TIA Portal) rather than a
|
||||
// transient fault — per driver-specs.md §5.
|
||||
results[i] = new DataValueSnapshot(null, StatusBadDeviceFailure, null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, pex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<object> ReadOneAsync(global::S7.Net.Plc plc, S7TagDefinition tag, CancellationToken ct)
|
||||
{
|
||||
var addr = _parsedByName[tag.Name];
|
||||
// S7.Net's string-based ReadAsync returns object where the boxed .NET type depends on
|
||||
// the size suffix: DBX=bool, DBB=byte, DBW=ushort, DBD=uint. Our S7DataType enum
|
||||
// specifies the SEMANTIC type (Int16 vs UInt16 vs Float32 etc.); the reinterpret below
|
||||
// converts the raw unsigned boxed value into the requested type without issuing an
|
||||
// extra PLC round-trip.
|
||||
var raw = await plc.ReadAsync(tag.Address, ct).ConfigureAwait(false)
|
||||
?? throw new System.IO.InvalidDataException($"S7.Net returned null for '{tag.Address}'");
|
||||
|
||||
return (tag.DataType, addr.Size, raw) switch
|
||||
{
|
||||
(S7DataType.Bool, S7Size.Bit, bool b) => b,
|
||||
(S7DataType.Byte, S7Size.Byte, byte by) => by,
|
||||
(S7DataType.UInt16, S7Size.Word, ushort u16) => u16,
|
||||
(S7DataType.Int16, S7Size.Word, ushort u16) => unchecked((short)u16),
|
||||
(S7DataType.UInt32, S7Size.DWord, uint u32) => u32,
|
||||
(S7DataType.Int32, S7Size.DWord, uint u32) => unchecked((int)u32),
|
||||
(S7DataType.Float32, S7Size.DWord, uint u32) => BitConverter.UInt32BitsToSingle(u32),
|
||||
|
||||
(S7DataType.Int64, _, _) => throw new NotSupportedException("S7 Int64 reads land in a follow-up PR"),
|
||||
(S7DataType.UInt64, _, _) => throw new NotSupportedException("S7 UInt64 reads land in a follow-up PR"),
|
||||
(S7DataType.Float64, _, _) => throw new NotSupportedException("S7 Float64 (LReal) reads land in a follow-up PR"),
|
||||
(S7DataType.String, _, _) => throw new NotSupportedException("S7 STRING reads land in a follow-up PR"),
|
||||
(S7DataType.DateTime, _, _) => throw new NotSupportedException("S7 DateTime reads land in a follow-up PR"),
|
||||
|
||||
_ => throw new System.IO.InvalidDataException(
|
||||
$"S7 Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " +
|
||||
$"parsed as Size={addr.Size}; S7.Net returned {raw.GetType().Name}"),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- IWritable ----
|
||||
|
||||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
||||
{
|
||||
var plc = RequirePlc();
|
||||
var results = new WriteResult[writes.Count];
|
||||
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < writes.Count; i++)
|
||||
{
|
||||
var w = writes[i];
|
||||
if (!_tagsByName.TryGetValue(w.FullReference, out var tag))
|
||||
{
|
||||
results[i] = new WriteResult(StatusBadNodeIdUnknown);
|
||||
continue;
|
||||
}
|
||||
if (!tag.Writable)
|
||||
{
|
||||
results[i] = new WriteResult(StatusBadNotWritable);
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
await WriteOneAsync(plc, tag, w.Value, cancellationToken).ConfigureAwait(false);
|
||||
results[i] = new WriteResult(0u);
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
results[i] = new WriteResult(StatusBadNotSupported);
|
||||
}
|
||||
catch (global::S7.Net.PlcException)
|
||||
{
|
||||
results[i] = new WriteResult(StatusBadDeviceFailure);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
results[i] = new WriteResult(StatusBadInternalError);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task WriteOneAsync(global::S7.Net.Plc plc, S7TagDefinition tag, object? value, CancellationToken ct)
|
||||
{
|
||||
// S7.Net's Plc.WriteAsync(string address, object value) expects the boxed value to
|
||||
// match the address's size-suffix type: DBX=bool, DBB=byte, DBW=ushort, DBD=uint.
|
||||
// Our S7DataType lets the caller pass short/int/float; convert to the unsigned
|
||||
// wire representation before handing off.
|
||||
var boxed = tag.DataType switch
|
||||
{
|
||||
S7DataType.Bool => (object)Convert.ToBoolean(value),
|
||||
S7DataType.Byte => (object)Convert.ToByte(value),
|
||||
S7DataType.UInt16 => (object)Convert.ToUInt16(value),
|
||||
S7DataType.Int16 => (object)unchecked((ushort)Convert.ToInt16(value)),
|
||||
S7DataType.UInt32 => (object)Convert.ToUInt32(value),
|
||||
S7DataType.Int32 => (object)unchecked((uint)Convert.ToInt32(value)),
|
||||
S7DataType.Float32 => (object)BitConverter.SingleToUInt32Bits(Convert.ToSingle(value)),
|
||||
|
||||
S7DataType.Int64 => throw new NotSupportedException("S7 Int64 writes land in a follow-up PR"),
|
||||
S7DataType.UInt64 => throw new NotSupportedException("S7 UInt64 writes land in a follow-up PR"),
|
||||
S7DataType.Float64 => throw new NotSupportedException("S7 Float64 (LReal) writes land in a follow-up PR"),
|
||||
S7DataType.String => throw new NotSupportedException("S7 STRING writes land in a follow-up PR"),
|
||||
S7DataType.DateTime => throw new NotSupportedException("S7 DateTime writes land in a follow-up PR"),
|
||||
_ => throw new InvalidOperationException($"Unknown S7DataType {tag.DataType}"),
|
||||
};
|
||||
await plc.WriteAsync(tag.Address, boxed, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private global::S7.Net.Plc RequirePlc() =>
|
||||
Plc ?? throw new InvalidOperationException("S7Driver not initialized");
|
||||
|
||||
// ---- ITagDiscovery ----
|
||||
|
||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
var folder = builder.Folder("S7", "S7");
|
||||
foreach (var t in _options.Tags)
|
||||
{
|
||||
folder.Variable(t.Name, t.Name, new DriverAttributeInfo(
|
||||
FullName: t.Name,
|
||||
DriverDataType: MapDataType(t.DataType),
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: t.Writable ? SecurityClassification.Operate : SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false));
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static DriverDataType MapDataType(S7DataType t) => t switch
|
||||
{
|
||||
S7DataType.Bool => DriverDataType.Boolean,
|
||||
S7DataType.Byte => DriverDataType.Int32, // no 8-bit in DriverDataType yet
|
||||
S7DataType.Int16 or S7DataType.UInt16 or S7DataType.Int32 or S7DataType.UInt32 => DriverDataType.Int32,
|
||||
S7DataType.Int64 or S7DataType.UInt64 => DriverDataType.Int32, // widens; lossy for >2^31-1
|
||||
S7DataType.Float32 => DriverDataType.Float32,
|
||||
S7DataType.Float64 => DriverDataType.Float64,
|
||||
S7DataType.String => DriverDataType.String,
|
||||
S7DataType.DateTime => DriverDataType.DateTime,
|
||||
_ => DriverDataType.Int32,
|
||||
};
|
||||
|
||||
// ---- ISubscribable (polling overlay) ----
|
||||
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
|
||||
{
|
||||
var id = Interlocked.Increment(ref _nextSubscriptionId);
|
||||
var cts = new CancellationTokenSource();
|
||||
// Floor at 100 ms — S7 CPUs scan 2-10 ms but the comms mailbox is processed at most
|
||||
// once per scan; sub-100 ms polling just queues wire-side with worse latency.
|
||||
var interval = publishingInterval < TimeSpan.FromMilliseconds(100)
|
||||
? TimeSpan.FromMilliseconds(100)
|
||||
: publishingInterval;
|
||||
var handle = new S7SubscriptionHandle(id);
|
||||
var state = new SubscriptionState(handle, [.. fullReferences], interval, cts);
|
||||
_subscriptions[id] = state;
|
||||
_ = Task.Run(() => PollLoopAsync(state, cts.Token), cts.Token);
|
||||
return Task.FromResult<ISubscriptionHandle>(handle);
|
||||
}
|
||||
|
||||
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
if (handle is S7SubscriptionHandle h && _subscriptions.TryRemove(h.Id, out var state))
|
||||
{
|
||||
state.Cts.Cancel();
|
||||
state.Cts.Dispose();
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task PollLoopAsync(SubscriptionState state, CancellationToken ct)
|
||||
{
|
||||
// Initial-data push per OPC UA Part 4 convention.
|
||||
try { await PollOnceAsync(state, forceRaise: true, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
catch { /* first-read error — polling continues */ }
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try { await Task.Delay(state.Interval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
|
||||
try { await PollOnceAsync(state, forceRaise: false, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
catch { /* transient polling error — loop continues, health surface reflects it */ }
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PollOnceAsync(SubscriptionState state, bool forceRaise, CancellationToken ct)
|
||||
{
|
||||
var snapshots = await ReadAsync(state.TagReferences, ct).ConfigureAwait(false);
|
||||
for (var i = 0; i < state.TagReferences.Count; i++)
|
||||
{
|
||||
var tagRef = state.TagReferences[i];
|
||||
var current = snapshots[i];
|
||||
var lastSeen = state.LastValues.TryGetValue(tagRef, out var prev) ? prev : default;
|
||||
|
||||
if (forceRaise || !Equals(lastSeen?.Value, current.Value) || lastSeen?.StatusCode != current.StatusCode)
|
||||
{
|
||||
state.LastValues[tagRef] = current;
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(state.Handle, tagRef, current));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record SubscriptionState(
|
||||
S7SubscriptionHandle Handle,
|
||||
IReadOnlyList<string> TagReferences,
|
||||
TimeSpan Interval,
|
||||
CancellationTokenSource Cts)
|
||||
{
|
||||
public System.Collections.Concurrent.ConcurrentDictionary<string, DataValueSnapshot> LastValues { get; }
|
||||
= new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private sealed record S7SubscriptionHandle(long Id) : ISubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId => $"s7-sub-{Id}";
|
||||
}
|
||||
|
||||
// ---- IHostConnectivityProbe ----
|
||||
|
||||
/// <summary>
|
||||
/// Host identifier surfaced in <see cref="GetHostStatuses"/>. <c>host:port</c> format
|
||||
/// matches the Modbus driver's convention so the Admin UI dashboard renders both
|
||||
/// family's rows uniformly.
|
||||
/// </summary>
|
||||
public string HostName => $"{_options.Host}:{_options.Port}";
|
||||
|
||||
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses()
|
||||
{
|
||||
lock (_probeLock)
|
||||
return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)];
|
||||
}
|
||||
|
||||
private async Task ProbeLoopAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var success = false;
|
||||
try
|
||||
{
|
||||
// Probe via S7.Net's low-cost GetCpuStatus — returns the CPU state (Run/Stop)
|
||||
// and is intentionally light on the comms mailbox. Single-word Plc.ReadAsync
|
||||
// would also work but GetCpuStatus doubles as a "PLC actually up" check.
|
||||
using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
probeCts.CancelAfter(_options.Probe.Timeout);
|
||||
|
||||
var plc = Plc;
|
||||
if (plc is null) throw new InvalidOperationException("Plc dropped during probe");
|
||||
|
||||
await _gate.WaitAsync(probeCts.Token).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
_ = await plc.ReadStatusAsync(probeCts.Token).ConfigureAwait(false);
|
||||
success = true;
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
|
||||
catch { /* transport/timeout/exception — treated as Stopped below */ }
|
||||
|
||||
TransitionTo(success ? HostState.Running : HostState.Stopped);
|
||||
|
||||
try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
}
|
||||
}
|
||||
|
||||
private void TransitionTo(HostState newState)
|
||||
{
|
||||
HostState old;
|
||||
lock (_probeLock)
|
||||
{
|
||||
old = _hostState;
|
||||
if (old == newState) return;
|
||||
_hostState = newState;
|
||||
_hostStateChangedUtc = DateTime.UtcNow;
|
||||
}
|
||||
OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, old, newState));
|
||||
}
|
||||
|
||||
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
try { await ShutdownAsync(CancellationToken.None).ConfigureAwait(false); }
|
||||
catch { /* disposal is best-effort */ }
|
||||
_gate.Dispose();
|
||||
}
|
||||
}
|
||||
112
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs
Normal file
112
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs
Normal file
@@ -0,0 +1,112 @@
|
||||
using S7NetCpuType = global::S7.Net.CpuType;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
|
||||
/// <summary>
|
||||
/// Siemens S7 native (S7comm / ISO-on-TCP port 102) driver configuration. Bound from the
|
||||
/// driver's <c>DriverConfig</c> JSON at <c>DriverHost.RegisterAsync</c>. Unlike the Modbus
|
||||
/// driver the S7 driver uses the PLC's *native* protocol — port 102 ISO-on-TCP rather
|
||||
/// than Modbus's 502, and S7-specific area codes (DB, M, I, Q) rather than holding-
|
||||
/// register / coil tables.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The driver requires <b>PUT/GET communication enabled</b> in the TIA Portal
|
||||
/// hardware config for S7-1200/1500. The factory default disables PUT/GET access,
|
||||
/// so a driver configured against a freshly-flashed CPU will see a hard error
|
||||
/// (S7.Net surfaces it as <c>Plc.ReadAsync</c> returning <c>ErrorCode.Accessing</c>).
|
||||
/// The driver maps that specifically to <c>BadNotSupported</c> and flags it as a
|
||||
/// configuration alert rather than a transient fault — blind Polly retry is wasted
|
||||
/// effort when the PLC will keep refusing every request.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// See <c>docs/v2/driver-specs.md</c> §5 for the full specification.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class S7DriverOptions
|
||||
{
|
||||
/// <summary>PLC IP address or hostname.</summary>
|
||||
public string Host { get; init; } = "127.0.0.1";
|
||||
|
||||
/// <summary>TCP port. ISO-on-TCP is 102 on every S7 model; override only for unusual NAT setups.</summary>
|
||||
public int Port { get; init; } = 102;
|
||||
|
||||
/// <summary>
|
||||
/// CPU family. Determines the ISO-TSAP slot byte that S7.Net uses during connection
|
||||
/// setup — pick the family that matches the target PLC exactly.
|
||||
/// </summary>
|
||||
public S7NetCpuType CpuType { get; init; } = S7NetCpuType.S71500;
|
||||
|
||||
/// <summary>
|
||||
/// Hardware rack number. Almost always 0; relevant only for distributed S7-400 racks
|
||||
/// with multiple CPUs.
|
||||
/// </summary>
|
||||
public short Rack { get; init; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// CPU slot. Conventions per family: S7-300 = slot 2, S7-400 = slot 2 or 3,
|
||||
/// S7-1200 / S7-1500 = slot 0 (onboard PN). S7.Net uses this to build the remote
|
||||
/// TSAP. Wrong slot → connection refused during handshake.
|
||||
/// </summary>
|
||||
public short Slot { get; init; } = 0;
|
||||
|
||||
/// <summary>Connect + per-operation timeout.</summary>
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>Pre-declared tag map. S7 has a symbol-table protocol but S7.Net does not expose it, so the driver operates off a static tag list configured per-site. Address grammar documented in S7AddressParser (PR 63).</summary>
|
||||
public IReadOnlyList<S7TagDefinition> Tags { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Background connectivity-probe settings. When enabled, the driver runs a tick loop
|
||||
/// that issues a cheap read against <see cref="S7ProbeOptions.ProbeAddress"/> every
|
||||
/// <see cref="S7ProbeOptions.Interval"/> and raises <c>OnHostStatusChanged</c> on
|
||||
/// Running ↔ Stopped transitions.
|
||||
/// </summary>
|
||||
public S7ProbeOptions Probe { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed class S7ProbeOptions
|
||||
{
|
||||
public bool Enabled { get; init; } = true;
|
||||
public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(5);
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>
|
||||
/// Address to probe for liveness. DB1.DBW0 is the convention if the PLC project
|
||||
/// reserves a small fingerprint DB for health checks (per <c>docs/v2/s7.md</c>);
|
||||
/// if not, pick any valid Merker word like <c>MW0</c>.
|
||||
/// </summary>
|
||||
public string ProbeAddress { get; init; } = "MW0";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One S7 variable as exposed by the driver. Addresses use S7.Net syntax — see
|
||||
/// <c>S7AddressParser</c> (PR 63) for the grammar.
|
||||
/// </summary>
|
||||
/// <param name="Name">Tag name; OPC UA browse name + driver full reference.</param>
|
||||
/// <param name="Address">S7 address string, e.g. <c>DB1.DBW0</c>, <c>M0.0</c>, <c>I0.0</c>, <c>QD4</c>. Grammar documented in <c>S7AddressParser</c> (PR 63).</param>
|
||||
/// <param name="DataType">Logical data type — drives the underlying S7.Net read/write width.</param>
|
||||
/// <param name="Writable">When true the driver accepts writes for this tag.</param>
|
||||
/// <param name="StringLength">For <c>DataType = String</c>: S7-string max length. Default 254 (S7 max).</param>
|
||||
public sealed record S7TagDefinition(
|
||||
string Name,
|
||||
string Address,
|
||||
S7DataType DataType,
|
||||
bool Writable = true,
|
||||
int StringLength = 254);
|
||||
|
||||
public enum S7DataType
|
||||
{
|
||||
Bool,
|
||||
Byte,
|
||||
Int16,
|
||||
UInt16,
|
||||
Int32,
|
||||
UInt32,
|
||||
Int64,
|
||||
UInt64,
|
||||
Float32,
|
||||
Float64,
|
||||
String,
|
||||
DateTime,
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<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.S7</RootNamespace>
|
||||
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.S7</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="S7netplus" Version="0.20.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.S7.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -5,6 +5,11 @@ using Opc.Ua.Server;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
using DriverWriteRequest = ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest;
|
||||
// Core.Abstractions defines a type-named HistoryReadResult (driver-side samples + continuation
|
||||
// point) that collides with Opc.Ua.HistoryReadResult (service-layer per-node result). We
|
||||
// assign driver-side results to an explicitly-aliased local and construct only the service
|
||||
// type in the overrides below.
|
||||
using OpcHistoryReadResult = Opc.Ua.HistoryReadResult;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
|
||||
@@ -71,7 +76,13 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
NodeId = new NodeId(_driver.DriverInstanceId, NamespaceIndex),
|
||||
BrowseName = new QualifiedName(_driver.DriverInstanceId, NamespaceIndex),
|
||||
DisplayName = new LocalizedText(_driver.DriverInstanceId),
|
||||
EventNotifier = EventNotifiers.None,
|
||||
// Driver root is the conventional event notifier for HistoryReadEvents — clients
|
||||
// request alarm history by targeting it and the node manager routes through
|
||||
// IHistoryProvider.ReadEventsAsync. SubscribeToEvents is also set so live-event
|
||||
// subscriptions (Alarm & Conditions) can point here in a future PR; today the
|
||||
// alarm events are emitted by per-variable AlarmConditionState siblings but a
|
||||
// "subscribe to all events from this driver" path would use this notifier.
|
||||
EventNotifier = (byte)(EventNotifiers.SubscribeToEvents | EventNotifiers.HistoryRead),
|
||||
};
|
||||
|
||||
// Link under Objects folder so clients see the driver subtree at browse root.
|
||||
@@ -122,8 +133,15 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
DisplayName = new LocalizedText(displayName),
|
||||
DataType = MapDataType(attributeInfo.DriverDataType),
|
||||
ValueRank = attributeInfo.IsArray ? ValueRanks.OneDimension : ValueRanks.Scalar,
|
||||
AccessLevel = AccessLevels.CurrentReadOrWrite,
|
||||
UserAccessLevel = AccessLevels.CurrentReadOrWrite,
|
||||
// Historized attributes get the HistoryRead access bit so the stack dispatches
|
||||
// incoming HistoryRead service calls to this node. Without it the base class
|
||||
// returns BadHistoryOperationUnsupported before our per-kind hook ever runs.
|
||||
// HistoryWrite isn't granted — history rewrite is a separate capability the
|
||||
// driver doesn't support today.
|
||||
AccessLevel = (byte)(AccessLevels.CurrentReadOrWrite
|
||||
| (attributeInfo.IsHistorized ? AccessLevels.HistoryRead : 0)),
|
||||
UserAccessLevel = (byte)(AccessLevels.CurrentReadOrWrite
|
||||
| (attributeInfo.IsHistorized ? AccessLevels.HistoryRead : 0)),
|
||||
Historizing = attributeInfo.IsHistorized,
|
||||
};
|
||||
_currentFolder.AddChild(v);
|
||||
@@ -384,4 +402,379 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
internal int VariableCount => _variablesByFullRef.Count;
|
||||
internal bool TryGetVariable(string fullRef, out BaseDataVariableState? v)
|
||||
=> _variablesByFullRef.TryGetValue(fullRef, out v!);
|
||||
|
||||
// ===================== HistoryRead service handlers (LMX #1, PR 38) =====================
|
||||
//
|
||||
// Wires the driver's IHistoryProvider capability (PR 35 added ReadAtTimeAsync / ReadEventsAsync
|
||||
// alongside the PR 19 ReadRawAsync / ReadProcessedAsync) to the OPC UA HistoryRead service.
|
||||
// CustomNodeManager2 has four protected per-kind hooks; the base dispatches to the right one
|
||||
// based on the concrete HistoryReadDetails subtype. Each hook is sync-returning-void — the
|
||||
// per-driver async calls are bridged via GetAwaiter().GetResult(), matching the pattern
|
||||
// OnReadValue / OnWriteValue already use in this class so HistoryRead doesn't introduce a
|
||||
// different sync-over-async convention.
|
||||
//
|
||||
// Per-node routing: every HistoryReadValueId in nodesToRead has a NodeHandle in
|
||||
// nodesToProcess; the NodeHandle's NodeId.Identifier is the driver-side full reference
|
||||
// (set during Variable() registration) so we can dispatch straight to IHistoryProvider
|
||||
// without a second lookup. Nodes without IHistoryProvider backing (drivers that don't
|
||||
// implement the capability) surface BadHistoryOperationUnsupported per slot and the
|
||||
// rest of the batch continues — same failure-isolation pattern as OnWriteValue.
|
||||
//
|
||||
// Continuation-point handling is pass-through only in this PR: the driver returns null
|
||||
// from its ContinuationPoint field today so the outer result's ContinuationPoint stays
|
||||
// empty. Full Session.SaveHistoryContinuationPoint plumbing is a follow-up when a driver
|
||||
// actually needs paging — the dispatch shape doesn't change, only the result-population.
|
||||
|
||||
private IHistoryProvider? History => _driver as IHistoryProvider;
|
||||
|
||||
protected override void HistoryReadRawModified(
|
||||
ServerSystemContext context, ReadRawModifiedDetails details, TimestampsToReturn timestamps,
|
||||
IList<HistoryReadValueId> nodesToRead, IList<OpcHistoryReadResult> results,
|
||||
IList<ServiceResult> errors, List<NodeHandle> nodesToProcess,
|
||||
IDictionary<NodeId, NodeState> cache)
|
||||
{
|
||||
if (History is null)
|
||||
{
|
||||
MarkAllUnsupported(nodesToProcess, results, errors);
|
||||
return;
|
||||
}
|
||||
|
||||
// IsReadModified=true requests a "modifications" history (who changed the data, when
|
||||
// it was re-written). The driver side has no modifications store — surface that
|
||||
// explicitly rather than silently returning raw data, which would mislead the client.
|
||||
if (details.IsReadModified)
|
||||
{
|
||||
MarkAllUnsupported(nodesToProcess, results, errors, StatusCodes.BadHistoryOperationUnsupported);
|
||||
return;
|
||||
}
|
||||
|
||||
for (var n = 0; n < nodesToProcess.Count; n++)
|
||||
{
|
||||
var handle = nodesToProcess[n];
|
||||
// NodeHandle.Index points back to the slot in the outer results/errors/nodesToRead
|
||||
// arrays. nodesToProcess is the filtered subset (just the nodes this manager
|
||||
// claimed), so writing to results[n] lands in the wrong slot when N > 1 and nodes
|
||||
// are interleaved across multiple node managers.
|
||||
var i = handle.Index;
|
||||
var fullRef = ResolveFullRef(handle);
|
||||
if (fullRef is null)
|
||||
{
|
||||
WriteNodeIdUnknown(results, errors, i);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var driverResult = History.ReadRawAsync(
|
||||
fullRef,
|
||||
details.StartTime,
|
||||
details.EndTime,
|
||||
details.NumValuesPerNode,
|
||||
CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
WriteResult(results, errors, i, StatusCodes.Good,
|
||||
BuildHistoryData(driverResult.Samples), driverResult.ContinuationPoint);
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
WriteUnsupported(results, errors, i);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "HistoryReadRaw failed for {FullRef}", fullRef);
|
||||
WriteInternalError(results, errors, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override void HistoryReadProcessed(
|
||||
ServerSystemContext context, ReadProcessedDetails details, TimestampsToReturn timestamps,
|
||||
IList<HistoryReadValueId> nodesToRead, IList<OpcHistoryReadResult> results,
|
||||
IList<ServiceResult> errors, List<NodeHandle> nodesToProcess,
|
||||
IDictionary<NodeId, NodeState> cache)
|
||||
{
|
||||
if (History is null)
|
||||
{
|
||||
MarkAllUnsupported(nodesToProcess, results, errors);
|
||||
return;
|
||||
}
|
||||
|
||||
// AggregateType is one NodeId shared across every item in the batch — map once.
|
||||
var aggregate = MapAggregate(details.AggregateType?.FirstOrDefault());
|
||||
if (aggregate is null)
|
||||
{
|
||||
MarkAllUnsupported(nodesToProcess, results, errors, StatusCodes.BadAggregateNotSupported);
|
||||
return;
|
||||
}
|
||||
|
||||
var interval = TimeSpan.FromMilliseconds(details.ProcessingInterval);
|
||||
for (var n = 0; n < nodesToProcess.Count; n++)
|
||||
{
|
||||
var handle = nodesToProcess[n];
|
||||
// NodeHandle.Index points back to the slot in the outer results/errors/nodesToRead
|
||||
// arrays. nodesToProcess is the filtered subset (just the nodes this manager
|
||||
// claimed), so writing to results[n] lands in the wrong slot when N > 1 and nodes
|
||||
// are interleaved across multiple node managers.
|
||||
var i = handle.Index;
|
||||
var fullRef = ResolveFullRef(handle);
|
||||
if (fullRef is null)
|
||||
{
|
||||
WriteNodeIdUnknown(results, errors, i);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var driverResult = History.ReadProcessedAsync(
|
||||
fullRef,
|
||||
details.StartTime,
|
||||
details.EndTime,
|
||||
interval,
|
||||
aggregate.Value,
|
||||
CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
WriteResult(results, errors, i, StatusCodes.Good,
|
||||
BuildHistoryData(driverResult.Samples), driverResult.ContinuationPoint);
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
WriteUnsupported(results, errors, i);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "HistoryReadProcessed failed for {FullRef}", fullRef);
|
||||
WriteInternalError(results, errors, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override void HistoryReadAtTime(
|
||||
ServerSystemContext context, ReadAtTimeDetails details, TimestampsToReturn timestamps,
|
||||
IList<HistoryReadValueId> nodesToRead, IList<OpcHistoryReadResult> results,
|
||||
IList<ServiceResult> errors, List<NodeHandle> nodesToProcess,
|
||||
IDictionary<NodeId, NodeState> cache)
|
||||
{
|
||||
if (History is null)
|
||||
{
|
||||
MarkAllUnsupported(nodesToProcess, results, errors);
|
||||
return;
|
||||
}
|
||||
|
||||
var requestedTimes = (IReadOnlyList<DateTime>)(details.ReqTimes?.ToArray() ?? Array.Empty<DateTime>());
|
||||
for (var n = 0; n < nodesToProcess.Count; n++)
|
||||
{
|
||||
var handle = nodesToProcess[n];
|
||||
// NodeHandle.Index points back to the slot in the outer results/errors/nodesToRead
|
||||
// arrays. nodesToProcess is the filtered subset (just the nodes this manager
|
||||
// claimed), so writing to results[n] lands in the wrong slot when N > 1 and nodes
|
||||
// are interleaved across multiple node managers.
|
||||
var i = handle.Index;
|
||||
var fullRef = ResolveFullRef(handle);
|
||||
if (fullRef is null)
|
||||
{
|
||||
WriteNodeIdUnknown(results, errors, i);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var driverResult = History.ReadAtTimeAsync(
|
||||
fullRef, requestedTimes, CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
WriteResult(results, errors, i, StatusCodes.Good,
|
||||
BuildHistoryData(driverResult.Samples), driverResult.ContinuationPoint);
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
WriteUnsupported(results, errors, i);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "HistoryReadAtTime failed for {FullRef}", fullRef);
|
||||
WriteInternalError(results, errors, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override void HistoryReadEvents(
|
||||
ServerSystemContext context, ReadEventDetails details, TimestampsToReturn timestamps,
|
||||
IList<HistoryReadValueId> nodesToRead, IList<OpcHistoryReadResult> results,
|
||||
IList<ServiceResult> errors, List<NodeHandle> nodesToProcess,
|
||||
IDictionary<NodeId, NodeState> cache)
|
||||
{
|
||||
if (History is null)
|
||||
{
|
||||
MarkAllUnsupported(nodesToProcess, results, errors);
|
||||
return;
|
||||
}
|
||||
|
||||
// SourceName filter extraction is deferred — EventFilter SelectClauses + WhereClause
|
||||
// handling is a dedicated concern (proper per-select-clause Variant population + where
|
||||
// filter evaluation). This PR treats the event query as "all events in range for the
|
||||
// node's source" and populates only the standard BaseEventType fields. Richer filter
|
||||
// handling is a follow-up; clients issuing empty/default filters get the right answer
|
||||
// today which covers the common alarm-history browse case.
|
||||
var maxEvents = (int)details.NumValuesPerNode;
|
||||
if (maxEvents <= 0) maxEvents = 1000;
|
||||
|
||||
for (var n = 0; n < nodesToProcess.Count; n++)
|
||||
{
|
||||
var handle = nodesToProcess[n];
|
||||
// NodeHandle.Index points back to the slot in the outer results/errors/nodesToRead
|
||||
// arrays. nodesToProcess is the filtered subset (just the nodes this manager
|
||||
// claimed), so writing to results[n] lands in the wrong slot when N > 1 and nodes
|
||||
// are interleaved across multiple node managers.
|
||||
var i = handle.Index;
|
||||
// Event history queries may target a notifier object (e.g. the driver-root folder)
|
||||
// rather than a specific variable — in that case we pass sourceName=null to mean
|
||||
// "all sources in the driver's namespace" per the IHistoryProvider contract.
|
||||
var fullRef = ResolveFullRef(handle);
|
||||
|
||||
try
|
||||
{
|
||||
var driverResult = History.ReadEventsAsync(
|
||||
sourceName: fullRef,
|
||||
startUtc: details.StartTime,
|
||||
endUtc: details.EndTime,
|
||||
maxEvents: maxEvents,
|
||||
cancellationToken: CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
WriteResult(results, errors, i, StatusCodes.Good,
|
||||
BuildHistoryEvent(driverResult.Events), driverResult.ContinuationPoint);
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
WriteUnsupported(results, errors, i);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "HistoryReadEvents failed for {FullRef}", fullRef);
|
||||
WriteInternalError(results, errors, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string? ResolveFullRef(NodeHandle handle) => handle.NodeId?.Identifier as string;
|
||||
|
||||
// Both the results list AND the parallel errors list must be populated — MasterNodeManager
|
||||
// merges them and the merged StatusCode is what the client sees. Leaving errors[i] at its
|
||||
// default (BadHistoryOperationUnsupported) overrides a Good result with Unsupported, which
|
||||
// masks a correctly-constructed HistoryData response. This was the subtle failure mode
|
||||
// that cost most of PR 38's debugging budget.
|
||||
private static void WriteResult(IList<OpcHistoryReadResult> results, IList<ServiceResult> errors,
|
||||
int i, uint statusCode, ExtensionObject historyData, byte[]? continuationPoint)
|
||||
{
|
||||
results[i] = new OpcHistoryReadResult
|
||||
{
|
||||
StatusCode = statusCode,
|
||||
HistoryData = historyData,
|
||||
ContinuationPoint = continuationPoint,
|
||||
};
|
||||
errors[i] = statusCode == StatusCodes.Good
|
||||
? ServiceResult.Good
|
||||
: new ServiceResult(statusCode);
|
||||
}
|
||||
|
||||
private static void WriteUnsupported(IList<OpcHistoryReadResult> results, IList<ServiceResult> errors, int i)
|
||||
{
|
||||
results[i] = new OpcHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
|
||||
errors[i] = StatusCodes.BadHistoryOperationUnsupported;
|
||||
}
|
||||
|
||||
private static void WriteInternalError(IList<OpcHistoryReadResult> results, IList<ServiceResult> errors, int i)
|
||||
{
|
||||
results[i] = new OpcHistoryReadResult { StatusCode = StatusCodes.BadInternalError };
|
||||
errors[i] = StatusCodes.BadInternalError;
|
||||
}
|
||||
|
||||
private static void WriteNodeIdUnknown(IList<OpcHistoryReadResult> results, IList<ServiceResult> errors, int i)
|
||||
{
|
||||
WriteNodeIdUnknown(results, errors, i);
|
||||
errors[i] = StatusCodes.BadNodeIdUnknown;
|
||||
}
|
||||
|
||||
private static void MarkAllUnsupported(
|
||||
List<NodeHandle> nodes, IList<OpcHistoryReadResult> results, IList<ServiceResult> errors,
|
||||
uint statusCode = StatusCodes.BadHistoryOperationUnsupported)
|
||||
{
|
||||
foreach (var handle in nodes)
|
||||
{
|
||||
results[handle.Index] = new OpcHistoryReadResult { StatusCode = statusCode };
|
||||
errors[handle.Index] = statusCode == StatusCodes.Good ? ServiceResult.Good : new ServiceResult(statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map the OPC UA Part 13 aggregate-function NodeId to the driver's
|
||||
/// <see cref="HistoryAggregateType"/>. Internal so the test suite can pin the mapping
|
||||
/// without exposing public API. Returns null for unsupported aggregates so the service
|
||||
/// handler can surface <c>BadAggregateNotSupported</c> on the whole batch.
|
||||
/// </summary>
|
||||
internal static HistoryAggregateType? MapAggregate(NodeId? aggregateNodeId)
|
||||
{
|
||||
if (aggregateNodeId is null) return null;
|
||||
|
||||
// Every AggregateFunction_* identifier is a numeric uint on the Server (0) namespace.
|
||||
// Comparing NodeIds by value handles all the cross-encoding cases (expanded vs plain).
|
||||
if (aggregateNodeId == ObjectIds.AggregateFunction_Average) return HistoryAggregateType.Average;
|
||||
if (aggregateNodeId == ObjectIds.AggregateFunction_Minimum) return HistoryAggregateType.Minimum;
|
||||
if (aggregateNodeId == ObjectIds.AggregateFunction_Maximum) return HistoryAggregateType.Maximum;
|
||||
if (aggregateNodeId == ObjectIds.AggregateFunction_Total) return HistoryAggregateType.Total;
|
||||
if (aggregateNodeId == ObjectIds.AggregateFunction_Count) return HistoryAggregateType.Count;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wrap driver samples as <c>HistoryData</c> in an <c>ExtensionObject</c> — the on-wire
|
||||
/// shape the OPC UA HistoryRead service expects for raw / processed / at-time reads.
|
||||
/// </summary>
|
||||
internal static ExtensionObject BuildHistoryData(IReadOnlyList<DataValueSnapshot> samples)
|
||||
{
|
||||
var values = new DataValueCollection(samples.Count);
|
||||
foreach (var s in samples) values.Add(ToDataValue(s));
|
||||
return new ExtensionObject(new HistoryData { DataValues = values });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wrap driver events as <c>HistoryEvent</c> in an <c>ExtensionObject</c>. Populates
|
||||
/// the minimum BaseEventType field set (SourceName, Message, Severity, Time,
|
||||
/// ReceiveTime, EventId) so clients that request the default
|
||||
/// <c>SimpleAttributeOperand</c> select-clauses see useful data. Custom EventFilter
|
||||
/// SelectClause evaluation is deferred — when a client sends a specific operand list,
|
||||
/// they currently get the standard fields back and ignore the extras. Documented on the
|
||||
/// public follow-up list.
|
||||
/// </summary>
|
||||
internal static ExtensionObject BuildHistoryEvent(IReadOnlyList<HistoricalEvent> events)
|
||||
{
|
||||
var fieldLists = new HistoryEventFieldListCollection(events.Count);
|
||||
foreach (var e in events)
|
||||
{
|
||||
var fields = new VariantCollection
|
||||
{
|
||||
// Order must match BaseEventType's conventional field ordering so clients that
|
||||
// didn't customize the SelectClauses still see recognizable columns. A future
|
||||
// PR that respects the client's SelectClause list will drive this from the filter.
|
||||
new Variant(e.EventId),
|
||||
new Variant(e.SourceName ?? string.Empty),
|
||||
new Variant(new LocalizedText(e.Message ?? string.Empty)),
|
||||
new Variant(e.Severity),
|
||||
new Variant(e.EventTimeUtc),
|
||||
new Variant(e.ReceivedTimeUtc),
|
||||
};
|
||||
fieldLists.Add(new HistoryEventFieldList { EventFields = fields });
|
||||
}
|
||||
return new ExtensionObject(new HistoryEvent { Events = fieldLists });
|
||||
}
|
||||
|
||||
internal static DataValue ToDataValue(DataValueSnapshot s)
|
||||
{
|
||||
var dv = new DataValue
|
||||
{
|
||||
Value = s.Value,
|
||||
StatusCode = new StatusCode(s.StatusCode),
|
||||
ServerTimestamp = s.ServerTimestampUtc,
|
||||
};
|
||||
if (s.SourceTimestampUtc.HasValue) dv.SourceTimestamp = s.SourceTimestampUtc.Value;
|
||||
return dv;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Exercises <see cref="AvevaPrerequisites"/> against the live dev box so the helper
|
||||
/// itself gets integration coverage — i.e. "do the probes return Pass for things that
|
||||
/// really are Pass?" as validated against this machine's known-installed topology.
|
||||
/// Category <c>LiveGalaxy</c> so CI / clean dev boxes skip cleanly.
|
||||
/// </summary>
|
||||
[Trait("Category", "LiveGalaxy")]
|
||||
public sealed class AvevaPrerequisitesLiveTests
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public AvevaPrerequisitesLiveTests(ITestOutputHelper output) => _output = output;
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAll_on_live_box_reports_Framework_install()
|
||||
{
|
||||
var report = await AvevaPrerequisites.CheckAllAsync();
|
||||
_output.WriteLine(report.ToString());
|
||||
report.Checks.ShouldContain(c =>
|
||||
c.Name == "registry:ArchestrA.Framework" && c.Status == PrerequisiteStatus.Pass,
|
||||
"ArchestrA Framework registry root should be found on this machine.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAll_on_live_box_reports_aaBootstrap_running()
|
||||
{
|
||||
var report = await AvevaPrerequisites.CheckAllAsync();
|
||||
var bootstrap = report.Checks.FirstOrDefault(c => c.Name == "service:aaBootstrap");
|
||||
bootstrap.ShouldNotBeNull();
|
||||
bootstrap.Status.ShouldBe(PrerequisiteStatus.Pass,
|
||||
$"aaBootstrap must be Running for any live-Galaxy test to work — detail: {bootstrap.Detail}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAll_on_live_box_reports_aaGR_running()
|
||||
{
|
||||
var report = await AvevaPrerequisites.CheckAllAsync();
|
||||
var gr = report.Checks.FirstOrDefault(c => c.Name == "service:aaGR");
|
||||
gr.ShouldNotBeNull();
|
||||
gr.Status.ShouldBe(PrerequisiteStatus.Pass,
|
||||
$"aaGR (Galaxy Repository) must be Running — detail: {gr.Detail}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAll_on_live_box_reports_MxAccess_COM_registered()
|
||||
{
|
||||
var report = await AvevaPrerequisites.CheckAllAsync();
|
||||
var com = report.Checks.FirstOrDefault(c => c.Name == "com:LMXProxy");
|
||||
com.ShouldNotBeNull();
|
||||
com.Status.ShouldBe(PrerequisiteStatus.Pass,
|
||||
$"LMXProxy.LMXProxyServer ProgID must resolve to an InprocServer32 DLL — detail: {com.Detail}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckRepositoryOnly_on_live_box_reports_ZB_reachable()
|
||||
{
|
||||
var report = await AvevaPrerequisites.CheckRepositoryOnlyAsync(ct: CancellationToken.None);
|
||||
var zb = report.Checks.FirstOrDefault(c => c.Name == "sql:ZB");
|
||||
zb.ShouldNotBeNull();
|
||||
zb.Status.ShouldBe(PrerequisiteStatus.Pass,
|
||||
$"ZB database must be reachable via SQL Server Windows auth — detail: {zb.Detail}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckRepositoryOnly_on_live_box_reports_non_zero_deployed_objects()
|
||||
{
|
||||
// This box has 49 deployed objects per the research; we just assert > 0 so adding/
|
||||
// removing objects doesn't break the test.
|
||||
var report = await AvevaPrerequisites.CheckRepositoryOnlyAsync();
|
||||
var deployed = report.Checks.FirstOrDefault(c => c.Name == "sql:ZB.deployedObjects");
|
||||
deployed.ShouldNotBeNull();
|
||||
deployed.Status.ShouldBe(PrerequisiteStatus.Pass,
|
||||
$"At least one deployed gobject should exist — detail: {deployed.Detail}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Aveva_side_is_ready_on_this_machine()
|
||||
{
|
||||
// Narrower than "livetest ready" — our own services (OtOpcUa / OtOpcUaGalaxyHost)
|
||||
// may not be installed on a developer's box while they're actively iterating on
|
||||
// them, but the AVEVA side (Framework / Galaxy Repository / MXAccess COM /
|
||||
// SQL / core services) should always be up on a machine with System Platform
|
||||
// installed. This assertion is what gates live-Galaxy tests that go straight to
|
||||
// the Galaxy Repository without routing through our stack.
|
||||
var report = await AvevaPrerequisites.CheckAllAsync(
|
||||
new AvevaPrerequisites.Options { CheckGalaxyHostPipe = false });
|
||||
_output.WriteLine(report.ToString());
|
||||
_output.WriteLine(report.Warnings ?? "no warnings");
|
||||
|
||||
// Enumerate AVEVA-side failures (if any) for an actionable assertion message.
|
||||
var avevaFails = report.Checks
|
||||
.Where(c => c.Status == PrerequisiteStatus.Fail &&
|
||||
c.Category != PrerequisiteCategory.OtOpcUaService)
|
||||
.ToList();
|
||||
report.IsAvevaSideReady.ShouldBeTrue(
|
||||
avevaFails.Count == 0
|
||||
? "unexpected state"
|
||||
: "AVEVA-side failures: " + string.Join(" ; ",
|
||||
avevaFails.Select(f => $"{f.Name}: {f.Detail}")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Report_captures_OtOpcUa_services_state_even_when_not_installed()
|
||||
{
|
||||
// The helper reports the status of OtOpcUaGalaxyHost + OtOpcUa services even if
|
||||
// they're not installed yet — absence is itself an actionable signal. This test
|
||||
// doesn't assert Pass/Fail on those services (their state depends on what's
|
||||
// installed when the test runs) — it only asserts the helper EMITTED the rows,
|
||||
// so nobody can ship a prerequisite check that silently omits our own services.
|
||||
var report = await AvevaPrerequisites.CheckAllAsync();
|
||||
|
||||
report.Checks.ShouldContain(c => c.Name == "service:OtOpcUaGalaxyHost");
|
||||
report.Checks.ShouldContain(c => c.Name == "service:OtOpcUa");
|
||||
report.Checks.ShouldContain(c => c.Name == "service:GLAuth");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests
|
||||
{
|
||||
@@ -16,6 +17,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests
|
||||
/// SQL the v1 Host uses, proving the lift is byte-for-byte equivalent at the
|
||||
/// <c>DiscoverHierarchyResponse</c> shape.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Since PR 36, skip logic is delegated to <see cref="AvevaPrerequisites.CheckRepositoryOnlyAsync"/>
|
||||
/// so operators see exactly why a test skipped ("ZB db not found" vs "SQL Server
|
||||
/// unreachable") instead of a silent return.
|
||||
/// </remarks>
|
||||
[Trait("Category", "LiveGalaxy")]
|
||||
public sealed class GalaxyRepositoryLiveSmokeTests
|
||||
{
|
||||
@@ -26,15 +32,20 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests
|
||||
CommandTimeoutSeconds = 10,
|
||||
};
|
||||
|
||||
private static async Task<string?> RepositorySkipReasonAsync()
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(4));
|
||||
var report = await AvevaPrerequisites.CheckRepositoryOnlyAsync(
|
||||
DevZbOptions().ConnectionString, cts.Token);
|
||||
return report.SkipReason;
|
||||
}
|
||||
|
||||
private static async Task<bool> ZbReachableAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var repo = new GalaxyRepository(DevZbOptions());
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
return await repo.TestConnectionAsync(cts.Token);
|
||||
}
|
||||
catch { return false; }
|
||||
// Legacy silent-skip adapter — keeps the existing tests compiling while
|
||||
// gradually migrating to the Skip-with-reason pattern. Returns true when the
|
||||
// prerequisite check has no Fail entries.
|
||||
return (await RepositorySkipReasonAsync()) is null;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.csproj"/>
|
||||
<Reference Include="System.ServiceProcess"/>
|
||||
<!-- IMxProxy's delegate signatures mention ArchestrA.MxAccess.MXSTATUS_PROXY, so tests
|
||||
implementing the interface must resolve that type at compile time. -->
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.LiveStack;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the pipe name + shared secret the live <see cref="GalaxyProxyDriver"/> needs
|
||||
/// to connect to a running <c>OtOpcUaGalaxyHost</c> Windows service. Two sources are
|
||||
/// consulted, first match wins:
|
||||
/// <list type="number">
|
||||
/// <item>Explicit env vars (<c>OTOPCUA_GALAXY_PIPE</c>, <c>OTOPCUA_GALAXY_SECRET</c>) — lets CI / benchwork override.</item>
|
||||
/// <item>The service's per-process <c>Environment</c> registry values under
|
||||
/// <c>HKLM\SYSTEM\CurrentControlSet\Services\OtOpcUaGalaxyHost</c> — what
|
||||
/// <c>Install-Services.ps1</c> writes at install time. Requires the test to run as a
|
||||
/// principal with read access to that registry key (typically Administrators).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Explicitly NOT baked-in-to-source: the shared secret is rotated per install (the
|
||||
/// installer generates 32 random bytes and stores the base64 string). A hard-coded secret
|
||||
/// in tests would diverge from production the moment someone re-installed the service.
|
||||
/// </remarks>
|
||||
public sealed record LiveStackConfig(string PipeName, string SharedSecret, string? Source)
|
||||
{
|
||||
public const string EnvPipeName = "OTOPCUA_GALAXY_PIPE";
|
||||
public const string EnvSharedSecret = "OTOPCUA_GALAXY_SECRET";
|
||||
public const string ServiceRegistryKey =
|
||||
@"SYSTEM\CurrentControlSet\Services\OtOpcUaGalaxyHost";
|
||||
public const string DefaultPipeName = "OtOpcUaGalaxy";
|
||||
|
||||
public static LiveStackConfig? Resolve()
|
||||
{
|
||||
var envPipe = Environment.GetEnvironmentVariable(EnvPipeName);
|
||||
var envSecret = Environment.GetEnvironmentVariable(EnvSharedSecret);
|
||||
if (!string.IsNullOrWhiteSpace(envPipe) && !string.IsNullOrWhiteSpace(envSecret))
|
||||
return new LiveStackConfig(envPipe, envSecret, "env vars");
|
||||
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
return null;
|
||||
|
||||
return FromServiceRegistry();
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static LiveStackConfig? FromServiceRegistry()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = Registry.LocalMachine.OpenSubKey(ServiceRegistryKey);
|
||||
if (key is null) return null;
|
||||
var env = key.GetValue("Environment") as string[];
|
||||
if (env is null || env.Length == 0) return null;
|
||||
|
||||
string? pipe = null, secret = null;
|
||||
foreach (var line in env)
|
||||
{
|
||||
var eq = line.IndexOf('=');
|
||||
if (eq <= 0) continue;
|
||||
var name = line[..eq];
|
||||
var value = line[(eq + 1)..];
|
||||
if (name.Equals(EnvPipeName, StringComparison.OrdinalIgnoreCase)) pipe = value;
|
||||
else if (name.Equals(EnvSharedSecret, StringComparison.OrdinalIgnoreCase)) secret = value;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(secret)) return null;
|
||||
return new LiveStackConfig(pipe ?? DefaultPipeName, secret, "service registry");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Access denied / key missing / malformed — caller gets null and surfaces a Skip.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Security.Principal;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.LiveStack;
|
||||
|
||||
/// <summary>
|
||||
/// Connects a single <see cref="GalaxyProxyDriver"/> to the already-running
|
||||
/// <c>OtOpcUaGalaxyHost</c> Windows service for the lifetime of a test class. Uses
|
||||
/// <see cref="AvevaPrerequisites"/> to decide whether to proceed; on failure,
|
||||
/// <see cref="SkipReason"/> is populated and each test calls <see cref="SkipIfUnavailable"/>
|
||||
/// to translate that into <c>Assert.Skip</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Does NOT spawn the Host process.</b> Production deploys <c>OtOpcUaGalaxyHost</c>
|
||||
/// as a standalone Windows service — spawning a second instance from a test would
|
||||
/// bypass the COM-apartment + service-account setup and fail differently than
|
||||
/// production (see <c>project_galaxy_host_service.md</c> memory).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Shared-secret handling</b>: read from <see cref="LiveStackConfig"/> — env vars
|
||||
/// first, then the service's registry-stored <c>Environment</c> values. Requires
|
||||
/// the test process to have read access to
|
||||
/// <c>HKLM\SYSTEM\CurrentControlSet\Services\OtOpcUaGalaxyHost</c>; on a dev box
|
||||
/// that typically means running the test host elevated, or exporting
|
||||
/// <c>OTOPCUA_GALAXY_SECRET</c> out-of-band.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class LiveStackFixture : IAsyncLifetime
|
||||
{
|
||||
public GalaxyProxyDriver? Driver { get; private set; }
|
||||
|
||||
public string? SkipReason { get; private set; }
|
||||
|
||||
public PrerequisiteReport? PrerequisiteReport { get; private set; }
|
||||
|
||||
public LiveStackConfig? Config { get; private set; }
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
// 0. Elevated-shell short-circuit. The OtOpcUaGalaxyHost pipe ACL allows the configured
|
||||
// SID but explicitly DENIES Administrators (decision #76 — production hardening).
|
||||
// A test process running with a high-integrity token (any elevated shell) carries the
|
||||
// Admins group in its security context, so the deny rule trumps the user's allow and
|
||||
// the pipe connect returns UnauthorizedAccessException — technically correct but
|
||||
// the operationally confusing failure mode that ate most of the PR 37 install
|
||||
// debugging session. Surfacing it explicitly here saves the next operator the same
|
||||
// five-step diagnosis. ParityFixture has the same skip with the same rationale.
|
||||
if (IsElevatedAdministratorOnWindows())
|
||||
{
|
||||
SkipReason =
|
||||
"Test host is running with elevated (Administrators) privileges, but the " +
|
||||
"OtOpcUaGalaxyHost named-pipe ACL explicitly denies Administrators per the IPC " +
|
||||
"security design (decision #76 / PipeAcl.cs). Re-run from a NORMAL (non-admin) " +
|
||||
"PowerShell window — even when your user is already in the pipe's allow list, " +
|
||||
"the elevated token's Admins group membership trumps the allow rule.";
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. AVEVA + OtOpcUa service state — actionable diagnostic if anything is missing.
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
PrerequisiteReport = await AvevaPrerequisites.CheckAllAsync(
|
||||
new AvevaPrerequisites.Options { CheckGalaxyHostPipe = true, CheckHistorian = false },
|
||||
cts.Token);
|
||||
|
||||
if (!PrerequisiteReport.IsLivetestReady)
|
||||
{
|
||||
SkipReason = PrerequisiteReport.SkipReason;
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Secret / pipe-name resolution. If the service is running but we can't discover its
|
||||
// env vars from registry (non-elevated test host), a clear message beats a silent
|
||||
// connect-rejected failure 10 seconds later.
|
||||
Config = LiveStackConfig.Resolve();
|
||||
if (Config is null)
|
||||
{
|
||||
SkipReason =
|
||||
$"Cannot resolve shared secret. Set {LiveStackConfig.EnvSharedSecret} (and optionally " +
|
||||
$"{LiveStackConfig.EnvPipeName}) in the environment, or run the test host elevated so it " +
|
||||
$"can read HKLM\\{LiveStackConfig.ServiceRegistryKey}\\Environment.";
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Connect. InitializeAsync does the pipe connect + handshake; a 5-second
|
||||
// ConnectTimeout gives enough headroom for a service that just started.
|
||||
Driver = new GalaxyProxyDriver(new GalaxyProxyOptions
|
||||
{
|
||||
DriverInstanceId = "live-stack-smoke",
|
||||
PipeName = Config.PipeName,
|
||||
SharedSecret = Config.SharedSecret,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(5),
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
await Driver.InitializeAsync(driverConfigJson: "{}", CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SkipReason =
|
||||
$"Connected to named pipe '{Config.PipeName}' but GalaxyProxyDriver.InitializeAsync failed: " +
|
||||
$"{ex.GetType().Name}: {ex.Message}. Common causes: shared secret mismatch (rotated after last install), " +
|
||||
$"service account SID not in pipe ACL (installer sets OTOPCUA_ALLOWED_SID to the service account — " +
|
||||
$"test must run as that user), or Host's backend couldn't connect to ZB.";
|
||||
Driver.Dispose();
|
||||
Driver = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Driver is not null)
|
||||
{
|
||||
try { await Driver.ShutdownAsync(CancellationToken.None); } catch { /* best-effort */ }
|
||||
Driver.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Translate <see cref="SkipReason"/> into <c>Assert.Skip</c>. Tests call this at the
|
||||
/// top of every fact so a fixture init failure shows up as a cleanly-skipped test with
|
||||
/// the full prerequisites report, not a cascading NullReferenceException on
|
||||
/// <see cref="Driver"/>.
|
||||
/// </summary>
|
||||
public void SkipIfUnavailable()
|
||||
{
|
||||
if (SkipReason is not null) Assert.Skip(SkipReason);
|
||||
}
|
||||
|
||||
private static bool IsElevatedAdministratorOnWindows()
|
||||
{
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return false;
|
||||
return CheckWindowsAdminToken();
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static bool CheckWindowsAdminToken()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var identity = WindowsIdentity.GetCurrent();
|
||||
return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Probe shouldn't crash the test; if we can't determine elevation, optimistically
|
||||
// continue and let the actual pipe connect surface its own error.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class LiveStackCollection : ICollectionFixture<LiveStackFixture>
|
||||
{
|
||||
public const string Name = "LiveStack";
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.LiveStack;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end smoke against the installed <c>OtOpcUaGalaxyHost</c> Windows service.
|
||||
/// Closes LMX follow-up #5 — exercises the full topology: <see cref="GalaxyProxyDriver"/>
|
||||
/// in-process → named-pipe IPC → <c>OtOpcUaGalaxyHost</c> service → <c>MxAccessGalaxyBackend</c> →
|
||||
/// live MXAccess runtime → real Galaxy objects + attributes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Preconditions</b> (all checked by <see cref="LiveStackFixture"/>, surfaced via
|
||||
/// <c>Assert.Skip</c> when missing):
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item>AVEVA System Platform installed + Platform deployed.</item>
|
||||
/// <item><c>aaBootstrap</c> / <c>aaGR</c> / <c>NmxSvc</c> / <c>MSSQLSERVER</c> running.</item>
|
||||
/// <item>MXAccess COM server registered.</item>
|
||||
/// <item>ZB database exists with at least one deployed gobject.</item>
|
||||
/// <item><c>OtOpcUaGalaxyHost</c> service installed + running (named pipe accepting connections).</item>
|
||||
/// <item>Shared secret discoverable via <c>OTOPCUA_GALAXY_SECRET</c> env var or the
|
||||
/// service's registry Environment values (test host typically needs to be elevated
|
||||
/// to read the latter).</item>
|
||||
/// <item>Test process runs as the account listed in the service's pipe ACL
|
||||
/// (<c>OTOPCUA_ALLOWED_SID</c>, typically the service account per decision #76).</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Tests here are deliberately read-only. Writes against live Galaxy attributes are a
|
||||
/// separate concern — they need a test-only UDA or an agreed scratch tag so they can't
|
||||
/// accidentally mutate a process-critical value. Adding a write test is a follow-up
|
||||
/// PR that reuses this fixture.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Trait("Category", "LiveGalaxy")]
|
||||
[Collection(LiveStackCollection.Name)]
|
||||
public sealed class LiveStackSmokeTests(LiveStackFixture fixture)
|
||||
{
|
||||
[Fact]
|
||||
public void Fixture_initialized_successfully()
|
||||
{
|
||||
fixture.SkipIfUnavailable();
|
||||
// If the fixture init succeeded, Driver is non-null and InitializeAsync completed.
|
||||
// This is the cheapest possible assertion that the IPC handshake worked end-to-end;
|
||||
// every other test in this class depends on it.
|
||||
fixture.Driver.ShouldNotBeNull();
|
||||
fixture.Config.ShouldNotBeNull();
|
||||
fixture.PrerequisiteReport.ShouldNotBeNull();
|
||||
fixture.PrerequisiteReport!.IsLivetestReady.ShouldBeTrue(fixture.PrerequisiteReport.SkipReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Driver_reports_Healthy_after_IPC_handshake()
|
||||
{
|
||||
fixture.SkipIfUnavailable();
|
||||
var health = fixture.Driver!.GetHealth();
|
||||
health.State.ShouldBe(DriverState.Healthy,
|
||||
$"Expected Healthy after successful IPC connect; Reason={health.LastError}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_returns_at_least_one_variable_from_live_galaxy()
|
||||
{
|
||||
fixture.SkipIfUnavailable();
|
||||
var builder = new CapturingAddressSpaceBuilder();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
await fixture.Driver!.DiscoverAsync(builder, cts.Token);
|
||||
|
||||
builder.Variables.Count.ShouldBeGreaterThan(0,
|
||||
"Live Galaxy has > 0 deployed objects per the prereq check — at least one variable must be discovered. " +
|
||||
"Zero usually means the Host couldn't read ZB (check OTOPCUA_GALAXY_ZB_CONN in the service Environment).");
|
||||
|
||||
// Every discovered attribute must carry a non-empty FullName so the OPC UA server can
|
||||
// route reads/writes back. Regression guard — PR 19 normalized this across drivers.
|
||||
builder.Variables.ShouldAllBe(v => !string.IsNullOrEmpty(v.AttributeInfo.FullName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHostStatuses_reports_at_least_one_platform()
|
||||
{
|
||||
fixture.SkipIfUnavailable();
|
||||
var statuses = fixture.Driver!.GetHostStatuses();
|
||||
statuses.Count.ShouldBeGreaterThan(0,
|
||||
"Live Galaxy must report at least one Platform/AppEngine host via IHostConnectivityProbe. " +
|
||||
"Zero means the Host's probe loop hasn't completed its first tick or the Platform isn't deployed locally.");
|
||||
|
||||
// Host names are driver-opaque to the Core but non-empty by contract.
|
||||
statuses.ShouldAllBe(h => !string.IsNullOrEmpty(h.HostName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Can_read_a_discovered_variable_from_live_galaxy()
|
||||
{
|
||||
fixture.SkipIfUnavailable();
|
||||
var builder = new CapturingAddressSpaceBuilder();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
await fixture.Driver!.DiscoverAsync(builder, cts.Token);
|
||||
builder.Variables.Count.ShouldBeGreaterThan(0);
|
||||
|
||||
// Pick the first discovered variable. Read-only smoke — we don't assert on Value,
|
||||
// only that a ReadAsync round-trip through Proxy → Host pipe → MXAccess → back
|
||||
// returns a snapshot with a non-BadInternalError status. Galaxy attributes default to
|
||||
// Uncertain quality until the Engine's first scan publishes them, which is fine here.
|
||||
var full = builder.Variables[0].AttributeInfo.FullName;
|
||||
var snapshots = await fixture.Driver!.ReadAsync([full], cts.Token);
|
||||
|
||||
snapshots.Count.ShouldBe(1);
|
||||
var snap = snapshots[0];
|
||||
snap.StatusCode.ShouldNotBe(0x80020000u,
|
||||
$"Read returned BadInternalError for {full} — the Host couldn't fulfil the request. " +
|
||||
$"Investigate: the Host service's logs at {System.Environment.GetFolderPath(System.Environment.SpecialFolder.CommonApplicationData)}\\OtOpcUa\\Galaxy\\logs.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_then_read_roundtrips_a_writable_Boolean_attribute_on_TestMachine_001()
|
||||
{
|
||||
// PR 40 — finishes LMX #5. Targets DelmiaReceiver_001.TestAttribute, the writable
|
||||
// Boolean attribute on the TestMachine_001 hierarchy that the dev Galaxy was deployed
|
||||
// with for exactly this kind of integration testing. We invert the current value and
|
||||
// assert the new value comes back, then restore the original so the test is effectively
|
||||
// idempotent (Galaxy holds the value across runs since it's a deployed UDA).
|
||||
fixture.SkipIfUnavailable();
|
||||
const string fullRef = "DelmiaReceiver_001.TestAttribute";
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
|
||||
// Read current value first — gives the cleanup path the right baseline. Galaxy may
|
||||
// return Uncertain quality until the Engine has scanned the attribute at least once;
|
||||
// we don't read into a strongly-typed bool until Status is Good.
|
||||
var before = (await fixture.Driver!.ReadAsync([fullRef], cts.Token))[0];
|
||||
before.StatusCode.ShouldNotBe(0x80020000u, $"baseline read failed for {fullRef}: {before.Value}");
|
||||
var originalBool = Convert.ToBoolean(before.Value ?? false);
|
||||
var inverted = !originalBool;
|
||||
|
||||
try
|
||||
{
|
||||
// Write the inverted value via IWritable.
|
||||
var writeResults = await fixture.Driver!.WriteAsync(
|
||||
[new(fullRef, inverted)], cts.Token);
|
||||
writeResults.Count.ShouldBe(1);
|
||||
writeResults[0].StatusCode.ShouldBe(0u,
|
||||
$"WriteAsync returned status 0x{writeResults[0].StatusCode:X8} for {fullRef} — " +
|
||||
$"check the Host service log at %ProgramData%\\OtOpcUa\\Galaxy\\.");
|
||||
|
||||
// The Engine's scan + acknowledgement is async — read in a short loop with a 5s
|
||||
// budget. Galaxy's attribute roundtrip on a dev box is typically sub-second but
|
||||
// we give headroom for first-scan after a service restart.
|
||||
DataValueSnapshot after = default!;
|
||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
after = (await fixture.Driver!.ReadAsync([fullRef], cts.Token))[0];
|
||||
if (after.StatusCode == 0u && Convert.ToBoolean(after.Value ?? false) == inverted) break;
|
||||
await Task.Delay(200, cts.Token);
|
||||
}
|
||||
after.StatusCode.ShouldBe(0u, "post-write read failed");
|
||||
Convert.ToBoolean(after.Value ?? false).ShouldBe(inverted,
|
||||
$"Wrote {inverted} but Galaxy returned {after.Value} after the scan window.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Restore — best-effort. If this throws the test still reports its primary result;
|
||||
// we just leave a flipped TestAttribute on the dev box (benign, name says it all).
|
||||
try { await fixture.Driver!.WriteAsync([new(fullRef, originalBool)], cts.Token); }
|
||||
catch { /* swallow */ }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_fires_OnDataChange_with_initial_value_then_again_after_a_write()
|
||||
{
|
||||
// Subscribe + write is the canonical "is the data path actually live" test for
|
||||
// an OPC UA driver. We subscribe to the same Boolean attribute, expect an initial-
|
||||
// value callback within a couple of seconds (per ISubscribable's contract — the
|
||||
// driver MAY fire OnDataChange immediately with the current value), then write a
|
||||
// distinct value and expect a second callback carrying the new value.
|
||||
fixture.SkipIfUnavailable();
|
||||
const string fullRef = "DelmiaReceiver_001.TestAttribute";
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
|
||||
// Capture every OnDataChange notification for this fullRef onto a thread-safe queue
|
||||
// we can poll from the test thread. Galaxy's MXAccess advisory fires on its own
|
||||
// thread; we don't want to block it.
|
||||
var notifications = new System.Collections.Concurrent.ConcurrentQueue<DataValueSnapshot>();
|
||||
void Handler(object? sender, DataChangeEventArgs e)
|
||||
{
|
||||
if (string.Equals(e.FullReference, fullRef, StringComparison.OrdinalIgnoreCase))
|
||||
notifications.Enqueue(e.Snapshot);
|
||||
}
|
||||
fixture.Driver!.OnDataChange += Handler;
|
||||
|
||||
// Read current value so we know which value to write to force a transition.
|
||||
var before = (await fixture.Driver!.ReadAsync([fullRef], cts.Token))[0];
|
||||
var originalBool = Convert.ToBoolean(before.Value ?? false);
|
||||
var toWrite = !originalBool;
|
||||
|
||||
ISubscriptionHandle? handle = null;
|
||||
try
|
||||
{
|
||||
handle = await fixture.Driver!.SubscribeAsync(
|
||||
[fullRef], TimeSpan.FromMilliseconds(250), cts.Token);
|
||||
|
||||
// Wait for initial-value notification — typical < 1s on a hot Galaxy, give 5s.
|
||||
await WaitForAsync(() => notifications.Count >= 1, TimeSpan.FromSeconds(5), cts.Token);
|
||||
notifications.Count.ShouldBeGreaterThanOrEqualTo(1,
|
||||
$"No initial-value OnDataChange for {fullRef} within 5s. " +
|
||||
$"Either MXAccess subscription failed silently or the Engine hasn't scanned yet.");
|
||||
|
||||
// Drain the initial-value queue before writing so we count post-write deltas only.
|
||||
var initialCount = notifications.Count;
|
||||
|
||||
// Write the toggled value. Engine scan + advisory fires the second callback.
|
||||
var w = await fixture.Driver!.WriteAsync([new(fullRef, toWrite)], cts.Token);
|
||||
w[0].StatusCode.ShouldBe(0u);
|
||||
|
||||
await WaitForAsync(() => notifications.Count > initialCount, TimeSpan.FromSeconds(8), cts.Token);
|
||||
notifications.Count.ShouldBeGreaterThan(initialCount,
|
||||
$"OnDataChange did not fire after writing {toWrite} to {fullRef} within 8s.");
|
||||
|
||||
// Find the post-write notification carrying the toggled value (initial value may
|
||||
// appear multiple times before the write commits — search the tail).
|
||||
var postWrite = notifications.ToArray().Reverse()
|
||||
.FirstOrDefault(n => n.StatusCode == 0u && Convert.ToBoolean(n.Value ?? false) == toWrite);
|
||||
postWrite.ShouldNotBe(default,
|
||||
$"No OnDataChange carrying the toggled value {toWrite} appeared in the queue: " +
|
||||
string.Join(",", notifications.Select(n => $"{n.Value}@{n.StatusCode:X8}")));
|
||||
}
|
||||
finally
|
||||
{
|
||||
fixture.Driver!.OnDataChange -= Handler;
|
||||
if (handle is not null)
|
||||
{
|
||||
try { await fixture.Driver!.UnsubscribeAsync(handle, cts.Token); } catch { /* swallow */ }
|
||||
}
|
||||
// Restore baseline.
|
||||
try { await fixture.Driver!.WriteAsync([new(fullRef, originalBool)], cts.Token); } catch { /* swallow */ }
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> predicate, TimeSpan budget, CancellationToken ct)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + budget;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (predicate()) return;
|
||||
await Task.Delay(100, ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal <see cref="IAddressSpaceBuilder"/> implementation that captures every
|
||||
/// Variable() call into a flat list so tests can inspect what discovery produced
|
||||
/// without running the full OPC UA node-manager stack.
|
||||
/// </summary>
|
||||
private sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, DriverAttributeInfo AttributeInfo)> Variables { get; } = [];
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
{
|
||||
Variables.Add((browseName, attributeInfo));
|
||||
return new NoopHandle(attributeInfo.FullName);
|
||||
}
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
||||
|
||||
private sealed class NoopHandle(string fullReference) : IVariableHandle
|
||||
{
|
||||
public string FullReference { get; } = fullReference;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NoopSink();
|
||||
private sealed class NoopSink : IAlarmConditionSink
|
||||
{
|
||||
public void OnTransition(AlarmEventArgs args) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.Probes;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// Entry point for live-AVEVA test fixtures. Runs every relevant probe and returns a
|
||||
/// <see cref="PrerequisiteReport"/> whose <c>SkipReason</c> feeds <c>Assert.Skip</c> when
|
||||
/// the environment isn't set up. Non-Windows hosts get a single aggregated Skip row per
|
||||
/// category instead of a flood of individual skips.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Call shape</b>:</para>
|
||||
/// <code>
|
||||
/// var report = await AvevaPrerequisites.CheckAllAsync();
|
||||
/// if (report.SkipReason is not null) Assert.Skip(report.SkipReason);
|
||||
/// </code>
|
||||
/// <para><b>Categories in rough order of 'would I want to know first?'</b>:</para>
|
||||
/// <list type="number">
|
||||
/// <item>Environment — process bitness, OS platform, RPCSS up.</item>
|
||||
/// <item>AvevaInstall — Framework registry, install paths, no pending reboot.</item>
|
||||
/// <item>AvevaCoreService — aaBootstrap / aaGR / NmxSvc running.</item>
|
||||
/// <item>MxAccessCom — LMXProxy.LMXProxyServer ProgID → CLSID → file-on-disk.</item>
|
||||
/// <item>GalaxyRepository — SQL reachable, ZB exists, deployed-object count.</item>
|
||||
/// <item>OtOpcUaService — our two Windows services + GLAuth.</item>
|
||||
/// <item>AvevaSoftService — aaLogger etc., warn only.</item>
|
||||
/// <item>AvevaHistorian — aahClientAccessPoint etc., optional.</item>
|
||||
/// </list>
|
||||
/// <para><b>What's NOT checked here</b>: end-to-end subscribe / read / write against a real
|
||||
/// Galaxy tag. That's the job of the live-smoke tests this helper gates — the helper just
|
||||
/// tells them whether running is worthwhile.</para>
|
||||
/// </remarks>
|
||||
public static class AvevaPrerequisites
|
||||
{
|
||||
// -------- Individual service lists (kept as data so tests can inspect / override) --------
|
||||
|
||||
/// <summary>Services whose absence means live-Galaxy tests can't run at all.</summary>
|
||||
internal static readonly (string Name, string Purpose)[] CoreServices =
|
||||
[
|
||||
("aaBootstrap", "master service that starts the Platform process + brokers aa* communication"),
|
||||
("aaGR", "Galaxy Repository host — mediates IDE / runtime access to ZB"),
|
||||
("NmxSvc", "Network Message Exchange — MXAccess + Bootstrap transport"),
|
||||
("MSSQLSERVER", "SQL Server instance that hosts the ZB database"),
|
||||
];
|
||||
|
||||
/// <summary>Warn-but-don't-fail AVEVA services.</summary>
|
||||
internal static readonly (string Name, string Purpose)[] SoftServices =
|
||||
[
|
||||
("aaLogger", "ArchestrA Logger — diagnostic log receiver; stack runs without it but error visibility suffers"),
|
||||
("aaUserValidator", "OS user/group auth for ArchestrA security; only required when Galaxy security mode isn't 'Open'"),
|
||||
("aaGlobalDataCacheMonitorSvr", "cross-platform global data cache; single-node dev boxes run fine without it"),
|
||||
];
|
||||
|
||||
/// <summary>Optional AVEVA Historian services — only required for HistoryRead IPC paths.</summary>
|
||||
internal static readonly (string Name, string Purpose)[] HistorianServices =
|
||||
[
|
||||
("aahClientAccessPoint", "AVEVA Historian Client Access Point — HistoryRead IPC endpoint"),
|
||||
("aahGateway", "AVEVA Historian Gateway"),
|
||||
];
|
||||
|
||||
/// <summary>OtOpcUa-stack Windows services + third-party deps we manage.</summary>
|
||||
internal static readonly (string Name, string Purpose, bool HardRequired)[] OtOpcUaServices =
|
||||
[
|
||||
("OtOpcUaGalaxyHost", "Galaxy.Host out-of-process service (net48 x86, STA + MXAccess)", true),
|
||||
("OtOpcUa", "Main OPC UA server service (hosts Proxy + DriverHost + Admin-facing DB publisher)", false),
|
||||
("GLAuth", "LDAP server (dev only) — glauth.exe on localhost:3893", false),
|
||||
];
|
||||
|
||||
// -------- Orchestrator --------
|
||||
|
||||
public static async Task<PrerequisiteReport> CheckAllAsync(
|
||||
Options? options = null, CancellationToken ct = default)
|
||||
{
|
||||
options ??= new Options();
|
||||
var checks = new List<PrerequisiteCheck>();
|
||||
|
||||
// Environment
|
||||
checks.Add(MxAccessComProbe.CheckProcessBitness());
|
||||
|
||||
// AvevaInstall — registry + files
|
||||
checks.Add(RegistryProbe.CheckFrameworkInstalled());
|
||||
checks.Add(RegistryProbe.CheckPlatformDeployed());
|
||||
checks.Add(RegistryProbe.CheckRebootPending());
|
||||
|
||||
// AvevaCoreService
|
||||
foreach (var (name, purpose) in CoreServices)
|
||||
checks.Add(ServiceProbe.Check(name, PrerequisiteCategory.AvevaCoreService, hardRequired: true, whatItDoes: purpose));
|
||||
|
||||
// MxAccessCom
|
||||
checks.Add(MxAccessComProbe.Check());
|
||||
|
||||
// GalaxyRepository
|
||||
checks.Add(await SqlProbe.CheckZbDatabaseAsync(options.SqlConnectionString, ct));
|
||||
// Deployed-object count only makes sense if the DB check passed.
|
||||
if (checks[checks.Count - 1].Status == PrerequisiteStatus.Pass)
|
||||
checks.Add(await SqlProbe.CheckDeployedObjectCountAsync(options.SqlConnectionString, ct));
|
||||
|
||||
// OtOpcUaService
|
||||
foreach (var (name, purpose, hard) in OtOpcUaServices)
|
||||
checks.Add(ServiceProbe.Check(name, PrerequisiteCategory.OtOpcUaService, hardRequired: hard, whatItDoes: purpose));
|
||||
if (options.CheckGalaxyHostPipe)
|
||||
checks.Add(await NamedPipeProbe.CheckGalaxyHostPipeAsync(options.GalaxyHostPipeName, ct));
|
||||
|
||||
// AvevaSoftService
|
||||
foreach (var (name, purpose) in SoftServices)
|
||||
checks.Add(ServiceProbe.Check(name, PrerequisiteCategory.AvevaSoftService, hardRequired: false, whatItDoes: purpose));
|
||||
|
||||
// AvevaHistorian
|
||||
if (options.CheckHistorian)
|
||||
{
|
||||
foreach (var (name, purpose) in HistorianServices)
|
||||
checks.Add(ServiceProbe.Check(name, PrerequisiteCategory.AvevaHistorian, hardRequired: false, whatItDoes: purpose));
|
||||
}
|
||||
|
||||
return new PrerequisiteReport(checks);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Narrower check for tests that only need the Galaxy Repository (SQL) path — don't
|
||||
/// pay the cost of probing every aa* service when the test only reads gobject rows.
|
||||
/// </summary>
|
||||
public static async Task<PrerequisiteReport> CheckRepositoryOnlyAsync(
|
||||
string? sqlConnectionString = null, CancellationToken ct = default)
|
||||
{
|
||||
var checks = new List<PrerequisiteCheck>
|
||||
{
|
||||
await SqlProbe.CheckZbDatabaseAsync(sqlConnectionString, ct),
|
||||
};
|
||||
if (checks[0].Status == PrerequisiteStatus.Pass)
|
||||
checks.Add(await SqlProbe.CheckDeployedObjectCountAsync(sqlConnectionString, ct));
|
||||
return new PrerequisiteReport(checks);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Narrower check for the named-pipe endpoint — tests that drive the full Proxy
|
||||
/// against a live Galaxy.Host service don't need the SQL or AVEVA-internal probes
|
||||
/// (the Host does that work internally; we just need the pipe to accept).
|
||||
/// </summary>
|
||||
public static async Task<PrerequisiteReport> CheckGalaxyHostPipeOnlyAsync(
|
||||
string? pipeName = null, CancellationToken ct = default)
|
||||
{
|
||||
var checks = new List<PrerequisiteCheck>
|
||||
{
|
||||
await NamedPipeProbe.CheckGalaxyHostPipeAsync(pipeName, ct),
|
||||
};
|
||||
return new PrerequisiteReport(checks);
|
||||
}
|
||||
|
||||
/// <summary>Knobs for <see cref="CheckAllAsync"/>.</summary>
|
||||
public sealed class Options
|
||||
{
|
||||
/// <summary>SQL Server connection string — defaults to Windows-auth <c>localhost\ZB</c>.</summary>
|
||||
public string? SqlConnectionString { get; init; }
|
||||
|
||||
/// <summary>Named-pipe endpoint for OtOpcUaGalaxyHost — defaults to <c>OtOpcUaGalaxy</c>.</summary>
|
||||
public string? GalaxyHostPipeName { get; init; }
|
||||
|
||||
/// <summary>Include the named-pipe probe. Off by default — it's a seconds-long TCP-like probe and some tests don't need it.</summary>
|
||||
public bool CheckGalaxyHostPipe { get; init; } = true;
|
||||
|
||||
/// <summary>Include Historian service probes. Off by default — Historian is optional.</summary>
|
||||
public bool CheckHistorian { get; init; } = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
#if NET48
|
||||
// Polyfills for C# 9+ language features that the helper uses but that net48 BCL doesn't
|
||||
// provide. Keeps the sources single-target-free at the language level — the same .cs files
|
||||
// build on both frameworks without preprocessor guards in the callsites.
|
||||
|
||||
namespace System.Runtime.CompilerServices
|
||||
{
|
||||
/// <summary>Required by C# 9 <c>init</c>-only setters and <c>record</c> types.</summary>
|
||||
internal static class IsExternalInit { }
|
||||
}
|
||||
|
||||
namespace System.Runtime.Versioning
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimal shim for the .NET 5+ <c>SupportedOSPlatformAttribute</c>. Pure marker for the
|
||||
/// compiler on net10; on net48 we still want the attribute to exist so the same
|
||||
/// <c>[SupportedOSPlatform("windows")]</c> source compiles. The attribute is internal
|
||||
/// and attribute-targets-everything to minimize surface.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.All, Inherited = false, AllowMultiple = true)]
|
||||
internal sealed class SupportedOSPlatformAttribute(string platformName) : Attribute
|
||||
{
|
||||
public string PlatformName { get; } = platformName;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport;
|
||||
|
||||
/// <summary>One prerequisite probe's outcome. <see cref="AvevaPrerequisites"/> returns many of these.</summary>
|
||||
/// <param name="Name">Short diagnostic id — e.g. <c>service:aaBootstrap</c>, <c>sql:ZB</c>, <c>registry:ArchestrA.Framework</c>.</param>
|
||||
/// <param name="Category">Which subsystem the probe belongs to — lets callers filter (e.g. "Historian warns don't gate the core Galaxy smoke").</param>
|
||||
/// <param name="Status">Outcome.</param>
|
||||
/// <param name="Detail">One-line specific message an operator can act on — <c>"aaGR not installed — install the Galaxy Repository role from the System Platform setup"</c> beats <c>"failed"</c>.</param>
|
||||
public sealed record PrerequisiteCheck(
|
||||
string Name,
|
||||
PrerequisiteCategory Category,
|
||||
PrerequisiteStatus Status,
|
||||
string Detail);
|
||||
|
||||
public enum PrerequisiteStatus
|
||||
{
|
||||
/// <summary>Prerequisite is met; no action needed.</summary>
|
||||
Pass,
|
||||
/// <summary>Soft dependency missing — stack still runs but some feature (e.g. logging) is degraded.</summary>
|
||||
Warn,
|
||||
/// <summary>Hard dependency missing — live tests can't proceed; <see cref="PrerequisiteReport.SkipReason"/> surfaces this.</summary>
|
||||
Fail,
|
||||
/// <summary>Probe wasn't applicable in this environment (e.g. non-Windows host, Historian not installed).</summary>
|
||||
Skip,
|
||||
}
|
||||
|
||||
public enum PrerequisiteCategory
|
||||
{
|
||||
/// <summary>Platform sanity — process bitness, OS platform, DCOM/RPCSS.</summary>
|
||||
Environment,
|
||||
/// <summary>Hard-required AVEVA Windows services (aaBootstrap, aaGR, NmxSvc).</summary>
|
||||
AvevaCoreService,
|
||||
/// <summary>Soft-required AVEVA Windows services (aaLogger, aaUserValidator) — warn only.</summary>
|
||||
AvevaSoftService,
|
||||
/// <summary>ArchestrA Framework install markers (registry + files).</summary>
|
||||
AvevaInstall,
|
||||
/// <summary>MXAccess COM server registration + file on disk.</summary>
|
||||
MxAccessCom,
|
||||
/// <summary>SQL Server reachability + ZB database presence + deployed-object count.</summary>
|
||||
GalaxyRepository,
|
||||
/// <summary>Historian services (optional — only required for HistoryRead IPC paths).</summary>
|
||||
AvevaHistorian,
|
||||
/// <summary>OtOpcUa-side services (OtOpcUa, OtOpcUaGalaxyHost) + third-party deps (GLAuth).</summary>
|
||||
OtOpcUaService,
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated result of an <see cref="AvevaPrerequisites.CheckAll"/> run. Test fixtures
|
||||
/// typically call <see cref="SkipReason"/> to produce the argument for xUnit's
|
||||
/// <c>Assert.Skip</c> when any hard dependency failed.
|
||||
/// </summary>
|
||||
public sealed class PrerequisiteReport
|
||||
{
|
||||
public IReadOnlyList<PrerequisiteCheck> Checks { get; }
|
||||
|
||||
public PrerequisiteReport(IEnumerable<PrerequisiteCheck> checks)
|
||||
{
|
||||
Checks = [.. checks];
|
||||
}
|
||||
|
||||
/// <summary>True when every probe is Pass / Warn / Skip — no Fail entries.</summary>
|
||||
public bool IsLivetestReady => !Checks.Any(c => c.Status == PrerequisiteStatus.Fail);
|
||||
|
||||
/// <summary>
|
||||
/// True when only the AVEVA-side probes pass — ignores failures in the
|
||||
/// <see cref="PrerequisiteCategory.OtOpcUaService"/> category. Lets a live-test gate
|
||||
/// say "AVEVA is ready even if the v2 services aren't installed yet" without
|
||||
/// conflating the two. Useful for tests that exercise Galaxy directly (e.g.
|
||||
/// <see cref="GalaxyRepositoryLiveSmokeTests"/>) rather than through our stack.
|
||||
/// </summary>
|
||||
public bool IsAvevaSideReady =>
|
||||
!Checks.Any(c => c.Status == PrerequisiteStatus.Fail && c.Category != PrerequisiteCategory.OtOpcUaService);
|
||||
|
||||
/// <summary>
|
||||
/// Multi-line message for <c>Assert.Skip</c> when a hard dependency isn't met. Returns
|
||||
/// null when <see cref="IsLivetestReady"/> is true.
|
||||
/// </summary>
|
||||
public string? SkipReason
|
||||
{
|
||||
get
|
||||
{
|
||||
var fails = Checks.Where(c => c.Status == PrerequisiteStatus.Fail).ToList();
|
||||
if (fails.Count == 0) return null;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"Live-AVEVA prerequisites not met ({fails.Count} failed):");
|
||||
foreach (var f in fails)
|
||||
sb.AppendLine($" • [{f.Category}] {f.Name} — {f.Detail}");
|
||||
sb.Append("Run `Get-Service aa*` / `sqlcmd -S localhost -d ZB -E -Q \"SELECT 1\"` to triage.");
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable summary of warnings — caller decides whether to log or ignore. Useful
|
||||
/// when a live test does pass but an operator should know their environment is degraded.
|
||||
/// </summary>
|
||||
public string? Warnings
|
||||
{
|
||||
get
|
||||
{
|
||||
var warns = Checks.Where(c => c.Status == PrerequisiteStatus.Warn).ToList();
|
||||
if (warns.Count == 0) return null;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"AVEVA prerequisites with warnings ({warns.Count}):");
|
||||
foreach (var w in warns)
|
||||
sb.AppendLine($" • [{w.Category}] {w.Name} — {w.Detail}");
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Throw <see cref="InvalidOperationException"/> if any <paramref name="categories"/>
|
||||
/// contain a Fail — useful when a specific test needs, say, Galaxy Repository but doesn't
|
||||
/// care about Historian. Call before <c>Assert.Skip</c> if you want to be strict.
|
||||
/// </summary>
|
||||
public void RequireCategories(params PrerequisiteCategory[] categories)
|
||||
{
|
||||
var set = categories.ToHashSet();
|
||||
var fails = Checks.Where(c => c.Status == PrerequisiteStatus.Fail && set.Contains(c.Category)).ToList();
|
||||
if (fails.Count == 0) return;
|
||||
|
||||
var detail = string.Join("; ", fails.Select(f => $"{f.Name}: {f.Detail}"));
|
||||
throw new InvalidOperationException($"Required prerequisite categories failed: {detail}");
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"PrerequisiteReport: {Checks.Count} checks");
|
||||
foreach (var c in Checks)
|
||||
sb.AppendLine($" [{c.Status,-4}] {c.Category}/{c.Name}: {c.Detail}");
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.Probes;
|
||||
|
||||
/// <summary>
|
||||
/// Confirms MXAccess COM server registration by resolving the
|
||||
/// <c>LMXProxy.LMXProxyServer</c> ProgID to its CLSID, then checking that the CLSID's
|
||||
/// 32-bit <c>InprocServer32</c> entry points at a file that exists on disk.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A common failure mode on partial installs: ProgID is registered but the CLSID
|
||||
/// InprocServer32 DLL is missing (previous install uninstalled but registry orphan remains).
|
||||
/// This probe surfaces that case with an actionable message instead of the
|
||||
/// <c>0x80040154 REGDB_E_CLASSNOTREG</c> you'd see from a late COM activation failure.
|
||||
/// </remarks>
|
||||
public static class MxAccessComProbe
|
||||
{
|
||||
public const string ProgId = "LMXProxy.LMXProxyServer";
|
||||
public const string VersionedProgId = "LMXProxy.LMXProxyServer.1";
|
||||
|
||||
public static PrerequisiteCheck Check()
|
||||
{
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom,
|
||||
PrerequisiteStatus.Skip, "COM registration probes only run on Windows.");
|
||||
}
|
||||
return CheckWindows();
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static PrerequisiteCheck CheckWindows()
|
||||
{
|
||||
try
|
||||
{
|
||||
var (clsid, dll) = RegistryProbe.ResolveProgIdToInproc(ProgId);
|
||||
if (clsid is null)
|
||||
{
|
||||
return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom,
|
||||
PrerequisiteStatus.Fail,
|
||||
$"ProgID {ProgId} not registered — MXAccess COM server isn't installed. " +
|
||||
$"Install System Platform's MXAccess component and re-run.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(dll))
|
||||
{
|
||||
return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom,
|
||||
PrerequisiteStatus.Fail,
|
||||
$"ProgID {ProgId} → CLSID {clsid} but InprocServer32 is empty. " +
|
||||
$"Registry is orphaned; re-register with: regsvr32 /s LmxProxy.dll (from an elevated cmd in the Framework bin dir).");
|
||||
}
|
||||
|
||||
// Resolve the recorded path — sometimes registered as a bare filename that the COM
|
||||
// runtime resolves via the current process's DLL-search path. Accept either an
|
||||
// absolute path that exists, or a bare filename whose resolution we can't verify
|
||||
// without loading it (treat as Pass-with-note).
|
||||
if (Path.IsPathRooted(dll))
|
||||
{
|
||||
if (!File.Exists(dll))
|
||||
{
|
||||
return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom,
|
||||
PrerequisiteStatus.Fail,
|
||||
$"ProgID {ProgId} → CLSID {clsid} → InprocServer32 {dll}, but the file is missing. " +
|
||||
$"Re-install the Framework or restore from backup.");
|
||||
}
|
||||
return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom,
|
||||
PrerequisiteStatus.Pass,
|
||||
$"ProgID {ProgId} → {dll} (file exists).");
|
||||
}
|
||||
|
||||
return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom,
|
||||
PrerequisiteStatus.Pass,
|
||||
$"ProgID {ProgId} → {dll} (bare filename — relies on PATH resolution at COM activation time).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom,
|
||||
PrerequisiteStatus.Warn,
|
||||
$"Probe failed: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Warn when running as a 64-bit process — MXAccess COM activation will fail with
|
||||
/// <c>0x80040154</c> regardless of registration state. The production drivers run net48
|
||||
/// x86; xunit hosts run 64-bit by default so this often surfaces first.
|
||||
/// </summary>
|
||||
public static PrerequisiteCheck CheckProcessBitness()
|
||||
{
|
||||
if (Environment.Is64BitProcess)
|
||||
{
|
||||
return new PrerequisiteCheck("env:ProcessBitness", PrerequisiteCategory.Environment,
|
||||
PrerequisiteStatus.Warn,
|
||||
"Test host is 64-bit. Direct MXAccess COM activation would fail with REGDB_E_CLASSNOTREG (0x80040154); " +
|
||||
"the production driver workaround is to run Galaxy.Host as a 32-bit process. Tests that only " +
|
||||
"talk to the Host service over the named pipe aren't affected.");
|
||||
}
|
||||
return new PrerequisiteCheck("env:ProcessBitness", PrerequisiteCategory.Environment,
|
||||
PrerequisiteStatus.Pass, "Test host is 32-bit.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.IO.Pipes;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.Probes;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the <c>OtOpcUaGalaxyHost</c> named-pipe endpoint is accepting connections —
|
||||
/// the handshake the Proxy performs at boot. A clean pipe connect without sending any
|
||||
/// framed message proves the Host service is listening; we disconnect immediately so we
|
||||
/// don't consume a session slot.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Default pipe name matches the installer script's <c>OTOPCUA_GALAXY_PIPE</c> default.
|
||||
/// Override when the Host service was installed with a non-default name (custom deployments).
|
||||
/// </remarks>
|
||||
public static class NamedPipeProbe
|
||||
{
|
||||
public const string DefaultGalaxyHostPipeName = "OtOpcUaGalaxy";
|
||||
|
||||
public static async Task<PrerequisiteCheck> CheckGalaxyHostPipeAsync(
|
||||
string? pipeName = null, CancellationToken ct = default)
|
||||
{
|
||||
pipeName ??= DefaultGalaxyHostPipeName;
|
||||
try
|
||||
{
|
||||
using var client = new NamedPipeClientStream(
|
||||
serverName: ".",
|
||||
pipeName: pipeName,
|
||||
direction: PipeDirection.InOut,
|
||||
options: PipeOptions.Asynchronous);
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(2));
|
||||
await client.ConnectAsync(cts.Token);
|
||||
|
||||
return new PrerequisiteCheck("pipe:OtOpcUaGalaxyHost", PrerequisiteCategory.OtOpcUaService,
|
||||
PrerequisiteStatus.Pass,
|
||||
$@"Pipe \\.\pipe\{pipeName} accepted a connection — OtOpcUaGalaxyHost is listening.");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return new PrerequisiteCheck("pipe:OtOpcUaGalaxyHost", PrerequisiteCategory.OtOpcUaService,
|
||||
PrerequisiteStatus.Fail,
|
||||
$@"Pipe \\.\pipe\{pipeName} not connectable within 2s — OtOpcUaGalaxyHost service isn't running. " +
|
||||
"Start with: sc.exe start OtOpcUaGalaxyHost");
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
return new PrerequisiteCheck("pipe:OtOpcUaGalaxyHost", PrerequisiteCategory.OtOpcUaService,
|
||||
PrerequisiteStatus.Fail,
|
||||
$@"Pipe \\.\pipe\{pipeName} connect timed out — service may be starting or stuck. " +
|
||||
"Check: sc.exe query OtOpcUaGalaxyHost");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PrerequisiteCheck("pipe:OtOpcUaGalaxyHost", PrerequisiteCategory.OtOpcUaService,
|
||||
PrerequisiteStatus.Fail,
|
||||
$@"Pipe \\.\pipe\{pipeName} connect failed: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.Probes;
|
||||
|
||||
/// <summary>
|
||||
/// Reads HKLM registry keys to confirm ArchestrA Framework / System Platform install
|
||||
/// markers. Matches the registered paths documented in
|
||||
/// <c>docs/v2/implementation/</c> — System Platform is 32-bit so keys live under
|
||||
/// <c>HKLM\SOFTWARE\WOW6432Node\ArchestrA\...</c>.
|
||||
/// </summary>
|
||||
public static class RegistryProbe
|
||||
{
|
||||
// Canonical install roots per the research on our dev box (System Platform 2020 R2).
|
||||
public const string ArchestrARootKey = @"SOFTWARE\WOW6432Node\ArchestrA";
|
||||
public const string FrameworkKey = @"SOFTWARE\WOW6432Node\ArchestrA\Framework";
|
||||
public const string PlatformKey = @"SOFTWARE\WOW6432Node\ArchestrA\Framework\Platform";
|
||||
public const string MsiInstallKey = @"SOFTWARE\WOW6432Node\ArchestrA\MSIInstall";
|
||||
|
||||
public static PrerequisiteCheck CheckFrameworkInstalled()
|
||||
{
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
return new PrerequisiteCheck("registry:ArchestrA.Framework", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Skip, "Registry probes only run on Windows.");
|
||||
}
|
||||
return FrameworkInstalledWindows();
|
||||
}
|
||||
|
||||
public static PrerequisiteCheck CheckPlatformDeployed()
|
||||
{
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
return new PrerequisiteCheck("registry:ArchestrA.Platform", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Skip, "Registry probes only run on Windows.");
|
||||
}
|
||||
return PlatformDeployedWindows();
|
||||
}
|
||||
|
||||
public static PrerequisiteCheck CheckRebootPending()
|
||||
{
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
return new PrerequisiteCheck("registry:ArchestrA.RebootPending", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Skip, "Registry probes only run on Windows.");
|
||||
}
|
||||
return RebootPendingWindows();
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static PrerequisiteCheck FrameworkInstalledWindows()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = Registry.LocalMachine.OpenSubKey(FrameworkKey);
|
||||
if (key is null)
|
||||
{
|
||||
return new PrerequisiteCheck("registry:ArchestrA.Framework", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Fail,
|
||||
$"Missing {FrameworkKey} — ArchestrA Framework isn't installed. Install AVEVA System Platform from the setup media.");
|
||||
}
|
||||
|
||||
var installPath = key.GetValue("InstallPath") as string;
|
||||
var rootPath = key.GetValue("RootPath") as string;
|
||||
if (string.IsNullOrWhiteSpace(installPath) || string.IsNullOrWhiteSpace(rootPath))
|
||||
{
|
||||
return new PrerequisiteCheck("registry:ArchestrA.Framework", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Warn,
|
||||
$"Framework key exists but InstallPath/RootPath values missing — install may be incomplete.");
|
||||
}
|
||||
|
||||
return new PrerequisiteCheck("registry:ArchestrA.Framework", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Pass,
|
||||
$"Installed at {installPath} (RootPath {rootPath}).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PrerequisiteCheck("registry:ArchestrA.Framework", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Warn,
|
||||
$"Probe failed: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static PrerequisiteCheck PlatformDeployedWindows()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = Registry.LocalMachine.OpenSubKey(PlatformKey);
|
||||
var pfeConfig = key?.GetValue("PfeConfigOptions") as string;
|
||||
if (string.IsNullOrWhiteSpace(pfeConfig))
|
||||
{
|
||||
return new PrerequisiteCheck("registry:ArchestrA.Platform.Deployed", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Warn,
|
||||
$"No Platform object deployed locally (Platform\\PfeConfigOptions empty). MXAccess will connect but subscriptions will fail. Deploy a Platform from the IDE.");
|
||||
}
|
||||
|
||||
// PfeConfigOptions format: "PlatformId=N,EngineId=N,EngineName=...,..."
|
||||
// A non-deployed state leaves PlatformId=0 or the key empty.
|
||||
if (pfeConfig.Contains("PlatformId=0,", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new PrerequisiteCheck("registry:ArchestrA.Platform.Deployed", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Warn,
|
||||
$"Platform never deployed (PfeConfigOptions has PlatformId=0). Deploy a Platform from the IDE before running live tests.");
|
||||
}
|
||||
|
||||
return new PrerequisiteCheck("registry:ArchestrA.Platform.Deployed", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Pass,
|
||||
$"Platform deployed ({pfeConfig}).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PrerequisiteCheck("registry:ArchestrA.Platform.Deployed", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Warn,
|
||||
$"Probe failed: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static PrerequisiteCheck RebootPendingWindows()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = Registry.LocalMachine.OpenSubKey(MsiInstallKey);
|
||||
var rebootRequired = key?.GetValue("RebootRequired") as string;
|
||||
if (string.Equals(rebootRequired, "True", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new PrerequisiteCheck("registry:ArchestrA.RebootPending", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Warn,
|
||||
"An ArchestrA patch has been installed but the machine hasn't rebooted. Post-patch behavior is undefined until a reboot.");
|
||||
}
|
||||
return new PrerequisiteCheck("registry:ArchestrA.RebootPending", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Pass,
|
||||
"No pending reboot flagged.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PrerequisiteCheck("registry:ArchestrA.RebootPending", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Warn,
|
||||
$"Probe failed: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read the registered <see cref="ComProgIdCheck"/> CLSID for the given ProgID and
|
||||
/// resolve the 32-bit <c>InprocServer32</c> file path. Returns null when either is missing.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
internal static (string? Clsid, string? InprocDllPath) ResolveProgIdToInproc(string progId)
|
||||
{
|
||||
using var progIdKey = Registry.ClassesRoot.OpenSubKey($@"{progId}\CLSID");
|
||||
var clsid = progIdKey?.GetValue(null) as string;
|
||||
if (string.IsNullOrWhiteSpace(clsid)) return (null, null);
|
||||
|
||||
// 32-bit COM server under Wow6432Node\CLSID\{guid}\InprocServer32 default value.
|
||||
using var inproc = Registry.LocalMachine.OpenSubKey(
|
||||
$@"SOFTWARE\Classes\WOW6432Node\CLSID\{clsid}\InprocServer32");
|
||||
var dll = inproc?.GetValue(null) as string;
|
||||
return (clsid, dll);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using System.ServiceProcess;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.Probes;
|
||||
|
||||
/// <summary>
|
||||
/// Queries the Windows Service Control Manager to report whether a named service is
|
||||
/// installed, its current state, and its start type. Non-Windows hosts return Skip.
|
||||
/// </summary>
|
||||
public static class ServiceProbe
|
||||
{
|
||||
public static PrerequisiteCheck Check(
|
||||
string serviceName,
|
||||
PrerequisiteCategory category,
|
||||
bool hardRequired,
|
||||
string whatItDoes)
|
||||
{
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
return new PrerequisiteCheck(
|
||||
Name: $"service:{serviceName}",
|
||||
Category: category,
|
||||
Status: PrerequisiteStatus.Skip,
|
||||
Detail: "Service probes only run on Windows.");
|
||||
}
|
||||
|
||||
return CheckWindows(serviceName, category, hardRequired, whatItDoes);
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static PrerequisiteCheck CheckWindows(
|
||||
string serviceName, PrerequisiteCategory category, bool hardRequired, string whatItDoes)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var sc = new ServiceController(serviceName);
|
||||
// Touch the Status to force the SCM lookup; if the service doesn't exist, this throws
|
||||
// InvalidOperationException with message "Service ... was not found on computer.".
|
||||
var status = sc.Status;
|
||||
var startType = sc.StartType;
|
||||
|
||||
return status switch
|
||||
{
|
||||
ServiceControllerStatus.Running => new PrerequisiteCheck(
|
||||
$"service:{serviceName}", category, PrerequisiteStatus.Pass,
|
||||
$"Running ({whatItDoes})"),
|
||||
|
||||
// DemandStart services (like NmxSvc) that are Stopped are not necessarily a
|
||||
// failure — the master service (aaBootstrap) brings them up on demand. Treat
|
||||
// Stopped+Demand as Warn so operators know the situation but tests still proceed.
|
||||
ServiceControllerStatus.Stopped when startType == ServiceStartMode.Manual =>
|
||||
new PrerequisiteCheck(
|
||||
$"service:{serviceName}", category, PrerequisiteStatus.Warn,
|
||||
$"Installed but Stopped (start type Manual — {whatItDoes}). " +
|
||||
"Will be pulled up on demand by the master service; fine for tests."),
|
||||
|
||||
ServiceControllerStatus.Stopped => Fail(
|
||||
$"Installed but Stopped. Start with: sc.exe start {serviceName} ({whatItDoes})"),
|
||||
|
||||
_ => new PrerequisiteCheck(
|
||||
$"service:{serviceName}", category, PrerequisiteStatus.Warn,
|
||||
$"Transitional state {status} ({whatItDoes}) — try again in a few seconds."),
|
||||
};
|
||||
|
||||
PrerequisiteCheck Fail(string detail) => new(
|
||||
$"service:{serviceName}", category,
|
||||
hardRequired ? PrerequisiteStatus.Fail : PrerequisiteStatus.Warn,
|
||||
detail);
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("was not found", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new PrerequisiteCheck(
|
||||
$"service:{serviceName}", category,
|
||||
hardRequired ? PrerequisiteStatus.Fail : PrerequisiteStatus.Warn,
|
||||
$"Not installed ({whatItDoes}). Install the relevant System Platform component and retry.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PrerequisiteCheck(
|
||||
$"service:{serviceName}", category, PrerequisiteStatus.Warn,
|
||||
$"Probe failed ({ex.GetType().Name}: {ex.Message}) — treat as unknown.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.Probes;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the Galaxy Repository SQL side: SQL Server reachable, <c>ZB</c> database
|
||||
/// present, and at least one deployed object exists (so live tests have something to read).
|
||||
/// Reuses the Windows-auth connection string the repo code defaults to.
|
||||
/// </summary>
|
||||
public static class SqlProbe
|
||||
{
|
||||
public const string DefaultConnectionString =
|
||||
"Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;Connect Timeout=3;";
|
||||
|
||||
public static async Task<PrerequisiteCheck> CheckZbDatabaseAsync(
|
||||
string? connectionString = null, CancellationToken ct = default)
|
||||
{
|
||||
connectionString ??= DefaultConnectionString;
|
||||
try
|
||||
{
|
||||
using var conn = new SqlConnection(connectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
// DB_ID returns null when the database doesn't exist on the connected server — distinct
|
||||
// failure mode from "server unreachable", deserves a distinct message.
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT DB_ID('ZB')";
|
||||
var dbIdObj = await cmd.ExecuteScalarAsync(ct);
|
||||
if (dbIdObj is null || dbIdObj is DBNull)
|
||||
{
|
||||
return new PrerequisiteCheck("sql:ZB", PrerequisiteCategory.GalaxyRepository,
|
||||
PrerequisiteStatus.Fail,
|
||||
"SQL Server reachable but database ZB does not exist. " +
|
||||
"Create the Galaxy from the IDE or restore a .cab backup.");
|
||||
}
|
||||
|
||||
return new PrerequisiteCheck("sql:ZB", PrerequisiteCategory.GalaxyRepository,
|
||||
PrerequisiteStatus.Pass, "Connected; ZB database exists.");
|
||||
}
|
||||
catch (SqlException ex)
|
||||
{
|
||||
return new PrerequisiteCheck("sql:ZB", PrerequisiteCategory.GalaxyRepository,
|
||||
PrerequisiteStatus.Fail,
|
||||
$"SQL Server unreachable: {ex.Message}. Ensure MSSQLSERVER service is running (sc.exe start MSSQLSERVER) and TCP 1433 is open.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PrerequisiteCheck("sql:ZB", PrerequisiteCategory.GalaxyRepository,
|
||||
PrerequisiteStatus.Fail,
|
||||
$"Unexpected probe error: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the count of deployed Galaxy objects (<c>deployed_version > 0</c>). Zero
|
||||
/// isn't a hard failure — lets someone boot a fresh Galaxy and still get meaningful
|
||||
/// test-suite output — but it IS a warning because any live-read smoke will have
|
||||
/// nothing to read.
|
||||
/// </summary>
|
||||
public static async Task<PrerequisiteCheck> CheckDeployedObjectCountAsync(
|
||||
string? connectionString = null, CancellationToken ct = default)
|
||||
{
|
||||
connectionString ??= DefaultConnectionString;
|
||||
try
|
||||
{
|
||||
using var conn = new SqlConnection(connectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT COUNT(*) FROM gobject WHERE deployed_version > 0";
|
||||
var countObj = await cmd.ExecuteScalarAsync(ct);
|
||||
var count = countObj is int i ? i : 0;
|
||||
|
||||
return count > 0
|
||||
? new PrerequisiteCheck("sql:ZB.deployedObjects", PrerequisiteCategory.GalaxyRepository,
|
||||
PrerequisiteStatus.Pass, $"{count} objects deployed — live reads have data to return.")
|
||||
: new PrerequisiteCheck("sql:ZB.deployedObjects", PrerequisiteCategory.GalaxyRepository,
|
||||
PrerequisiteStatus.Warn,
|
||||
"ZB contains no deployed objects. Discovery smoke tests will return empty hierarchies; " +
|
||||
"deploy at least a Platform + AppEngine from the IDE to exercise the read path.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PrerequisiteCheck("sql:ZB.deployedObjects", PrerequisiteCategory.GalaxyRepository,
|
||||
PrerequisiteStatus.Warn,
|
||||
$"Couldn't count deployed objects: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- Multi-target: net10.0 for modern consumer projects (Galaxy.Proxy.Tests, E2E, Admin.Tests),
|
||||
net48 for the Galaxy.Host.Tests project that has to stay on .NET Framework x86 for its
|
||||
MXAccess-COM parent project. The helper uses no OS-level APIs that differ between the
|
||||
two frameworks (registry / SQL / ServiceController are surface-compatible). -->
|
||||
<TargetFrameworks>net10.0;net48</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
|
||||
<!-- System.ServiceProcess.ServiceController + Microsoft.Win32.Registry are cross-platform
|
||||
assemblies that throw PlatformNotSupportedException on non-Windows; the probes in
|
||||
this project guard with RuntimeInformation.IsOSPlatform(OSPlatform.Windows) so they
|
||||
return Skip on Linux/macOS rather than crashing the test host. -->
|
||||
<PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.0"/>
|
||||
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0"/>
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.1"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net48'">
|
||||
<!-- net48 ships System.ServiceProcess + Microsoft.Win32 in-box via BCL references. -->
|
||||
<Reference Include="System.ServiceProcess"/>
|
||||
<!-- Microsoft.Data.SqlClient v6 supports net462+; single-target for consistency. -->
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.1"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,56 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies DL205/DL260 binary-coded-decimal register handling against the
|
||||
/// <c>dl205.json</c> pymodbus profile. HR[1072] = 0x1234 on the profile represents
|
||||
/// decimal 1234 (BCD nibbles). Reading it as <see cref="ModbusDataType.Int16"/> would
|
||||
/// return 0x1234 = 4660; the <see cref="ModbusDataType.Bcd16"/> path decodes 1234.
|
||||
/// </summary>
|
||||
[Collection(ModbusSimulatorCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "DL205")]
|
||||
public sealed class DL205BcdQuirkTests(ModbusSimulatorFixture sim)
|
||||
{
|
||||
[Fact]
|
||||
public async Task DL205_BCD16_decodes_HR1072_as_decimal_1234()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "dl205",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Assert.Skip("MODBUS_SIM_PROFILE != dl205 — skipping (standard profile does not seed HR[1072]).");
|
||||
}
|
||||
|
||||
var options = new ModbusDriverOptions
|
||||
{
|
||||
Host = sim.Host,
|
||||
Port = sim.Port,
|
||||
UnitId = 1,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Tags =
|
||||
[
|
||||
new ModbusTagDefinition("DL205_Count_Bcd",
|
||||
ModbusRegion.HoldingRegisters, Address: 1072,
|
||||
DataType: ModbusDataType.Bcd16, Writable: false),
|
||||
new ModbusTagDefinition("DL205_Count_Int16",
|
||||
ModbusRegion.HoldingRegisters, Address: 1072,
|
||||
DataType: ModbusDataType.Int16, Writable: false),
|
||||
],
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-bcd");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await driver.ReadAsync(["DL205_Count_Bcd", "DL205_Count_Int16"],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
results[0].Value.ShouldBe(1234, "DL205 BCD register 0x1234 represents decimal 1234 per the DirectLOGIC convention");
|
||||
|
||||
results[1].StatusCode.ShouldBe(0u);
|
||||
results[1].Value.ShouldBe((short)0x1234, "same register read as Int16 returns the raw 0x1234 = 4660 value — proves BCD path is distinct");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies DL260 I/O-memory coil mappings against the <c>dl205.json</c> pymodbus profile.
|
||||
/// DirectLOGIC Y-outputs and C-relays are exposed to Modbus as FC01/FC05 coils, but at
|
||||
/// non-zero base addresses that confuse operators used to "Y0 is the first coil". The sim
|
||||
/// seeds Y0 → coil 2048 = ON and C0 → coil 3072 = ON as fixed markers.
|
||||
/// </summary>
|
||||
[Collection(ModbusSimulatorCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "DL205")]
|
||||
public sealed class DL205CoilMappingTests(ModbusSimulatorFixture sim)
|
||||
{
|
||||
[Fact]
|
||||
public async Task DL260_Y0_maps_to_coil_2048()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "dl205",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Assert.Skip("MODBUS_SIM_PROFILE != dl205 — skipping.");
|
||||
}
|
||||
|
||||
var coil = DirectLogicAddress.YOutputToCoil("Y0");
|
||||
coil.ShouldBe((ushort)2048);
|
||||
|
||||
var options = BuildOptions(sim, [
|
||||
new ModbusTagDefinition("DL260_Y0",
|
||||
ModbusRegion.Coils, Address: coil,
|
||||
DataType: ModbusDataType.Bool, Writable: false),
|
||||
]);
|
||||
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-y0");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await driver.ReadAsync(["DL260_Y0"], TestContext.Current.CancellationToken);
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
results[0].Value.ShouldBe(true, "dl205.json seeds coil 2048 (Y0) = ON");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DL260_C0_maps_to_coil_3072()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "dl205",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Assert.Skip("MODBUS_SIM_PROFILE != dl205 — skipping.");
|
||||
}
|
||||
|
||||
var coil = DirectLogicAddress.CRelayToCoil("C0");
|
||||
coil.ShouldBe((ushort)3072);
|
||||
|
||||
var options = BuildOptions(sim, [
|
||||
new ModbusTagDefinition("DL260_C0",
|
||||
ModbusRegion.Coils, Address: coil,
|
||||
DataType: ModbusDataType.Bool, Writable: false),
|
||||
]);
|
||||
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-c0");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await driver.ReadAsync(["DL260_C0"], TestContext.Current.CancellationToken);
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
results[0].Value.ShouldBe(true, "dl205.json seeds coil 3072 (C0) = ON");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DL260_scratch_Crelay_supports_write_then_read()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "dl205",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Assert.Skip("MODBUS_SIM_PROFILE != dl205 — skipping.");
|
||||
}
|
||||
|
||||
// Scratch C-relay at coil 4000 (per dl205.json _quirk note) is writable. Write=true then
|
||||
// read back to confirm FC05 round-trip works against the DL-mapped coil bank.
|
||||
var options = BuildOptions(sim, [
|
||||
new ModbusTagDefinition("DL260_C_Scratch",
|
||||
ModbusRegion.Coils, Address: 4000,
|
||||
DataType: ModbusDataType.Bool, Writable: true),
|
||||
]);
|
||||
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-cscratch");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var writeResults = await driver.WriteAsync(
|
||||
[new(FullReference: "DL260_C_Scratch", Value: true)],
|
||||
TestContext.Current.CancellationToken);
|
||||
writeResults[0].StatusCode.ShouldBe(0u);
|
||||
|
||||
var readResults = await driver.ReadAsync(["DL260_C_Scratch"], TestContext.Current.CancellationToken);
|
||||
readResults[0].StatusCode.ShouldBe(0u);
|
||||
readResults[0].Value.ShouldBe(true);
|
||||
}
|
||||
|
||||
private static ModbusDriverOptions BuildOptions(ModbusSimulatorFixture sim, IReadOnlyList<ModbusTagDefinition> tags)
|
||||
=> new()
|
||||
{
|
||||
Host = sim.Host,
|
||||
Port = sim.Port,
|
||||
UnitId = 1,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Tags = tags,
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the driver's Modbus-exception → OPC UA StatusCode translation end-to-end
|
||||
/// against the dl205.json pymodbus profile. pymodbus returns exception 02 (Illegal Data
|
||||
/// Address) for reads outside the configured register ranges, matching real DL205/DL260
|
||||
/// firmware behavior per <c>docs/v2/dl205.md</c> §exception-codes. The driver must surface
|
||||
/// that as <c>BadOutOfRange</c> (0x803C0000) — not <c>BadInternalError</c> — so the
|
||||
/// operator sees a tag-config diagnosis instead of a generic driver-fault message.
|
||||
/// </summary>
|
||||
[Collection(ModbusSimulatorCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "DL205")]
|
||||
public sealed class DL205ExceptionCodeTests(ModbusSimulatorFixture sim)
|
||||
{
|
||||
[Fact]
|
||||
public async Task DL205_FC03_at_unmapped_register_returns_BadOutOfRange()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "dl205",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Assert.Skip("MODBUS_SIM_PROFILE != dl205 — skipping.");
|
||||
}
|
||||
|
||||
// Address 16383 is the last cell of hr-size=16384 in dl205.json; address 16384 is
|
||||
// beyond the configured HR range. pymodbus validates and returns exception 02
|
||||
// (Illegal Data Address).
|
||||
var options = new ModbusDriverOptions
|
||||
{
|
||||
Host = sim.Host,
|
||||
Port = sim.Port,
|
||||
UnitId = 1,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Tags =
|
||||
[
|
||||
new ModbusTagDefinition("Unmapped",
|
||||
ModbusRegion.HoldingRegisters, Address: 16383,
|
||||
DataType: ModbusDataType.UInt16, Writable: false),
|
||||
],
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-exc");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await driver.ReadAsync(["Unmapped"], TestContext.Current.CancellationToken);
|
||||
results[0].StatusCode.ShouldBe(0x803C0000u,
|
||||
"DL205 returns exception 02 for an FC03 at an unmapped register; driver must translate to BadOutOfRange (not BadInternalError)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies DL205/DL260 CDAB word ordering for 32-bit floats against the
|
||||
/// <c>dl205.json</c> pymodbus profile. DirectLOGIC stores IEEE-754 singles with the low
|
||||
/// word at the lower register address (CDAB) rather than the high word (ABCD). Reading
|
||||
/// <c>HR[1056..1057]</c> with <see cref="ModbusByteOrder.BigEndian"/> produces a tiny
|
||||
/// denormal (~5.74e-41) instead of the intended 1.5f — a silent "value is 0" bug in the
|
||||
/// field unless the caller opts into <see cref="ModbusByteOrder.WordSwap"/>.
|
||||
/// </summary>
|
||||
[Collection(ModbusSimulatorCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "DL205")]
|
||||
public sealed class DL205FloatCdabQuirkTests(ModbusSimulatorFixture sim)
|
||||
{
|
||||
[Fact]
|
||||
public async Task DL205_Float32_CDAB_decodes_1_5f_from_HR1056()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "dl205",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Assert.Skip("MODBUS_SIM_PROFILE != dl205 — skipping (standard profile does not seed HR[1056..1057]).");
|
||||
}
|
||||
|
||||
var options = new ModbusDriverOptions
|
||||
{
|
||||
Host = sim.Host,
|
||||
Port = sim.Port,
|
||||
UnitId = 1,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Tags =
|
||||
[
|
||||
new ModbusTagDefinition("DL205_Float_CDAB",
|
||||
ModbusRegion.HoldingRegisters, Address: 1056,
|
||||
DataType: ModbusDataType.Float32, Writable: false,
|
||||
ByteOrder: ModbusByteOrder.WordSwap),
|
||||
// Control: same address, BigEndian — proves the default decode produces garbage.
|
||||
new ModbusTagDefinition("DL205_Float_ABCD",
|
||||
ModbusRegion.HoldingRegisters, Address: 1056,
|
||||
DataType: ModbusDataType.Float32, Writable: false,
|
||||
ByteOrder: ModbusByteOrder.BigEndian),
|
||||
],
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-cdab");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await driver.ReadAsync(["DL205_Float_CDAB", "DL205_Float_ABCD"],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
results[0].Value.ShouldBe(1.5f, "DL205 Float32 with WordSwap (CDAB) must decode HR[1056..1057] as 1.5f");
|
||||
|
||||
// The BigEndian read of the same wire bytes should differ — not asserting the exact
|
||||
// denormal value (that couples the test to IEEE-754 bit math) but the two decodes MUST
|
||||
// disagree, otherwise the word-order flag is a no-op.
|
||||
results[1].StatusCode.ShouldBe(0u);
|
||||
results[1].Value.ShouldNotBe(1.5f);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,30 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
|
||||
|
||||
/// <summary>
|
||||
/// Tag map for the AutomationDirect DL205 device class. Mirrors what the ModbusPal
|
||||
/// <c>.xmpp</c> profile in <c>ModbusPal/DL205.xmpp</c> exposes (or the real PLC, when
|
||||
/// Tag map for the AutomationDirect DL205 device class. Mirrors what the pymodbus
|
||||
/// <c>dl205.json</c> profile in <c>Pymodbus/dl205.json</c> exposes (or the real PLC, when
|
||||
/// <see cref="ModbusSimulatorFixture"/> is pointed at one).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the scaffold — each tag is deliberately generic so the smoke test has stable
|
||||
/// addresses to read. Device-specific quirk tests (word order, max-register, register-zero
|
||||
/// access, etc.) will land in their own test classes alongside this profile as the user
|
||||
/// validates each behavior in ModbusPal; see <c>docs/v2/modbus-test-plan.md</c> §per-device
|
||||
/// validates each behavior in pymodbus; see <c>docs/v2/modbus-test-plan.md</c> §per-device
|
||||
/// quirk catalog for the checklist.
|
||||
/// </remarks>
|
||||
public static class DL205Profile
|
||||
{
|
||||
/// <summary>Holding register the smoke test reads. Address 100 sidesteps the DL205
|
||||
/// register-zero quirk (pending confirmation) — see modbus-test-plan.md.</summary>
|
||||
public const ushort SmokeHoldingRegister = 100;
|
||||
/// <summary>
|
||||
/// Holding register the smoke test writes + reads. Address 200 is the first cell of the
|
||||
/// scratch HR range in both <c>Pymodbus/standard.json</c> (HR[200..209] = 0) and
|
||||
/// <c>Pymodbus/dl205.json</c> (HR[4096..4103] added in PR 43 for the same purpose), so
|
||||
/// the smoke test runs identically against either simulator profile. Originally
|
||||
/// targeted HR[100] — moved to HR[200] when the standard profile claimed HR[100] as
|
||||
/// the auto-incrementing register that drives subscribe-and-receive tests.
|
||||
/// </summary>
|
||||
public const ushort SmokeHoldingRegister = 200;
|
||||
|
||||
/// <summary>Expected value the ModbusPal profile seeds into register 100. When running
|
||||
/// against a real DL205 (or a ModbusPal profile where this register is writable), the smoke
|
||||
/// test seeds this value first, then reads it back.</summary>
|
||||
/// <summary>Value the smoke test writes then reads back to assert round-trip integrity.</summary>
|
||||
public const short SmokeHoldingValue = 1234;
|
||||
|
||||
public static ModbusDriverOptions BuildOptions(string host, int port) => new()
|
||||
@@ -32,7 +36,7 @@ public static class DL205Profile
|
||||
Tags =
|
||||
[
|
||||
new ModbusTagDefinition(
|
||||
Name: "DL205_Smoke_HReg100",
|
||||
Name: "Smoke_HReg200",
|
||||
Region: ModbusRegion.HoldingRegisters,
|
||||
Address: SmokeHoldingRegister,
|
||||
DataType: ModbusDataType.Int16,
|
||||
|
||||
@@ -38,13 +38,13 @@ public sealed class DL205SmokeTests(ModbusSimulatorFixture sim)
|
||||
// zeroed at simulator start, and tests must not depend on prior-test state per the
|
||||
// test-plan conventions.
|
||||
var writeResults = await driver.WriteAsync(
|
||||
[new(FullReference: "DL205_Smoke_HReg100", Value: (short)DL205Profile.SmokeHoldingValue)],
|
||||
[new(FullReference: "Smoke_HReg200", Value: (short)DL205Profile.SmokeHoldingValue)],
|
||||
TestContext.Current.CancellationToken);
|
||||
writeResults.Count.ShouldBe(1);
|
||||
writeResults[0].StatusCode.ShouldBe(0u, "write must succeed against the ModbusPal DL205 profile");
|
||||
|
||||
var readResults = await driver.ReadAsync(
|
||||
["DL205_Smoke_HReg100"],
|
||||
["Smoke_HReg200"],
|
||||
TestContext.Current.CancellationToken);
|
||||
readResults.Count.ShouldBe(1);
|
||||
readResults[0].StatusCode.ShouldBe(0u);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the DL205/DL260 low-byte-first ASCII string packing quirk against the
|
||||
/// <c>dl205.json</c> pymodbus profile. Standard Modbus packs the first char of each pair
|
||||
/// in the high byte of the register; DirectLOGIC packs it in the low byte instead. Without
|
||||
/// <see cref="ModbusStringByteOrder.LowByteFirst"/> the driver decodes "eHllo" garbage
|
||||
/// even though the bytes on the wire are identical.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Requires the dl205 profile (<c>Pymodbus\serve.ps1 -Profile dl205</c>). The standard
|
||||
/// profile does not seed HR[1040..1042] with string bytes, so running this against the
|
||||
/// standard profile returns <c>"\0\0\0\0\0"</c> and the test fails. Skip when the env
|
||||
/// var <c>MODBUS_SIM_PROFILE</c> is not set to <c>dl205</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Collection(ModbusSimulatorCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "DL205")]
|
||||
public sealed class DL205StringQuirkTests(ModbusSimulatorFixture sim)
|
||||
{
|
||||
[Fact]
|
||||
public async Task DL205_string_low_byte_first_decodes_Hello_from_HR1040()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "dl205",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Assert.Skip("MODBUS_SIM_PROFILE != dl205 — skipping (standard profile does not seed HR[1040..1042]).");
|
||||
}
|
||||
|
||||
var options = new ModbusDriverOptions
|
||||
{
|
||||
Host = sim.Host,
|
||||
Port = sim.Port,
|
||||
UnitId = 1,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Tags =
|
||||
[
|
||||
new ModbusTagDefinition(
|
||||
Name: "DL205_Hello_Low",
|
||||
Region: ModbusRegion.HoldingRegisters,
|
||||
Address: 1040,
|
||||
DataType: ModbusDataType.String,
|
||||
Writable: false,
|
||||
StringLength: 5,
|
||||
StringByteOrder: ModbusStringByteOrder.LowByteFirst),
|
||||
// Control: same address, HighByteFirst, to prove the driver would have decoded
|
||||
// garbage without the quirk flag.
|
||||
new ModbusTagDefinition(
|
||||
Name: "DL205_Hello_High",
|
||||
Region: ModbusRegion.HoldingRegisters,
|
||||
Address: 1040,
|
||||
DataType: ModbusDataType.String,
|
||||
Writable: false,
|
||||
StringLength: 5,
|
||||
StringByteOrder: ModbusStringByteOrder.HighByteFirst),
|
||||
],
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-string");
|
||||
await driver.InitializeAsync(driverConfigJson: "{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await driver.ReadAsync(["DL205_Hello_Low", "DL205_Hello_High"],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
results.Count.ShouldBe(2);
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
results[0].Value.ShouldBe("Hello", "DL205 low-byte-first ordering must produce 'Hello' from HR[1040..1042]");
|
||||
|
||||
// The high-byte-first read of the same wire bytes should differ — not asserting the
|
||||
// exact garbage string (that would couple the test to the ASCII byte math) but the two
|
||||
// decodes MUST disagree, otherwise the quirk flag is a no-op.
|
||||
results[1].StatusCode.ShouldBe(0u);
|
||||
results[1].Value.ShouldNotBe("Hello");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the DL205/DL260 V-memory octal addressing quirk end-to-end: use
|
||||
/// <see cref="DirectLogicAddress.UserVMemoryToPdu"/> to translate <c>V2000</c> octal into
|
||||
/// the Modbus PDU address actually dispatched, then read the marker the dl205.json profile
|
||||
/// placed at that address. HR[0x0400] = 0x2000 proves the translation was performed
|
||||
/// correctly — a naïve caller treating "V2000" as decimal 2000 would read HR[2000] (which
|
||||
/// the profile leaves at 0) and miss the marker entirely.
|
||||
/// </summary>
|
||||
[Collection(ModbusSimulatorCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "DL205")]
|
||||
public sealed class DL205VMemoryQuirkTests(ModbusSimulatorFixture sim)
|
||||
{
|
||||
[Fact]
|
||||
public async Task DL205_V2000_user_memory_resolves_to_PDU_0x0400_marker()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "dl205",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Assert.Skip("MODBUS_SIM_PROFILE != dl205 — skipping (standard profile does not seed V-memory markers).");
|
||||
}
|
||||
|
||||
var pdu = DirectLogicAddress.UserVMemoryToPdu("V2000");
|
||||
pdu.ShouldBe((ushort)0x0400);
|
||||
|
||||
var options = new ModbusDriverOptions
|
||||
{
|
||||
Host = sim.Host,
|
||||
Port = sim.Port,
|
||||
UnitId = 1,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Tags =
|
||||
[
|
||||
new ModbusTagDefinition("DL205_V2000",
|
||||
ModbusRegion.HoldingRegisters, Address: pdu,
|
||||
DataType: ModbusDataType.UInt16, Writable: false),
|
||||
],
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-vmem");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await driver.ReadAsync(["DL205_V2000"], TestContext.Current.CancellationToken);
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
results[0].Value.ShouldBe((ushort)0x2000, "dl205.json seeds HR[0x0400] with marker 0x2000 (= V2000 value)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DL205_V40400_system_memory_resolves_to_PDU_0x2100_marker()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "dl205",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Assert.Skip("MODBUS_SIM_PROFILE != dl205 — skipping.");
|
||||
}
|
||||
|
||||
// V40400 is system memory on DL260 / H2-ECOM100 absolute mode; it does NOT follow the
|
||||
// simple octal-to-decimal formula (40400 octal = 16640 decimal, which would read HR[0x4100]).
|
||||
// The CPU places the system bank at PDU 0x2100 instead. Proving the helper routes there.
|
||||
var pdu = DirectLogicAddress.SystemVMemoryToPdu(0);
|
||||
pdu.ShouldBe((ushort)0x2100);
|
||||
|
||||
var options = new ModbusDriverOptions
|
||||
{
|
||||
Host = sim.Host,
|
||||
Port = sim.Port,
|
||||
UnitId = 1,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Tags =
|
||||
[
|
||||
new ModbusTagDefinition("DL205_V40400",
|
||||
ModbusRegion.HoldingRegisters, Address: pdu,
|
||||
DataType: ModbusDataType.UInt16, Writable: false),
|
||||
],
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-sysv");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await driver.ReadAsync(["DL205_V40400"], TestContext.Current.CancellationToken);
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
results[0].Value.ShouldBe((ushort)0x4040, "dl205.json seeds HR[0x2100] with marker 0x4040 (= V40400 value)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the DL260 X-input discrete-input mapping against the <c>dl205.json</c>
|
||||
/// pymodbus profile. X-inputs are FC02 discrete-input-only (Modbus doesn't allow writes
|
||||
/// to discrete inputs), and the DirectLOGIC convention is X0 → DI 0 with octal offsets
|
||||
/// for subsequent addresses. The sim seeds X20 octal (= DI 16) = ON so the test can
|
||||
/// prove the helper routes through to the right cell.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// X0 / X1 / …X17 octal all share cell 0 (DI 0-15 → cell 0 bits 0-15) which conflicts
|
||||
/// with the V0 uint16 marker; we can't seed both types at cell 0 under shared-blocks
|
||||
/// semantics. So the test uses X20 octal (first address beyond the cell-0 boundary) which
|
||||
/// lands cleanly at cell 1 bit 0 and leaves the V0 register-zero quirk intact.
|
||||
/// </remarks>
|
||||
[Collection(ModbusSimulatorCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "DL205")]
|
||||
public sealed class DL205XInputTests(ModbusSimulatorFixture sim)
|
||||
{
|
||||
[Fact]
|
||||
public async Task DL260_X20_octal_maps_to_DiscreteInput_16_and_reads_ON()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "dl205",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Assert.Skip("MODBUS_SIM_PROFILE != dl205 — skipping.");
|
||||
}
|
||||
|
||||
// X20 octal = decimal 16 = DI 16 per the DL260 convention (X-inputs start at DI 0).
|
||||
var di = DirectLogicAddress.XInputToDiscrete("X20");
|
||||
di.ShouldBe((ushort)16);
|
||||
|
||||
var options = BuildOptions(sim, [
|
||||
new ModbusTagDefinition("DL260_X20",
|
||||
ModbusRegion.DiscreteInputs, Address: di,
|
||||
DataType: ModbusDataType.Bool, Writable: false),
|
||||
// Unpopulated-X control: pymodbus returns 0 (not exception) for any bit in the
|
||||
// configured DI range that wasn't explicitly seeded — per docs/v2/dl205.md
|
||||
// "Reading a non-populated X input ... returns zero, not an exception".
|
||||
new ModbusTagDefinition("DL260_X21_off",
|
||||
ModbusRegion.DiscreteInputs, Address: DirectLogicAddress.XInputToDiscrete("X21"),
|
||||
DataType: ModbusDataType.Bool, Writable: false),
|
||||
]);
|
||||
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-xinput");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await driver.ReadAsync(["DL260_X20", "DL260_X21_off"], TestContext.Current.CancellationToken);
|
||||
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
results[0].Value.ShouldBe(true, "dl205.json seeds cell 1 bit 0 (X20 octal = DI 16) = ON");
|
||||
|
||||
results[1].StatusCode.ShouldBe(0u, "unpopulated X inputs must read cleanly — DL260 does NOT raise an exception");
|
||||
results[1].Value.ShouldBe(false);
|
||||
}
|
||||
|
||||
private static ModbusDriverOptions BuildOptions(ModbusSimulatorFixture sim, IReadOnlyList<ModbusTagDefinition> tags)
|
||||
=> new()
|
||||
{
|
||||
Host = sim.Host,
|
||||
Port = sim.Port,
|
||||
UnitId = 1,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Tags = tags,
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.Mitsubishi;
|
||||
|
||||
/// <summary>
|
||||
/// Tag map for the Mitsubishi MELSEC device class with a representative Modbus Device
|
||||
/// Assignment block mapping D0..D1023 → HR[0..1023]. Mirrors the behaviors in
|
||||
/// <c>mitsubishi.json</c> pymodbus profile and <c>docs/v2/mitsubishi.md</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// MELSEC Modbus sites all have *different* device-assignment parameter blocks; this profile
|
||||
/// models the conventional default. Per-model differences (FX5U needs firmware ≥ 1.060 for
|
||||
/// Modbus server; QJ71MT91 lacks FC22/FC23; FX/iQ-F use octal X/Y while Q/L/iQ-R use hex)
|
||||
/// are handled in <see cref="MelsecAddress"/> (PR 59) and the per-model test files.
|
||||
/// </remarks>
|
||||
public static class MitsubishiProfile
|
||||
{
|
||||
/// <summary>
|
||||
/// Scratch HR the smoke test writes + reads. Address 200 mirrors the
|
||||
/// dl205/s7_1500/standard scratch range so one smoke test pattern works across every
|
||||
/// device profile the simulator supports.
|
||||
/// </summary>
|
||||
public const ushort SmokeHoldingRegister = 200;
|
||||
|
||||
/// <summary>Value the smoke test writes then reads back.</summary>
|
||||
public const short SmokeHoldingValue = 7890;
|
||||
|
||||
public static ModbusDriverOptions BuildOptions(string host, int port) => new()
|
||||
{
|
||||
Host = host,
|
||||
Port = port,
|
||||
UnitId = 1,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Tags =
|
||||
[
|
||||
new ModbusTagDefinition(
|
||||
Name: "Smoke_HReg200",
|
||||
Region: ModbusRegion.HoldingRegisters,
|
||||
Address: SmokeHoldingRegister,
|
||||
DataType: ModbusDataType.Int16,
|
||||
Writable: true),
|
||||
],
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.Mitsubishi;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the MELSEC-family Modbus quirks against the <c>mitsubishi.json</c> pymodbus
|
||||
/// profile: CDAB word order default, binary-not-BCD D-register encoding, hex X-input
|
||||
/// parsing (Q/L/iQ-R), D0 fingerprint, M-relay coil mapping with bank base.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Groups all quirks in one test class instead of per-behavior classes (unlike the DL205
|
||||
/// set) because MELSEC's per-model differentiation is handled by the
|
||||
/// <see cref="MelsecFamily"/> enum on the helper + <c>MODBUS_SIM_PROFILE</c> env var on
|
||||
/// the fixture, rather than per-PR test classes.
|
||||
/// </remarks>
|
||||
[Collection(ModbusSimulatorCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "Mitsubishi")]
|
||||
public sealed class MitsubishiQuirkTests(ModbusSimulatorFixture sim)
|
||||
{
|
||||
[Fact]
|
||||
public async Task Mitsubishi_D0_fingerprint_reads_0x1234()
|
||||
{
|
||||
if (!ShouldRun()) return;
|
||||
await using var driver = await NewDriverAsync(
|
||||
new ModbusTagDefinition("D0_Fingerprint",
|
||||
ModbusRegion.HoldingRegisters,
|
||||
Address: MelsecAddress.DRegisterToHolding("D0"),
|
||||
DataType: ModbusDataType.UInt16, Writable: false));
|
||||
|
||||
var r = await driver.ReadAsync(["D0_Fingerprint"], TestContext.Current.CancellationToken);
|
||||
r[0].StatusCode.ShouldBe(0u);
|
||||
r[0].Value.ShouldBe((ushort)0x1234);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mitsubishi_Float32_CDAB_decodes_1_5f_from_D100()
|
||||
{
|
||||
if (!ShouldRun()) return;
|
||||
// MELSEC Q/L/iQ-R/iQ-F all store 32-bit values with CDAB word order (low word at
|
||||
// lower D-register address). HR[100..101] = [0, 0x3FC0] decodes as 1.5f under
|
||||
// WordSwap but as a denormal under BigEndian.
|
||||
var addr = MelsecAddress.DRegisterToHolding("D100");
|
||||
await using var driver = await NewDriverAsync(
|
||||
new ModbusTagDefinition("D100_Float_CDAB",
|
||||
ModbusRegion.HoldingRegisters, Address: addr,
|
||||
DataType: ModbusDataType.Float32, Writable: false,
|
||||
ByteOrder: ModbusByteOrder.WordSwap),
|
||||
new ModbusTagDefinition("D100_Float_ABCD_control",
|
||||
ModbusRegion.HoldingRegisters, Address: addr,
|
||||
DataType: ModbusDataType.Float32, Writable: false,
|
||||
ByteOrder: ModbusByteOrder.BigEndian));
|
||||
|
||||
var r = await driver.ReadAsync(
|
||||
["D100_Float_CDAB", "D100_Float_ABCD_control"],
|
||||
TestContext.Current.CancellationToken);
|
||||
r[0].Value.ShouldBe(1.5f, "MELSEC stores Float32 CDAB; WordSwap decode returns 1.5f");
|
||||
r[1].Value.ShouldNotBe(1.5f, "same wire with BigEndian must decode to a different value");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mitsubishi_D10_is_binary_not_BCD()
|
||||
{
|
||||
if (!ShouldRun()) return;
|
||||
// Counter-to-DL205: MELSEC D-registers are binary by default. D10 = 1234 decimal =
|
||||
// 0x04D2. Reading as Int16 returns 1234; reading as Bcd16 would throw (nibble 0xD is
|
||||
// non-BCD) — the integration test proves the Int16 decode wins.
|
||||
await using var driver = await NewDriverAsync(
|
||||
new ModbusTagDefinition("D10_Binary",
|
||||
ModbusRegion.HoldingRegisters,
|
||||
Address: MelsecAddress.DRegisterToHolding("D10"),
|
||||
DataType: ModbusDataType.Int16, Writable: false));
|
||||
|
||||
var r = await driver.ReadAsync(["D10_Binary"], TestContext.Current.CancellationToken);
|
||||
r[0].StatusCode.ShouldBe(0u);
|
||||
r[0].Value.ShouldBe((short)1234, "MELSEC stores numeric D-register values in binary; 0x04D2 = 1234");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mitsubishi_D10_as_BCD_throws_because_nibble_is_non_decimal()
|
||||
{
|
||||
if (!ShouldRun()) return;
|
||||
// If a site configured D10 with Bcd16 data type but the ladder writes binary, the
|
||||
// BCD decoder MUST reject the garbage rather than silently returning wrong decimal.
|
||||
// 0x04D2 contains nibble 0xD which fails BCD validation.
|
||||
await using var driver = await NewDriverAsync(
|
||||
new ModbusTagDefinition("D10_WrongBcd",
|
||||
ModbusRegion.HoldingRegisters,
|
||||
Address: MelsecAddress.DRegisterToHolding("D10"),
|
||||
DataType: ModbusDataType.Bcd16, Writable: false));
|
||||
|
||||
var r = await driver.ReadAsync(["D10_WrongBcd"], TestContext.Current.CancellationToken);
|
||||
// ReadAsync catches the InvalidDataException from DecodeBcd and surfaces it as
|
||||
// BadCommunicationError (PR 52 mapping). Non-zero status = caller sees a real
|
||||
// problem and can check their tag config instead of getting silently-wrong numbers.
|
||||
r[0].StatusCode.ShouldNotBe(0u, "BCD decode of binary 0x04D2 must fail loudly because nibble D is non-BCD");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mitsubishi_QLiQR_X210_hex_maps_to_DI_528_reads_ON()
|
||||
{
|
||||
if (!ShouldRun()) return;
|
||||
// MELSEC-Q / L / iQ-R: X addresses are hex. X210 = 0x210 = 528 decimal.
|
||||
// mitsubishi.json seeds cell 33 (DI 528..543) with value 9 = bit 0 + bit 3 set.
|
||||
// X210 → DI 528 → cell 33 bit 0 = 1 (ON).
|
||||
var addr = MelsecAddress.XInputToDiscrete("X210", MelsecFamily.Q_L_iQR);
|
||||
addr.ShouldBe((ushort)528);
|
||||
|
||||
await using var driver = await NewDriverAsync(
|
||||
new ModbusTagDefinition("X210_hex",
|
||||
ModbusRegion.DiscreteInputs, Address: addr,
|
||||
DataType: ModbusDataType.Bool, Writable: false));
|
||||
|
||||
var r = await driver.ReadAsync(["X210_hex"], TestContext.Current.CancellationToken);
|
||||
r[0].StatusCode.ShouldBe(0u);
|
||||
r[0].Value.ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mitsubishi_family_trap_X20_differs_on_Q_vs_FX()
|
||||
{
|
||||
// Not a live-sim test — a unit-level proof that the MELSEC family selector gates the
|
||||
// address correctly. Included in the integration suite so anyone running the MELSEC
|
||||
// tests sees the trap called out explicitly.
|
||||
MelsecAddress.XInputToDiscrete("X20", MelsecFamily.Q_L_iQR).ShouldBe((ushort)32);
|
||||
MelsecAddress.XInputToDiscrete("X20", MelsecFamily.F_iQF).ShouldBe((ushort)16);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mitsubishi_M512_maps_to_coil_512_reads_ON()
|
||||
{
|
||||
if (!ShouldRun()) return;
|
||||
// mitsubishi.json seeds cell 32 (coil 512..527) with value 5 = bit 0 + bit 2 set.
|
||||
// M512 → coil 512 → cell 32 bit 0 = 1 (ON).
|
||||
var addr = MelsecAddress.MRelayToCoil("M512");
|
||||
addr.ShouldBe((ushort)512);
|
||||
|
||||
await using var driver = await NewDriverAsync(
|
||||
new ModbusTagDefinition("M512",
|
||||
ModbusRegion.Coils, Address: addr,
|
||||
DataType: ModbusDataType.Bool, Writable: false));
|
||||
|
||||
var r = await driver.ReadAsync(["M512"], TestContext.Current.CancellationToken);
|
||||
r[0].StatusCode.ShouldBe(0u);
|
||||
r[0].Value.ShouldBe(true);
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private bool ShouldRun()
|
||||
{
|
||||
if (sim.SkipReason is not null) { Assert.Skip(sim.SkipReason); return false; }
|
||||
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "mitsubishi",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Assert.Skip("MODBUS_SIM_PROFILE != mitsubishi — skipping.");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<ModbusDriver> NewDriverAsync(params ModbusTagDefinition[] tags)
|
||||
{
|
||||
var drv = new ModbusDriver(
|
||||
new ModbusDriverOptions
|
||||
{
|
||||
Host = sim.Host,
|
||||
Port = sim.Port,
|
||||
UnitId = 1,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Tags = tags,
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
},
|
||||
driverInstanceId: "melsec-quirk");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
return drv;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.Mitsubishi;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end smoke against the MELSEC <c>mitsubishi.json</c> pymodbus profile (or a real
|
||||
/// MELSEC QJ71MT91 / iQ-R / FX5U when <c>MODBUS_SIM_ENDPOINT</c> points at one). Drives
|
||||
/// the full <see cref="ModbusDriver"/> + real <see cref="ModbusTcpTransport"/> stack.
|
||||
/// Success proves the driver initializes against the MELSEC sim, writes a known value,
|
||||
/// and reads it back — the baseline every Mitsubishi-specific test (PR 59+) builds on.
|
||||
/// </summary>
|
||||
[Collection(ModbusSimulatorCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "Mitsubishi")]
|
||||
public sealed class MitsubishiSmokeTests(ModbusSimulatorFixture sim)
|
||||
{
|
||||
[Fact]
|
||||
public async Task Mitsubishi_roundtrip_write_then_read_of_holding_register()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "mitsubishi",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Assert.Skip("MODBUS_SIM_PROFILE != mitsubishi — skipping.");
|
||||
}
|
||||
|
||||
var options = MitsubishiProfile.BuildOptions(sim.Host, sim.Port);
|
||||
await using var driver = new ModbusDriver(options, driverInstanceId: "melsec-smoke");
|
||||
await driver.InitializeAsync(driverConfigJson: "{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var writeResults = await driver.WriteAsync(
|
||||
[new(FullReference: "Smoke_HReg200", Value: (short)MitsubishiProfile.SmokeHoldingValue)],
|
||||
TestContext.Current.CancellationToken);
|
||||
writeResults.Count.ShouldBe(1);
|
||||
writeResults[0].StatusCode.ShouldBe(0u, "write must succeed against the MELSEC pymodbus profile");
|
||||
|
||||
var readResults = await driver.ReadAsync(
|
||||
["Smoke_HReg200"],
|
||||
TestContext.Current.CancellationToken);
|
||||
readResults.Count.ShouldBe(1);
|
||||
readResults[0].StatusCode.ShouldBe(0u);
|
||||
readResults[0].Value.ShouldBe((short)MitsubishiProfile.SmokeHoldingValue);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
# ModbusPal simulator profiles
|
||||
|
||||
Drop device-specific `.xmpp` profiles here. The integration tests connect to the
|
||||
endpoint in `MODBUS_SIM_ENDPOINT` (default `localhost:502`) and expect the
|
||||
simulator to already be running — tests do not launch ModbusPal themselves,
|
||||
because its Java GUI + JRE requirement is heavier than the harness is worth.
|
||||
|
||||
## Getting started
|
||||
|
||||
1. Download ModbusPal from SourceForge (`modbuspal.jar`).
|
||||
2. `java -jar modbuspal.jar` to launch the GUI.
|
||||
3. Load a profile from this directory (or configure one manually) and start the
|
||||
simulator on TCP port 502.
|
||||
4. `dotnet test tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests` — tests
|
||||
auto-skip with a clear `SkipReason` if the TCP probe at the configured
|
||||
endpoint fails within 2 seconds.
|
||||
|
||||
## Profile files
|
||||
|
||||
- `DL205.xmpp` — _to be added_ — register map reflecting the AutomationDirect
|
||||
DL205 quirks tracked in `docs/v2/modbus-test-plan.md`. The scaffolded smoke
|
||||
test in `DL205/DL205SmokeTests.cs` needs holding register 100 writable and
|
||||
present; a minimal ModbusPal profile with a single holding-register bank at
|
||||
address 100 is sufficient.
|
||||
|
||||
## Environment variables
|
||||
|
||||
- `MODBUS_SIM_ENDPOINT` — override the simulator endpoint. Accepts `host:port`;
|
||||
defaults to `localhost:502`. Useful when pointing the suite at a real PLC on
|
||||
the bench.
|
||||
@@ -3,8 +3,9 @@ using System.Net.Sockets;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Reachability probe for a Modbus TCP simulator (ModbusPal or a real PLC). Parses
|
||||
/// <c>MODBUS_SIM_ENDPOINT</c> (default <c>localhost:502</c>) and TCP-connects once at
|
||||
/// Reachability probe for a Modbus TCP simulator (pymodbus-driven, see
|
||||
/// <c>Pymodbus/serve.ps1</c>) or a real PLC. Parses
|
||||
/// <c>MODBUS_SIM_ENDPOINT</c> (default <c>localhost:5020</c> per PR 43) and TCP-connects once at
|
||||
/// fixture construction. Each test checks <see cref="SkipReason"/> and calls
|
||||
/// <c>Assert.Skip</c> when the endpoint was unreachable, so a dev box without a running
|
||||
/// simulator still passes `dotnet test` cleanly — matches the Galaxy live-smoke pattern in
|
||||
@@ -25,7 +26,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
|
||||
/// </remarks>
|
||||
public sealed class ModbusSimulatorFixture : IAsyncDisposable
|
||||
{
|
||||
private const string DefaultEndpoint = "localhost:502";
|
||||
// PR 43: default port is 5020 (pymodbus convention) instead of 502 (Modbus standard).
|
||||
// Picking 5020 sidesteps the privileged-port admin requirement on Windows + matches the
|
||||
// port baked into the pymodbus simulator JSON profiles in Pymodbus/. Override with
|
||||
// MODBUS_SIM_ENDPOINT to point at a real PLC on its native port 502.
|
||||
private const string DefaultEndpoint = "localhost:5020";
|
||||
private const string EndpointEnvVar = "MODBUS_SIM_ENDPOINT";
|
||||
|
||||
public string Host { get; }
|
||||
@@ -41,18 +46,30 @@ public sealed class ModbusSimulatorFixture : IAsyncDisposable
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
var task = client.ConnectAsync(Host, Port);
|
||||
// Force IPv4 family on the probe — pymodbus's TCP server binds 0.0.0.0 (IPv4 only)
|
||||
// while .NET's TcpClient default-resolves "localhost" → IPv6 ::1 first, fails to
|
||||
// connect, and only then tries IPv4. Under .NET 10 the IPv6 fail surfaces as a
|
||||
// 2s timeout (no graceful fallback by default), so the C# probe times out even
|
||||
// though a PowerShell probe of the same endpoint succeeds. Resolving + dialing
|
||||
// explicit IPv4 sidesteps the dual-stack ordering.
|
||||
using var client = new TcpClient(System.Net.Sockets.AddressFamily.InterNetwork);
|
||||
var task = client.ConnectAsync(
|
||||
System.Net.Dns.GetHostAddresses(Host)
|
||||
.FirstOrDefault(a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
||||
?? System.Net.IPAddress.Loopback,
|
||||
Port);
|
||||
if (!task.Wait(TimeSpan.FromSeconds(2)) || !client.Connected)
|
||||
{
|
||||
SkipReason = $"Modbus simulator at {Host}:{Port} did not accept a TCP connection within 2s. " +
|
||||
$"Start ModbusPal (or override {EndpointEnvVar}) and re-run.";
|
||||
$"Start the pymodbus simulator (Pymodbus\\serve.ps1 -Profile standard) " +
|
||||
$"or override {EndpointEnvVar}, then re-run.";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SkipReason = $"Modbus simulator at {Host}:{Port} unreachable: {ex.GetType().Name}: {ex.Message}. " +
|
||||
$"Start ModbusPal (or override {EndpointEnvVar}) and re-run.";
|
||||
$"Start the pymodbus simulator (Pymodbus\\serve.ps1 -Profile standard) " +
|
||||
$"or override {EndpointEnvVar}, then re-run.";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
# pymodbus simulator profiles
|
||||
|
||||
Two JSON-config profiles for pymodbus's `ModbusSimulatorServer`. Replaces the
|
||||
ModbusPal `.xmpp` profiles that lived here in PR 42 — pymodbus is headless,
|
||||
maintained, semantic about register layout, and pip-installable on Windows.
|
||||
|
||||
| File | What it simulates | Test category |
|
||||
|---|---|---|
|
||||
| [`standard.json`](standard.json) | Generic Modbus TCP server — HR[0..31] = address-as-value, HR[100] declarative auto-increment via `"action": "increment"`, alternating coils, scratch ranges for write tests. | `Trait=Standard` |
|
||||
| [`dl205.json`](dl205.json) | AutomationDirect DirectLOGIC DL205 / DL260 quirks per [`docs/v2/dl205.md`](../../../docs/v2/dl205.md): low-byte-first string packing, CDAB Float32, BCD numerics, V-memory address markers, Y/C coil mappings. Inline `_quirk` comments per register name the behavior. | `Trait=DL205` |
|
||||
|
||||
Both bind TCP **5020** (pymodbus convention; sidesteps the Windows admin
|
||||
requirement for privileged port 502). The integration-test fixture
|
||||
(`ModbusSimulatorFixture`) defaults to `localhost:5020` to match — override
|
||||
via `MODBUS_SIM_ENDPOINT` to point at a real PLC on its native port 502.
|
||||
|
||||
Run only **one profile at a time** (they share TCP 5020).
|
||||
|
||||
## Install
|
||||
|
||||
```powershell
|
||||
pip install "pymodbus[simulator]==3.13.0"
|
||||
```
|
||||
|
||||
The `[simulator]` extra pulls in `aiohttp` for the optional web UI / REST API.
|
||||
Pinned to 3.13.0 for reproducibility — avoid 4.x dev releases until stabilized.
|
||||
Requires Python ≥ 3.10. Windows Firewall will prompt on first bind; allow
|
||||
Private network.
|
||||
|
||||
## Run
|
||||
|
||||
Foreground (Ctrl+C to stop). Use the `serve.ps1` wrapper:
|
||||
|
||||
```powershell
|
||||
.\serve.ps1 -Profile standard
|
||||
.\serve.ps1 -Profile dl205
|
||||
```
|
||||
|
||||
Or invoke pymodbus directly:
|
||||
|
||||
```powershell
|
||||
pymodbus.simulator `
|
||||
--modbus_server srv `
|
||||
--modbus_device dev `
|
||||
--json_file .\standard.json `
|
||||
--http_port 8080
|
||||
```
|
||||
|
||||
Web UI at `http://localhost:8080` lets you inspect + poke registers manually.
|
||||
Pass `--no_http` (or `-HttpPort 0` to `serve.ps1`) to disable.
|
||||
|
||||
## Run the integration tests
|
||||
|
||||
In a separate shell, with the simulator running:
|
||||
|
||||
```powershell
|
||||
cd C:\Users\dohertj2\Desktop\lmxopcua
|
||||
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests
|
||||
```
|
||||
|
||||
Tests auto-skip with a clear `SkipReason` if `localhost:5020` isn't reachable
|
||||
within 2 seconds. Filter by trait when both profiles' tests coexist:
|
||||
|
||||
```powershell
|
||||
dotnet test ... --filter "Trait=Standard"
|
||||
dotnet test ... --filter "Trait=DL205"
|
||||
```
|
||||
|
||||
## What's encoded in each profile
|
||||
|
||||
### standard.json
|
||||
|
||||
- HR[0..31]: each register's value equals its address. Easy mental map.
|
||||
- HR[100]: `"action": "increment"` ticks 0..65535 on every register access — drives subscribe-and-receive tests so they have a register that changes without a write.
|
||||
- HR[200..209]: scratch range for write-roundtrip tests.
|
||||
- Coils[0..31]: alternating on/off (even=on).
|
||||
- Coils[100..109]: scratch.
|
||||
- All addresses 0..1023 are writable (`"write": [[0, 1023]]`).
|
||||
|
||||
### dl205.json (per `docs/v2/dl205.md`)
|
||||
|
||||
| HR address | Quirk demonstrated | Raw value | Decoded |
|
||||
|---|---|---|---|
|
||||
| `0` (V0) | Register 0 is valid (rejects-register-0 rumour disproved) | `51966` (0xCAFE) | marker |
|
||||
| `1024` (V2000 octal) | V-memory octal-to-decimal mapping | `8192` (0x2000) | marker |
|
||||
| `8448` (V40400 octal) | V40400 → PDU 0x2100 (NOT register 0) | `16448` (0x4040) | marker |
|
||||
| `1040..1042` | String "Hello" packed first-char-low-byte | `25928, 27756, 111` | `"Hello"` |
|
||||
| `1056..1057` | Float32 1.5f in CDAB word order | `0, 16320` | `1.5f` |
|
||||
| `1072` | Decimal 1234 in BCD encoding | `4660` (0x1234) | `1234` |
|
||||
| `1280..1407` | 128-register block (FC03 cap = 128 above spec's 125) | first/last/mid markers; rest defaults to 0 | for FC03 cap test |
|
||||
|
||||
| Coil address | Quirk demonstrated |
|
||||
|---|---|
|
||||
| `2048` | Y0 maps to coil 2048 (DL260 layout) |
|
||||
| `3072` | C0 maps to coil 3072 (DL260 layout) |
|
||||
| `4000..4007` | Scratch C-relay range for write-roundtrip tests |
|
||||
|
||||
The DL260 X-input markers (FC02 discrete inputs) **are not encoded separately**
|
||||
because the profile uses `shared blocks: true` (matches DL series memory
|
||||
model) — coils/DI/HR/IR overlay the same word address space. Tests that
|
||||
target FC02 against this profile end up reading the same bit positions as
|
||||
the coils they share with.
|
||||
|
||||
## What's IN pymodbus that wasn't in ModbusPal
|
||||
|
||||
- **All four standard tables** (HR, IR, coils, DI) configurable via `co size` / `di size` / `hr size` / `ir size` setup keys.
|
||||
- **Per-register raw uint16 seeding** — `{"addr": 1040, "value": 25928}` puts exactly that 16-bit value on the wire. No interpretation.
|
||||
- **Built-in actions**: `increment`, `random`, `timestamp`, `reset`, `uptime` for declarative dynamic registers. No Python script alongside the config required.
|
||||
- **Custom actions** — point `--custom_actions_module` at a `.py` file exposing callables to express anything more complex (per-second wall-clock ticks, BCD synthesis, etc.).
|
||||
- **Headless** — pure CLI process, no Java, no Swing. Pip-installable. Plays well with CI runners.
|
||||
- **Web UI / REST API** — `--http_port 8080` adds an aiohttp server for live inspection. Optional.
|
||||
- **Maintained** — current stable 3.13.0 (April 2026), active development on 4.0 dev branch.
|
||||
|
||||
## Trade-offs vs the hand-authored ModbusPal profiles
|
||||
|
||||
- pymodbus's built-in `float32` type stores in pymodbus's word order; for explicit DL205 CDAB control we seed two raw `uint16` entries instead. Documented inline in `dl205.json`.
|
||||
- `increment` action ticks per-access, not wall-clock. A 250ms-poll integration test sees variation either way; for strict 1Hz cadence add `--custom_actions_module my_actions.py` with a `time.time()`-based callable.
|
||||
- `dl205.json` uses `shared blocks: true` because it matches DL series memory model; `standard.json` uses `shared blocks: false` so coils and HR address spaces are independent (more like a textbook PLC).
|
||||
|
||||
## File format reference
|
||||
|
||||
```json
|
||||
{
|
||||
"server_list": {
|
||||
"<server-name>": {
|
||||
"comm": "tcp",
|
||||
"host": "0.0.0.0",
|
||||
"port": 5020,
|
||||
"framer": "socket",
|
||||
"device_id": 1
|
||||
}
|
||||
},
|
||||
"device_list": {
|
||||
"<device-name>": {
|
||||
"setup": {
|
||||
"co size": N, "di size": N, "hr size": N, "ir size": N,
|
||||
"shared blocks": false,
|
||||
"type exception": false,
|
||||
"defaults": { "value": {...}, "action": {...} }
|
||||
},
|
||||
"invalid": [],
|
||||
"write": [[<from>, <to>]],
|
||||
"bits": [{"addr": N, "value": 0|1}],
|
||||
"uint16": [{"addr": N, "value": <0..65535>, "action"?: "increment", "parameters"?: {...}}],
|
||||
"uint32": [{"addr": N, "value": <int>}],
|
||||
"float32": [{"addr": N, "value": <float>}],
|
||||
"string": [{"addr": N, "value": "<text>"}],
|
||||
"repeat": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The CLI args `--modbus_server <server-name> --modbus_device <device-name>`
|
||||
pick which entries the simulator binds.
|
||||
|
||||
## References
|
||||
|
||||
- [pymodbus on PyPI](https://pypi.org/project/pymodbus/) — install, version pin
|
||||
- [Simulator config docs](https://pymodbus.readthedocs.io/en/dev/source/library/simulator/config.html) — full schema reference
|
||||
- [Simulator REST API](https://pymodbus.readthedocs.io/en/latest/source/library/simulator/restapi.html) — for the optional web UI
|
||||
- [`docs/v2/dl205.md`](../../../docs/v2/dl205.md) — what each DL205 profile entry simulates
|
||||
- [`docs/v2/modbus-test-plan.md`](../../../docs/v2/modbus-test-plan.md) — the `DL205_<behavior>` test naming convention
|
||||
@@ -0,0 +1,111 @@
|
||||
{
|
||||
"_comment": "DL205.json — DirectLOGIC DL205/DL260 quirk simulator. Models docs/v2/dl205.md as concrete register values. NOTE: pymodbus rejects unknown keys at device-list / setup level; explanatory comments live at top-level _comment + in README + git. Inline _quirk keys WITHIN individual register entries are accepted by pymodbus 3.13.0 (it only validates addr / value / action / parameters per entry). Each quirky uint16 is a pre-computed raw 16-bit value; pymodbus serves it verbatim. shared blocks=true matches DL series memory model. write list mirrors each seeded block — pymodbus rejects sweeping write ranges that include undefined cells.",
|
||||
|
||||
"server_list": {
|
||||
"srv": {
|
||||
"comm": "tcp",
|
||||
"host": "0.0.0.0",
|
||||
"port": 5020,
|
||||
"framer": "socket",
|
||||
"device_id": 1
|
||||
}
|
||||
},
|
||||
|
||||
"device_list": {
|
||||
"dev": {
|
||||
"setup": {
|
||||
"co size": 16384,
|
||||
"di size": 8192,
|
||||
"hr size": 16384,
|
||||
"ir size": 1024,
|
||||
"shared blocks": true,
|
||||
"type exception": false,
|
||||
"defaults": {
|
||||
"value": {"bits": 0, "uint16": 0, "uint32": 0, "float32": 0.0, "string": " "},
|
||||
"action": {"bits": null, "uint16": null, "uint32": null, "float32": null, "string": null}
|
||||
}
|
||||
},
|
||||
"invalid": [],
|
||||
"write": [
|
||||
[0, 0],
|
||||
[200, 209],
|
||||
[1024, 1024],
|
||||
[1040, 1042],
|
||||
[1056, 1057],
|
||||
[1072, 1072],
|
||||
[1280, 1282],
|
||||
[1343, 1343],
|
||||
[1407, 1407],
|
||||
[1, 1],
|
||||
[128, 128],
|
||||
[192, 192],
|
||||
[250, 250],
|
||||
[8448, 8448]
|
||||
],
|
||||
|
||||
"uint16": [
|
||||
{"_quirk": "V0 marker. HR[0]=0xCAFE proves register 0 is valid on DL205/DL260 (rejects-register-0 was a DL05/DL06 relative-mode artefact). 0xCAFE = 51966.",
|
||||
"addr": 0, "value": 51966},
|
||||
|
||||
{"_quirk": "Scratch HR range 200..209 — mirrors the standard.json scratch range so the smoke test (DL205Profile.SmokeHoldingRegister=200) round-trips identically against either profile.",
|
||||
"addr": 200, "value": 0},
|
||||
{"addr": 201, "value": 0},
|
||||
{"addr": 202, "value": 0},
|
||||
{"addr": 203, "value": 0},
|
||||
{"addr": 204, "value": 0},
|
||||
{"addr": 205, "value": 0},
|
||||
{"addr": 206, "value": 0},
|
||||
{"addr": 207, "value": 0},
|
||||
{"addr": 208, "value": 0},
|
||||
{"addr": 209, "value": 0},
|
||||
|
||||
{"_quirk": "V2000 marker. V2000 octal = decimal 1024 = PDU 0x0400. Marker 0x2000 = 8192.",
|
||||
"addr": 1024, "value": 8192},
|
||||
|
||||
{"_quirk": "V40400 marker. V40400 octal = decimal 8448 = PDU 0x2100 (NOT register 0). Marker 0x4040 = 16448.",
|
||||
"addr": 8448, "value": 16448},
|
||||
|
||||
{"_quirk": "String 'Hello' first char in LOW byte. HR[0x410] = 'H'(0x48) lo + 'e'(0x65) hi = 0x6548 = 25928.",
|
||||
"addr": 1040, "value": 25928},
|
||||
{"_quirk": "String 'Hello' second char-pair: 'l'(0x6C) lo + 'l'(0x6C) hi = 0x6C6C = 27756.",
|
||||
"addr": 1041, "value": 27756},
|
||||
{"_quirk": "String 'Hello' third char-pair: 'o'(0x6F) lo + null(0x00) hi = 0x006F = 111.",
|
||||
"addr": 1042, "value": 111},
|
||||
|
||||
{"_quirk": "Float32 1.5f in CDAB word order. IEEE 754 1.5 = 0x3FC00000. CDAB = low word first: HR[0x420]=0x0000, HR[0x421]=0x3FC0=16320.",
|
||||
"addr": 1056, "value": 0},
|
||||
{"_quirk": "Float32 1.5f CDAB high word.",
|
||||
"addr": 1057, "value": 16320},
|
||||
|
||||
{"_quirk": "BCD register. Decimal 1234 stored as BCD nibbles 0x1234 = 4660. NOT binary 1234 (= 0x04D2).",
|
||||
"addr": 1072, "value": 4660},
|
||||
|
||||
{"_quirk": "FC03 cap test marker — first cell of a 128-register span the FC03 cap test reads. Other cells in the span aren't seeded explicitly, so reads of HR[1283..1342] / 1344..1406 return the default 0; the seeded markers at 1280, 1281, 1282, 1343, 1407 prove the span boundaries.",
|
||||
"addr": 1280, "value": 0},
|
||||
{"addr": 1281, "value": 1},
|
||||
{"addr": 1282, "value": 2},
|
||||
{"addr": 1343, "value": 63},
|
||||
{"addr": 1407, "value": 127}
|
||||
],
|
||||
|
||||
"bits": [
|
||||
{"_quirk": "X-input bank marker cell. X0 -> DI 0 conflicts with uint16 V0 at cell 0, so this marker covers X20 octal (= decimal 16 = DI 16 = cell 1 bit 0). X20=ON, X23 octal (DI 19 = cell 1 bit 3)=ON -> cell 1 value = 0b00001001 = 9.",
|
||||
"addr": 1, "value": 9},
|
||||
|
||||
{"_quirk": "Y-output bank marker cell. pymodbus's simulator maps Modbus FC01/02/05 bit-addresses to cell index = bit_addr / 16; so Modbus coil 2048 lives at cell 128 bit 0. Y0=ON (bit 0), Y1=OFF (bit 1), Y2=ON (bit 2) -> value=0b00000101=5 proves DL260 mapping Y0 -> coil 2048.",
|
||||
"addr": 128, "value": 5},
|
||||
|
||||
{"_quirk": "C-relay bank marker cell. Modbus coil 3072 -> cell 192 bit 0. C0=ON (bit 0), C1=OFF (bit 1), C2=ON (bit 2) -> value=5 proves DL260 mapping C0 -> coil 3072.",
|
||||
"addr": 192, "value": 5},
|
||||
|
||||
{"_quirk": "Scratch cell for coil 4000..4015 write round-trip tests. Cell 250 holds Modbus coils 4000-4015; all bits start at 0 and tests set specific bits via FC05.",
|
||||
"addr": 250, "value": 0}
|
||||
],
|
||||
|
||||
"uint32": [],
|
||||
"float32": [],
|
||||
"string": [],
|
||||
"repeat": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"_comment": "mitsubishi.json -- Mitsubishi MELSEC Modbus TCP quirk simulator covering QJ71MT91, iQ-R, iQ-F/FX5U, and FX3U-ENET-P502 behaviors documented in docs/v2/mitsubishi.md. MELSEC CPUs store multi-word values in CDAB order (opposite of S7 ABCD, same family as DL260). The Modbus-module 'Modbus Device Assignment Parameter' block is per-site, so this profile models one *representative* assignment mapping D-register D0..D1023 -> HR 0..1023, M-relay M0..M511 -> coil 0..511, X-input X0..X15 -> DI 0..15 (X-addresses are HEX on Q/L/iQ-R, so X10 = decimal 16; on FX/iQ-F they're OCTAL like DL260). pymodbus bit-address semantics are the same as dl205.json and s7_1500.json (FC01/02/05/15 address N maps to cell index N/16).",
|
||||
|
||||
"server_list": {
|
||||
"srv": {
|
||||
"comm": "tcp",
|
||||
"host": "0.0.0.0",
|
||||
"port": 5020,
|
||||
"framer": "socket",
|
||||
"device_id": 1
|
||||
}
|
||||
},
|
||||
|
||||
"device_list": {
|
||||
"dev": {
|
||||
"setup": {
|
||||
"co size": 4096,
|
||||
"di size": 4096,
|
||||
"hr size": 4096,
|
||||
"ir size": 1024,
|
||||
"shared blocks": true,
|
||||
"type exception": false,
|
||||
"defaults": {
|
||||
"value": {"bits": 0, "uint16": 0, "uint32": 0, "float32": 0.0, "string": " "},
|
||||
"action": {"bits": null, "uint16": null, "uint32": null, "float32": null, "string": null}
|
||||
}
|
||||
},
|
||||
"invalid": [],
|
||||
"write": [
|
||||
[0, 0],
|
||||
[10, 10],
|
||||
[100, 101],
|
||||
[200, 209],
|
||||
[300, 301],
|
||||
[500, 500]
|
||||
],
|
||||
|
||||
"uint16": [
|
||||
{"_quirk": "D0 fingerprint marker. MELSEC D0 is the first data register; Modbus Device Assignment typically maps D0..D1023 -> HR 0..1023. 0x1234 is the fingerprint operators set in GX Works to prove the mapping parameter block is in effect.",
|
||||
"addr": 0, "value": 4660},
|
||||
|
||||
{"_quirk": "Scratch HR range 200..209 -- mirrors the dl205/s7_1500/standard scratch range so smoke tests (MitsubishiProfile.SmokeHoldingRegister=200) round-trip identically against any profile.",
|
||||
"addr": 200, "value": 0},
|
||||
{"addr": 201, "value": 0},
|
||||
{"addr": 202, "value": 0},
|
||||
{"addr": 203, "value": 0},
|
||||
{"addr": 204, "value": 0},
|
||||
{"addr": 205, "value": 0},
|
||||
{"addr": 206, "value": 0},
|
||||
{"addr": 207, "value": 0},
|
||||
{"addr": 208, "value": 0},
|
||||
{"addr": 209, "value": 0},
|
||||
|
||||
{"_quirk": "Float32 1.5f in CDAB word order (MELSEC Q/L/iQ-R/iQ-F default, same as DL260). HR[100]=0x0000=0 low word, HR[101]=0x3FC0=16320 high word. Decode with ByteOrder.WordSwap returns 1.5f; BigEndian decode returns a denormal.",
|
||||
"addr": 100, "value": 0},
|
||||
{"addr": 101, "value": 16320},
|
||||
|
||||
{"_quirk": "Int32 0x12345678 in CDAB word order. HR[300]=0x5678=22136 low word, HR[301]=0x1234=4660 high word. Contrasts with the S7 profile's ABCD encoding at the same address.",
|
||||
"addr": 300, "value": 22136},
|
||||
{"addr": 301, "value": 4660},
|
||||
|
||||
{"_quirk": "D10 = decimal 1234 stored as BINARY (NOT BCD like DL205). 0x04D2 = 1234 decimal. Caller reading with Bcd16 data type would decode this as binary 1234's BCD nibbles which are non-BCD and throw InvalidDataException -- proves MELSEC is binary-by-default, opposite of DL205's BCD-by-default quirk.",
|
||||
"addr": 10, "value": 1234},
|
||||
|
||||
{"_quirk": "Modbus Device Assignment boundary marker. HR[500] represents the last register in an assigned D-range D500. Beyond this (HR[501..4095]) would be Illegal Data Address on a real QJ71MT91 with this specific parameter block; pymodbus returns default 0 because its shared cell array has space -- real-PLC parity is documented in docs/v2/mitsubishi.md §device-assignment, not enforced here.",
|
||||
"addr": 500, "value": 500}
|
||||
],
|
||||
|
||||
"bits": [
|
||||
{"_quirk": "M-relay marker cell at cell 32 = Modbus coil 512 = MELSEC M512 (coils 0..15 collide with the D0 uint16 marker cell, so we place the M marker above that). Cell 32 bit 0 = 1 and bit 2 = 1 (value = 0b101 = 5) = M512=ON, M513=OFF, M514=ON. Matches the Y0/Y2 marker pattern in dl205 and s7_1500 profiles.",
|
||||
"addr": 32, "value": 5},
|
||||
|
||||
{"_quirk": "X-input marker cell at cell 33 = Modbus DI 528 (= MELSEC X210 hex on Q/L/iQ-R). Cell 33 bit 0 = 1 and bit 3 = 1 (value = 0x9 = 9). Chosen above cell 1 so it doesn't collide with any uint16 D-register. Proves the hex-parsing X-input helper on Q/L/iQ-R family; FX/iQ-F families use octal X-addresses tested separately.",
|
||||
"addr": 33, "value": 9}
|
||||
],
|
||||
|
||||
"uint32": [],
|
||||
"float32": [],
|
||||
"string": [],
|
||||
"repeat": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"_comment": "s7_1500.json -- Siemens SIMATIC S7-1500 + MB_SERVER quirk simulator. Models docs/v2/s7.md behaviors as concrete register values. Unlike DL260 (CDAB word order default) or Mitsubishi (CDAB default), S7 MB_SERVER uses ABCD word order by default because Siemens native CPU types are big-endian top-to-bottom both within the register pair and byte pair. This profile exists so the driver's S7 profile default ByteOrder.BigEndian can be validated end-to-end. pymodbus bit-address semantics are the same as dl205.json (FC01/02/05/15 address X maps to cell index X/16); seed bits at the appropriate cell-indexed positions.",
|
||||
|
||||
"server_list": {
|
||||
"srv": {
|
||||
"comm": "tcp",
|
||||
"host": "0.0.0.0",
|
||||
"port": 5020,
|
||||
"framer": "socket",
|
||||
"device_id": 1
|
||||
}
|
||||
},
|
||||
|
||||
"device_list": {
|
||||
"dev": {
|
||||
"setup": {
|
||||
"co size": 4096,
|
||||
"di size": 4096,
|
||||
"hr size": 4096,
|
||||
"ir size": 1024,
|
||||
"shared blocks": true,
|
||||
"type exception": false,
|
||||
"defaults": {
|
||||
"value": {"bits": 0, "uint16": 0, "uint32": 0, "float32": 0.0, "string": " "},
|
||||
"action": {"bits": null, "uint16": null, "uint32": null, "float32": null, "string": null}
|
||||
}
|
||||
},
|
||||
"invalid": [],
|
||||
"write": [
|
||||
[0, 0],
|
||||
[25, 25],
|
||||
[100, 101],
|
||||
[200, 209],
|
||||
[300, 301]
|
||||
],
|
||||
|
||||
"uint16": [
|
||||
{"_quirk": "DB1 header marker. On an S7-1500 with MB_SERVER pointing at DB1, operators often reserve DB1.DBW0 for a fingerprint word so clients can verify they're talking to the right DB. 0xABCD = 43981.",
|
||||
"addr": 0, "value": 43981},
|
||||
|
||||
{"_quirk": "Scratch HR range 200..209 -- mirrors the standard.json scratch range so the smoke test (S7_1500Profile.SmokeHoldingRegister=200) round-trips identically against either profile.",
|
||||
"addr": 200, "value": 0},
|
||||
{"addr": 201, "value": 0},
|
||||
{"addr": 202, "value": 0},
|
||||
{"addr": 203, "value": 0},
|
||||
{"addr": 204, "value": 0},
|
||||
{"addr": 205, "value": 0},
|
||||
{"addr": 206, "value": 0},
|
||||
{"addr": 207, "value": 0},
|
||||
{"addr": 208, "value": 0},
|
||||
{"addr": 209, "value": 0},
|
||||
|
||||
{"_quirk": "Float32 1.5f in ABCD word order (Siemens big-endian default, OPPOSITE of DL260 CDAB). IEEE-754 1.5 = 0x3FC00000. ABCD = high word first: HR[100]=0x3FC0=16320, HR[101]=0x0000=0.",
|
||||
"addr": 100, "value": 16320},
|
||||
{"_quirk": "Float32 1.5f ABCD low word.",
|
||||
"addr": 101, "value": 0},
|
||||
|
||||
{"_quirk": "Int32 0x12345678 in ABCD word order. HR[300]=0x1234=4660, HR[301]=0x5678=22136. Demonstrates the contrast with DL260 CDAB Int32 encoding.",
|
||||
"addr": 300, "value": 4660},
|
||||
{"addr": 301, "value": 22136}
|
||||
],
|
||||
|
||||
"bits": [
|
||||
{"_quirk": "Coil bank marker cell. S7 MB_SERVER doesn't fix coil addresses; this simulates a user-wired DB where coil 400 (=bit 0 of cell 25) represents a latched digital output. Cell 25 bit 0 = 1 proves the wire-format round-trip works for coils on S7 profile.",
|
||||
"addr": 25, "value": 1},
|
||||
|
||||
{"_quirk": "Discrete-input bank marker cell. DI 500 (=bit 0 of cell 31) = 1. Like coils, discrete inputs on S7 MB_SERVER are per-site; we assert the end-to-end FC02 path only.",
|
||||
"addr": 31, "value": 1}
|
||||
],
|
||||
|
||||
"uint32": [],
|
||||
"float32": [],
|
||||
"string": [],
|
||||
"repeat": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Launches the pymodbus simulator with one of the integration-test profiles
|
||||
(Standard or DL205). Foreground process — Ctrl+C to stop.
|
||||
|
||||
.PARAMETER Profile
|
||||
Which simulator profile to run: 'standard' or 'dl205'. Both bind TCP 5020 by
|
||||
default so they can't run simultaneously on the same box.
|
||||
|
||||
.PARAMETER HttpPort
|
||||
Port for pymodbus's optional web UI / REST API. Default 8080. Pass 0 to
|
||||
disable (passes --no_http).
|
||||
|
||||
.EXAMPLE
|
||||
.\serve.ps1 -Profile standard
|
||||
Starts the standard server on TCP 5020 with web UI on 8080.
|
||||
|
||||
.EXAMPLE
|
||||
.\serve.ps1 -Profile dl205 -HttpPort 0
|
||||
Starts the DL205 server on TCP 5020, no web UI.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [ValidateSet('standard', 'dl205', 's7_1500', 'mitsubishi')] [string]$Profile,
|
||||
[int]$HttpPort = 8080
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$here = $PSScriptRoot
|
||||
|
||||
# Confirm pymodbus.simulator is on PATH — clearer message than the
|
||||
# 'CommandNotFoundException' dotnet style.
|
||||
$cmd = Get-Command pymodbus.simulator -ErrorAction SilentlyContinue
|
||||
if (-not $cmd) {
|
||||
Write-Error "pymodbus.simulator not found. Install with: pip install 'pymodbus[simulator]==3.13.0'"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$jsonFile = Join-Path $here "$Profile.json"
|
||||
if (-not (Test-Path $jsonFile)) {
|
||||
Write-Error "Profile config not found: $jsonFile"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$args = @(
|
||||
'--modbus_server', 'srv',
|
||||
'--modbus_device', 'dev',
|
||||
'--json_file', $jsonFile
|
||||
)
|
||||
|
||||
if ($HttpPort -gt 0) {
|
||||
$args += @('--http_port', $HttpPort)
|
||||
Write-Host "Web UI will be at http://localhost:$HttpPort"
|
||||
} else {
|
||||
$args += '--no_http'
|
||||
}
|
||||
|
||||
Write-Host "Starting pymodbus simulator: profile=$Profile TCP=localhost:5020"
|
||||
Write-Host "Ctrl+C to stop."
|
||||
& pymodbus.simulator @args
|
||||
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"_comment": "Standard.json — generic Modbus TCP server for the integration suite. See ../README.md. NOTE: pymodbus rejects unknown keys at device-list / setup level; explanatory comments live in the README + git history. Layout: HR[0..31]=address-as-value, HR[100]=auto-increment, HR[200..209]=scratch, coils 1024..1055=alternating, coils 1100..1109=scratch. Coils live at 1024+ because pymodbus stores all 4 standard tables in ONE underlying cell array — bits and uint16 at the same address conflict (each cell can only be typed once).",
|
||||
|
||||
"server_list": {
|
||||
"srv": {
|
||||
"comm": "tcp",
|
||||
"host": "0.0.0.0",
|
||||
"port": 5020,
|
||||
"framer": "socket",
|
||||
"device_id": 1
|
||||
}
|
||||
},
|
||||
|
||||
"device_list": {
|
||||
"dev": {
|
||||
"setup": {
|
||||
"co size": 2048,
|
||||
"di size": 2048,
|
||||
"hr size": 2048,
|
||||
"ir size": 2048,
|
||||
"shared blocks": true,
|
||||
"type exception": false,
|
||||
"defaults": {
|
||||
"value": {"bits": 0, "uint16": 0, "uint32": 0, "float32": 0.0, "string": " "},
|
||||
"action": {"bits": null, "uint16": null, "uint32": null, "float32": null, "string": null}
|
||||
}
|
||||
},
|
||||
"invalid": [],
|
||||
"write": [
|
||||
[0, 31],
|
||||
[100, 100],
|
||||
[200, 209],
|
||||
[1024, 1055],
|
||||
[1100, 1109]
|
||||
],
|
||||
|
||||
"uint16": [
|
||||
{"addr": 0, "value": 0}, {"addr": 1, "value": 1},
|
||||
{"addr": 2, "value": 2}, {"addr": 3, "value": 3},
|
||||
{"addr": 4, "value": 4}, {"addr": 5, "value": 5},
|
||||
{"addr": 6, "value": 6}, {"addr": 7, "value": 7},
|
||||
{"addr": 8, "value": 8}, {"addr": 9, "value": 9},
|
||||
{"addr": 10, "value": 10}, {"addr": 11, "value": 11},
|
||||
{"addr": 12, "value": 12}, {"addr": 13, "value": 13},
|
||||
{"addr": 14, "value": 14}, {"addr": 15, "value": 15},
|
||||
{"addr": 16, "value": 16}, {"addr": 17, "value": 17},
|
||||
{"addr": 18, "value": 18}, {"addr": 19, "value": 19},
|
||||
{"addr": 20, "value": 20}, {"addr": 21, "value": 21},
|
||||
{"addr": 22, "value": 22}, {"addr": 23, "value": 23},
|
||||
{"addr": 24, "value": 24}, {"addr": 25, "value": 25},
|
||||
{"addr": 26, "value": 26}, {"addr": 27, "value": 27},
|
||||
{"addr": 28, "value": 28}, {"addr": 29, "value": 29},
|
||||
{"addr": 30, "value": 30}, {"addr": 31, "value": 31},
|
||||
|
||||
{"addr": 100, "value": 0,
|
||||
"action": "increment",
|
||||
"parameters": {"minval": 0, "maxval": 65535}},
|
||||
|
||||
{"addr": 200, "value": 0}, {"addr": 201, "value": 0},
|
||||
{"addr": 202, "value": 0}, {"addr": 203, "value": 0},
|
||||
{"addr": 204, "value": 0}, {"addr": 205, "value": 0},
|
||||
{"addr": 206, "value": 0}, {"addr": 207, "value": 0},
|
||||
{"addr": 208, "value": 0}, {"addr": 209, "value": 0}
|
||||
],
|
||||
|
||||
"bits": [
|
||||
{"addr": 1024, "value": 1}, {"addr": 1025, "value": 0},
|
||||
{"addr": 1026, "value": 1}, {"addr": 1027, "value": 0},
|
||||
{"addr": 1028, "value": 1}, {"addr": 1029, "value": 0},
|
||||
{"addr": 1030, "value": 1}, {"addr": 1031, "value": 0},
|
||||
{"addr": 1032, "value": 1}, {"addr": 1033, "value": 0},
|
||||
{"addr": 1034, "value": 1}, {"addr": 1035, "value": 0},
|
||||
{"addr": 1036, "value": 1}, {"addr": 1037, "value": 0},
|
||||
{"addr": 1038, "value": 1}, {"addr": 1039, "value": 0},
|
||||
{"addr": 1040, "value": 1}, {"addr": 1041, "value": 0},
|
||||
{"addr": 1042, "value": 1}, {"addr": 1043, "value": 0},
|
||||
{"addr": 1044, "value": 1}, {"addr": 1045, "value": 0},
|
||||
{"addr": 1046, "value": 1}, {"addr": 1047, "value": 0},
|
||||
{"addr": 1048, "value": 1}, {"addr": 1049, "value": 0},
|
||||
{"addr": 1050, "value": 1}, {"addr": 1051, "value": 0},
|
||||
{"addr": 1052, "value": 1}, {"addr": 1053, "value": 0},
|
||||
{"addr": 1054, "value": 1}, {"addr": 1055, "value": 0},
|
||||
|
||||
{"addr": 1100, "value": 0}, {"addr": 1101, "value": 0},
|
||||
{"addr": 1102, "value": 0}, {"addr": 1103, "value": 0},
|
||||
{"addr": 1104, "value": 0}, {"addr": 1105, "value": 0},
|
||||
{"addr": 1106, "value": 0}, {"addr": 1107, "value": 0},
|
||||
{"addr": 1108, "value": 0}, {"addr": 1109, "value": 0}
|
||||
],
|
||||
|
||||
"uint32": [],
|
||||
"float32": [],
|
||||
"string": [],
|
||||
"repeat": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.S7;
|
||||
|
||||
/// <summary>
|
||||
/// Tag map for the Siemens SIMATIC S7-1500 device class with the <c>MB_SERVER</c> library
|
||||
/// block mapping HR[0..] to DB1.DBW0+. Mirrors <c>s7_1500.json</c> in <c>Pymodbus/</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Unlike DL205, S7 has no fixed Modbus memory map — every site wires MB_SERVER to a
|
||||
/// different DB. The profile here models the *default* user layout documented in
|
||||
/// <c>docs/v2/s7.md</c> §per-model-matrix: DB1.DBW0 = fingerprint marker, a scratch HR
|
||||
/// range 200..209 for write-roundtrip tests, and ABCD-order Float32 / Int32 markers at
|
||||
/// HR[100..101] and HR[300..301] to prove the driver's S7 profile default is correct.
|
||||
/// </remarks>
|
||||
public static class S7_1500Profile
|
||||
{
|
||||
/// <summary>
|
||||
/// Scratch HR the smoke test writes + reads. Address 200 mirrors the DL205 /
|
||||
/// standard scratch range so one smoke test pattern works across all device profiles.
|
||||
/// </summary>
|
||||
public const ushort SmokeHoldingRegister = 200;
|
||||
|
||||
/// <summary>Value the smoke test writes then reads back.</summary>
|
||||
public const short SmokeHoldingValue = 4321;
|
||||
|
||||
public static ModbusDriverOptions BuildOptions(string host, int port) => new()
|
||||
{
|
||||
Host = host,
|
||||
Port = port,
|
||||
UnitId = 1,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Tags =
|
||||
[
|
||||
new ModbusTagDefinition(
|
||||
Name: "Smoke_HReg200",
|
||||
Region: ModbusRegion.HoldingRegisters,
|
||||
Address: SmokeHoldingRegister,
|
||||
DataType: ModbusDataType.Int16,
|
||||
Writable: true),
|
||||
],
|
||||
// Disable the background probe loop — integration tests drive reads explicitly and
|
||||
// the probe would race with assertions.
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.S7;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end smoke against the S7-1500 <c>MB_SERVER</c> pymodbus profile (or a real
|
||||
/// S7-1500 + MB_SERVER deployment when <c>MODBUS_SIM_ENDPOINT</c> points at one). Drives
|
||||
/// the full <see cref="ModbusDriver"/> + real <see cref="ModbusTcpTransport"/> stack —
|
||||
/// no fake transport. Success proves the driver initializes against the S7 simulator,
|
||||
/// writes a known value, and reads it back with the correct status and value, which is
|
||||
/// the baseline every S7-specific test (PR 57+) builds on.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// S7-specific quirk tests (MB_SERVER requires non-optimized DBs, ABCD word order
|
||||
/// default, port-per-connection, FC23 Illegal Function, STOP-mode behaviour, etc.) land
|
||||
/// as separate test classes in this directory as each quirk is validated in pymodbus.
|
||||
/// Keep this smoke test deliberately narrow — filtering by device class
|
||||
/// (<c>--filter DisplayName~S7</c>) should surface the quirk-specific failure mode when
|
||||
/// something goes wrong, not a blanket smoke failure that could mean anything.
|
||||
/// </remarks>
|
||||
[Collection(ModbusSimulatorCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "S7")]
|
||||
public sealed class S7_1500SmokeTests(ModbusSimulatorFixture sim)
|
||||
{
|
||||
[Fact]
|
||||
public async Task S7_1500_roundtrip_write_then_read_of_holding_register()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "s7_1500",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Assert.Skip("MODBUS_SIM_PROFILE != s7_1500 — skipping (other profiles don't seed the S7 scratch range identically).");
|
||||
}
|
||||
|
||||
var options = S7_1500Profile.BuildOptions(sim.Host, sim.Port);
|
||||
await using var driver = new ModbusDriver(options, driverInstanceId: "s7-smoke");
|
||||
await driver.InitializeAsync(driverConfigJson: "{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var writeResults = await driver.WriteAsync(
|
||||
[new(FullReference: "Smoke_HReg200", Value: (short)S7_1500Profile.SmokeHoldingValue)],
|
||||
TestContext.Current.CancellationToken);
|
||||
writeResults.Count.ShouldBe(1);
|
||||
writeResults[0].StatusCode.ShouldBe(0u, "write must succeed against the S7-1500 MB_SERVER profile");
|
||||
|
||||
var readResults = await driver.ReadAsync(
|
||||
["Smoke_HReg200"],
|
||||
TestContext.Current.CancellationToken);
|
||||
readResults.Count.ShouldBe(1);
|
||||
readResults[0].StatusCode.ShouldBe(0u);
|
||||
readResults[0].Value.ShouldBe((short)S7_1500Profile.SmokeHoldingValue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.S7;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the Siemens S7 big-endian (<c>ABCD</c>) word-order default for Float32 and
|
||||
/// Int32 against the <c>s7_1500.json</c> pymodbus profile. S7's native CPU types are
|
||||
/// big-endian end-to-end, so <c>MB_SERVER</c> places the high word at the lower register
|
||||
/// address — <b>opposite</b> of DL260's CDAB. The driver's S7-family tag config must
|
||||
/// therefore default to <see cref="ModbusByteOrder.BigEndian"/>; selecting
|
||||
/// <see cref="ModbusByteOrder.WordSwap"/> against an S7 would decode garbage.
|
||||
/// </summary>
|
||||
[Collection(ModbusSimulatorCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "S7")]
|
||||
public sealed class S7_ByteOrderTests(ModbusSimulatorFixture sim)
|
||||
{
|
||||
[Fact]
|
||||
public async Task S7_Float32_ABCD_decodes_1_5f_from_HR100()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "s7_1500",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Assert.Skip("MODBUS_SIM_PROFILE != s7_1500 — skipping (s7_1500 profile is the only one seeding HR[100..101] ABCD).");
|
||||
}
|
||||
|
||||
var options = new ModbusDriverOptions
|
||||
{
|
||||
Host = sim.Host,
|
||||
Port = sim.Port,
|
||||
UnitId = 1,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Tags =
|
||||
[
|
||||
new ModbusTagDefinition("S7_Float_ABCD",
|
||||
ModbusRegion.HoldingRegisters, Address: 100,
|
||||
DataType: ModbusDataType.Float32, Writable: false,
|
||||
ByteOrder: ModbusByteOrder.BigEndian),
|
||||
// Control: same address with WordSwap should decode garbage — proves the
|
||||
// two code paths diverge on S7 wire bytes.
|
||||
new ModbusTagDefinition("S7_Float_CDAB_control",
|
||||
ModbusRegion.HoldingRegisters, Address: 100,
|
||||
DataType: ModbusDataType.Float32, Writable: false,
|
||||
ByteOrder: ModbusByteOrder.WordSwap),
|
||||
],
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
await using var driver = new ModbusDriver(options, driverInstanceId: "s7-float-abcd");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await driver.ReadAsync(
|
||||
["S7_Float_ABCD", "S7_Float_CDAB_control"],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
results[0].Value.ShouldBe(1.5f, "S7 MB_SERVER stores Float32 in ABCD word order; BigEndian decode returns 1.5f");
|
||||
|
||||
results[1].StatusCode.ShouldBe(0u);
|
||||
results[1].Value.ShouldNotBe(1.5f, "applying CDAB swap to S7 ABCD bytes must produce a different value — confirms the flag is not a no-op and S7 profile default must be BigEndian");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task S7_Int32_ABCD_decodes_0x12345678_from_HR300()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "s7_1500",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Assert.Skip("MODBUS_SIM_PROFILE != s7_1500 — skipping.");
|
||||
}
|
||||
|
||||
var options = new ModbusDriverOptions
|
||||
{
|
||||
Host = sim.Host,
|
||||
Port = sim.Port,
|
||||
UnitId = 1,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Tags =
|
||||
[
|
||||
new ModbusTagDefinition("S7_Int32_ABCD",
|
||||
ModbusRegion.HoldingRegisters, Address: 300,
|
||||
DataType: ModbusDataType.Int32, Writable: false,
|
||||
ByteOrder: ModbusByteOrder.BigEndian),
|
||||
],
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
await using var driver = new ModbusDriver(options, driverInstanceId: "s7-int-abcd");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await driver.ReadAsync(["S7_Int32_ABCD"], TestContext.Current.CancellationToken);
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
results[0].Value.ShouldBe(0x12345678,
|
||||
"S7 Int32 stored as HR[300]=0x1234, HR[301]=0x5678 with ABCD order decodes to 0x12345678 — DL260 would store the reverse order");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task S7_DB1_fingerprint_marker_at_HR0_reads_0xABCD()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "s7_1500",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Assert.Skip("MODBUS_SIM_PROFILE != s7_1500 — skipping.");
|
||||
}
|
||||
|
||||
// Real-world MB_SERVER deployments typically reserve DB1.DBW0 as a fingerprint so
|
||||
// clients can verify they're pointing at the right DB (protects against typos in
|
||||
// the MB_SERVER.MB_HOLD_REG.DB_number parameter). 0xABCD is the convention.
|
||||
var options = new ModbusDriverOptions
|
||||
{
|
||||
Host = sim.Host,
|
||||
Port = sim.Port,
|
||||
UnitId = 1,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Tags =
|
||||
[
|
||||
new ModbusTagDefinition("S7_Fingerprint",
|
||||
ModbusRegion.HoldingRegisters, Address: 0,
|
||||
DataType: ModbusDataType.UInt16, Writable: false),
|
||||
],
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
await using var driver = new ModbusDriver(options, driverInstanceId: "s7-fingerprint");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await driver.ReadAsync(["S7_Fingerprint"], TestContext.Current.CancellationToken);
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
results[0].Value.ShouldBe((ushort)0xABCD);
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,10 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="ModbusPal\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
||||
<None Update="Pymodbus\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
||||
<None Update="DL205\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
||||
<None Update="S7\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
||||
<None Update="Mitsubishi\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DirectLogicAddressTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("V0", (ushort)0x0000)]
|
||||
[InlineData("V1", (ushort)0x0001)]
|
||||
[InlineData("V7", (ushort)0x0007)]
|
||||
[InlineData("V10", (ushort)0x0008)]
|
||||
[InlineData("V2000", (ushort)0x0400)] // canonical DL205/DL260 user-memory start
|
||||
[InlineData("V7777", (ushort)0x0FFF)]
|
||||
[InlineData("V10000", (ushort)0x1000)]
|
||||
[InlineData("V17777", (ushort)0x1FFF)]
|
||||
public void UserVMemoryToPdu_converts_octal_V_prefix(string v, ushort expected)
|
||||
=> DirectLogicAddress.UserVMemoryToPdu(v).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData("0", (ushort)0)]
|
||||
[InlineData("2000", (ushort)0x0400)]
|
||||
[InlineData("v2000", (ushort)0x0400)] // lowercase v
|
||||
[InlineData(" V2000 ", (ushort)0x0400)] // surrounding whitespace
|
||||
public void UserVMemoryToPdu_accepts_bare_or_prefixed_or_padded(string v, ushort expected)
|
||||
=> DirectLogicAddress.UserVMemoryToPdu(v).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData("V8")] // 8 is not a valid octal digit
|
||||
[InlineData("V19")]
|
||||
[InlineData("V2009")]
|
||||
public void UserVMemoryToPdu_rejects_non_octal_digits(string v)
|
||||
{
|
||||
Should.Throw<ArgumentException>(() => DirectLogicAddress.UserVMemoryToPdu(v))
|
||||
.Message.ShouldContain("octal");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("V")]
|
||||
public void UserVMemoryToPdu_rejects_empty_input(string? v)
|
||||
=> Should.Throw<ArgumentException>(() => DirectLogicAddress.UserVMemoryToPdu(v!));
|
||||
|
||||
[Fact]
|
||||
public void UserVMemoryToPdu_overflow_rejected()
|
||||
{
|
||||
// 200000 octal = 0x10000 — one past ushort range.
|
||||
Should.Throw<OverflowException>(() => DirectLogicAddress.UserVMemoryToPdu("V200000"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SystemVMemoryBasePdu_is_0x2100_for_V40400()
|
||||
{
|
||||
// V40400 on DL260 / H2-ECOM100 absolute mode → PDU 0x2100 (decimal 8448), NOT 0x4100
|
||||
// which a naive octal-to-decimal of 40400 octal would give (= 16640).
|
||||
DirectLogicAddress.SystemVMemoryBasePdu.ShouldBe((ushort)0x2100);
|
||||
DirectLogicAddress.SystemVMemoryToPdu(0).ShouldBe((ushort)0x2100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SystemVMemoryToPdu_offsets_within_bank()
|
||||
{
|
||||
DirectLogicAddress.SystemVMemoryToPdu(1).ShouldBe((ushort)0x2101);
|
||||
DirectLogicAddress.SystemVMemoryToPdu(0x100).ShouldBe((ushort)0x2200);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SystemVMemoryToPdu_rejects_overflow()
|
||||
{
|
||||
// ushort wrap: 0xFFFF - 0x2100 = 0xDEFF; anything above should throw.
|
||||
Should.NotThrow(() => DirectLogicAddress.SystemVMemoryToPdu(0xDEFF));
|
||||
Should.Throw<OverflowException>(() => DirectLogicAddress.SystemVMemoryToPdu(0xDF00));
|
||||
}
|
||||
|
||||
// --- Bit memory: Y-output, C-relay, X-input, SP-special ---
|
||||
|
||||
[Theory]
|
||||
[InlineData("Y0", (ushort)2048)]
|
||||
[InlineData("Y1", (ushort)2049)]
|
||||
[InlineData("Y7", (ushort)2055)]
|
||||
[InlineData("Y10", (ushort)2056)] // octal 10 = decimal 8
|
||||
[InlineData("Y17", (ushort)2063)] // octal 17 = decimal 15
|
||||
[InlineData("Y777", (ushort)2559)] // top of DL260 Y range per doc table
|
||||
public void YOutputToCoil_adds_octal_offset_to_2048(string y, ushort expected)
|
||||
=> DirectLogicAddress.YOutputToCoil(y).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData("C0", (ushort)3072)]
|
||||
[InlineData("C1", (ushort)3073)]
|
||||
[InlineData("C10", (ushort)3080)]
|
||||
[InlineData("C1777", (ushort)4095)] // top of DL260 C range
|
||||
public void CRelayToCoil_adds_octal_offset_to_3072(string c, ushort expected)
|
||||
=> DirectLogicAddress.CRelayToCoil(c).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData("X0", (ushort)0)]
|
||||
[InlineData("X17", (ushort)15)]
|
||||
[InlineData("X777", (ushort)511)] // top of DL260 X range
|
||||
public void XInputToDiscrete_adds_octal_offset_to_0(string x, ushort expected)
|
||||
=> DirectLogicAddress.XInputToDiscrete(x).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData("SP0", (ushort)1024)]
|
||||
[InlineData("SP7", (ushort)1031)]
|
||||
[InlineData("sp0", (ushort)1024)] // lowercase prefix
|
||||
[InlineData("SP777", (ushort)1535)]
|
||||
public void SpecialToDiscrete_adds_octal_offset_to_1024(string sp, ushort expected)
|
||||
=> DirectLogicAddress.SpecialToDiscrete(sp).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData("Y8")]
|
||||
[InlineData("C9")]
|
||||
[InlineData("X18")]
|
||||
public void Bit_address_rejects_non_octal_digits(string bad)
|
||||
=> Should.Throw<ArgumentException>(() =>
|
||||
{
|
||||
if (bad[0] == 'Y') DirectLogicAddress.YOutputToCoil(bad);
|
||||
else if (bad[0] == 'C') DirectLogicAddress.CRelayToCoil(bad);
|
||||
else DirectLogicAddress.XInputToDiscrete(bad);
|
||||
});
|
||||
|
||||
[Theory]
|
||||
[InlineData("Y")]
|
||||
[InlineData("C")]
|
||||
[InlineData("")]
|
||||
public void Bit_address_rejects_empty(string bad)
|
||||
=> Should.Throw<ArgumentException>(() => DirectLogicAddress.YOutputToCoil(bad));
|
||||
|
||||
[Fact]
|
||||
public void YOutputToCoil_accepts_lowercase_prefix()
|
||||
=> DirectLogicAddress.YOutputToCoil("y0").ShouldBe((ushort)2048);
|
||||
|
||||
[Fact]
|
||||
public void CRelayToCoil_accepts_bare_octal_without_C_prefix()
|
||||
=> DirectLogicAddress.CRelayToCoil("0").ShouldBe((ushort)3072);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class MelsecAddressTests
|
||||
{
|
||||
// --- X / Y hex vs octal family trap ---
|
||||
|
||||
[Theory]
|
||||
[InlineData("X0", (ushort)0)]
|
||||
[InlineData("X9", (ushort)9)]
|
||||
[InlineData("XA", (ushort)10)] // hex
|
||||
[InlineData("XF", (ushort)15)]
|
||||
[InlineData("X10", (ushort)16)] // hex 0x10 = decimal 16
|
||||
[InlineData("X20", (ushort)32)] // hex 0x20 = decimal 32 — the classic MELSEC-Q trap
|
||||
[InlineData("X1FF", (ushort)511)]
|
||||
[InlineData("x10", (ushort)16)] // lowercase prefix
|
||||
public void XInputToDiscrete_QLiQR_parses_hex(string x, ushort expected)
|
||||
=> MelsecAddress.XInputToDiscrete(x, MelsecFamily.Q_L_iQR).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData("X0", (ushort)0)]
|
||||
[InlineData("X7", (ushort)7)]
|
||||
[InlineData("X10", (ushort)8)] // octal 10 = decimal 8
|
||||
[InlineData("X20", (ushort)16)] // octal 20 = decimal 16 — SAME string, DIFFERENT value on FX
|
||||
[InlineData("X777", (ushort)511)]
|
||||
public void XInputToDiscrete_FiQF_parses_octal(string x, ushort expected)
|
||||
=> MelsecAddress.XInputToDiscrete(x, MelsecFamily.F_iQF).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData("Y0", (ushort)0)]
|
||||
[InlineData("Y1F", (ushort)31)]
|
||||
public void YOutputToCoil_QLiQR_parses_hex(string y, ushort expected)
|
||||
=> MelsecAddress.YOutputToCoil(y, MelsecFamily.Q_L_iQR).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData("Y0", (ushort)0)]
|
||||
[InlineData("Y17", (ushort)15)]
|
||||
public void YOutputToCoil_FiQF_parses_octal(string y, ushort expected)
|
||||
=> MelsecAddress.YOutputToCoil(y, MelsecFamily.F_iQF).ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public void Same_address_string_decodes_differently_between_families()
|
||||
{
|
||||
// This is the headline quirk: "X20" in GX Works means one thing on Q-series and
|
||||
// another on FX-series. The driver's family selector is the only defence.
|
||||
MelsecAddress.XInputToDiscrete("X20", MelsecFamily.Q_L_iQR).ShouldBe((ushort)32);
|
||||
MelsecAddress.XInputToDiscrete("X20", MelsecFamily.F_iQF).ShouldBe((ushort)16);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("X8")] // 8 is non-octal
|
||||
[InlineData("X12G")] // G is non-hex
|
||||
public void XInputToDiscrete_FiQF_rejects_non_octal(string bad)
|
||||
=> Should.Throw<ArgumentException>(() => MelsecAddress.XInputToDiscrete(bad, MelsecFamily.F_iQF));
|
||||
|
||||
[Theory]
|
||||
[InlineData("X12G")]
|
||||
public void XInputToDiscrete_QLiQR_rejects_non_hex(string bad)
|
||||
=> Should.Throw<ArgumentException>(() => MelsecAddress.XInputToDiscrete(bad, MelsecFamily.Q_L_iQR));
|
||||
|
||||
[Fact]
|
||||
public void XInputToDiscrete_honors_bank_base_from_assignment_block()
|
||||
{
|
||||
// Real-world QJ71MT91 assignment blocks commonly place X at DI 8192+ when other
|
||||
// ranges take the low Modbus addresses. Helper must add the base cleanly.
|
||||
MelsecAddress.XInputToDiscrete("X10", MelsecFamily.Q_L_iQR, xBankBase: 8192).ShouldBe((ushort)(8192 + 16));
|
||||
}
|
||||
|
||||
// --- M-relay (decimal, both families) ---
|
||||
|
||||
[Theory]
|
||||
[InlineData("M0", (ushort)0)]
|
||||
[InlineData("M10", (ushort)10)] // M addresses are DECIMAL, not hex or octal
|
||||
[InlineData("M511", (ushort)511)]
|
||||
[InlineData("m99", (ushort)99)] // lowercase
|
||||
public void MRelayToCoil_parses_decimal(string m, ushort expected)
|
||||
=> MelsecAddress.MRelayToCoil(m).ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public void MRelayToCoil_honors_bank_base()
|
||||
=> MelsecAddress.MRelayToCoil("M0", mBankBase: 512).ShouldBe((ushort)512);
|
||||
|
||||
[Fact]
|
||||
public void MRelayToCoil_rejects_non_numeric()
|
||||
=> Should.Throw<ArgumentException>(() => MelsecAddress.MRelayToCoil("M1F"));
|
||||
|
||||
// --- D-register (decimal, both families) ---
|
||||
|
||||
[Theory]
|
||||
[InlineData("D0", (ushort)0)]
|
||||
[InlineData("D100", (ushort)100)]
|
||||
[InlineData("d1023", (ushort)1023)]
|
||||
public void DRegisterToHolding_parses_decimal(string d, ushort expected)
|
||||
=> MelsecAddress.DRegisterToHolding(d).ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public void DRegisterToHolding_honors_bank_base()
|
||||
=> MelsecAddress.DRegisterToHolding("D10", dBankBase: 4096).ShouldBe((ushort)4106);
|
||||
|
||||
[Fact]
|
||||
public void DRegisterToHolding_rejects_empty()
|
||||
=> Should.Throw<ArgumentException>(() => MelsecAddress.DRegisterToHolding("D"));
|
||||
|
||||
// --- overflow ---
|
||||
|
||||
[Fact]
|
||||
public void XInputToDiscrete_overflow_throws()
|
||||
{
|
||||
// 0xFFFF + base 1 = 0x10000 — past ushort.
|
||||
Should.Throw<OverflowException>(() =>
|
||||
MelsecAddress.XInputToDiscrete("XFFFF", MelsecFamily.Q_L_iQR, xBankBase: 1));
|
||||
}
|
||||
}
|
||||
165
tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusCapTests.cs
Normal file
165
tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusCapTests.cs
Normal file
@@ -0,0 +1,165 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusCapTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Records every PDU sent so tests can assert request-count and per-request quantity —
|
||||
/// the only observable behaviour of the auto-chunking path.
|
||||
/// </summary>
|
||||
private sealed class RecordingTransport : IModbusTransport
|
||||
{
|
||||
public readonly ushort[] HoldingRegisters = new ushort[1024];
|
||||
public readonly List<(ushort Address, ushort Quantity)> Fc03Requests = new();
|
||||
public readonly List<(ushort Address, ushort Quantity)> Fc16Requests = new();
|
||||
|
||||
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
var fc = pdu[0];
|
||||
if (fc == 0x03)
|
||||
{
|
||||
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
Fc03Requests.Add((addr, qty));
|
||||
var byteCount = (byte)(qty * 2);
|
||||
var resp = new byte[2 + byteCount];
|
||||
resp[0] = 0x03;
|
||||
resp[1] = byteCount;
|
||||
for (var i = 0; i < qty; i++)
|
||||
{
|
||||
resp[2 + i * 2] = (byte)(HoldingRegisters[addr + i] >> 8);
|
||||
resp[3 + i * 2] = (byte)(HoldingRegisters[addr + i] & 0xFF);
|
||||
}
|
||||
return Task.FromResult(resp);
|
||||
}
|
||||
if (fc == 0x10)
|
||||
{
|
||||
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
Fc16Requests.Add((addr, qty));
|
||||
for (var i = 0; i < qty; i++)
|
||||
HoldingRegisters[addr + i] = (ushort)((pdu[6 + i * 2] << 8) | pdu[7 + i * 2]);
|
||||
return Task.FromResult(new byte[] { 0x10, pdu[1], pdu[2], pdu[3], pdu[4] });
|
||||
}
|
||||
return Task.FromException<byte[]>(new ModbusException(fc, 0x01, $"fc={fc} unsupported"));
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_within_cap_issues_single_FC03_request()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("S", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||
StringLength: 40); // 20 regs — fits in default cap (125).
|
||||
var transport = new RecordingTransport();
|
||||
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], Probe = new ModbusProbeOptions { Enabled = false } };
|
||||
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
_ = await drv.ReadAsync(["S"], TestContext.Current.CancellationToken);
|
||||
|
||||
transport.Fc03Requests.Count.ShouldBe(1);
|
||||
transport.Fc03Requests[0].Quantity.ShouldBe((ushort)20);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_above_cap_splits_into_two_FC03_requests()
|
||||
{
|
||||
// 240-char string = 120 regs. Cap = 100 (a typical sub-spec device cap). Expect 100 + 20.
|
||||
var tag = new ModbusTagDefinition("LongString", ModbusRegion.HoldingRegisters, 100, ModbusDataType.String,
|
||||
StringLength: 240);
|
||||
var transport = new RecordingTransport();
|
||||
// Seed cells so the re-assembled payload is stable — confirms chunks are stitched in order.
|
||||
for (ushort i = 100; i < 100 + 120; i++)
|
||||
transport.HoldingRegisters[i] = (ushort)((('A' + (i - 100) % 26) << 8) | ('A' + (i - 100) % 26));
|
||||
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = "fake",
|
||||
Tags = [tag],
|
||||
MaxRegistersPerRead = 100,
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await drv.ReadAsync(["LongString"], TestContext.Current.CancellationToken);
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
|
||||
transport.Fc03Requests.Count.ShouldBe(2, "120 regs / cap 100 → 2 requests");
|
||||
transport.Fc03Requests[0].ShouldBe(((ushort)100, (ushort)100));
|
||||
transport.Fc03Requests[1].ShouldBe(((ushort)200, (ushort)20));
|
||||
|
||||
// Payload continuity: re-assembled string starts where register 100 does and keeps going.
|
||||
var s = (string)results[0].Value!;
|
||||
s.Length.ShouldBeGreaterThan(0);
|
||||
s[0].ShouldBe('A'); // register[100] high byte
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_cap_honors_Mitsubishi_lower_cap_of_64()
|
||||
{
|
||||
// 200-char string = 100 regs. Mitsubishi Q cap = 64. Expect: 64, 36.
|
||||
var tag = new ModbusTagDefinition("MitString", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||
StringLength: 200);
|
||||
var transport = new RecordingTransport();
|
||||
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], MaxRegistersPerRead = 64, Probe = new ModbusProbeOptions { Enabled = false } };
|
||||
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
_ = await drv.ReadAsync(["MitString"], TestContext.Current.CancellationToken);
|
||||
|
||||
transport.Fc03Requests.Count.ShouldBe(2);
|
||||
transport.Fc03Requests[0].Quantity.ShouldBe((ushort)64);
|
||||
transport.Fc03Requests[1].Quantity.ShouldBe((ushort)36);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_exceeding_cap_throws_instead_of_splitting()
|
||||
{
|
||||
// Partial FC16 across two transactions is not atomic. Forcing an explicit exception so the
|
||||
// caller knows their tag definition is incompatible with the device cap rather than silently
|
||||
// writing half a string and crashing between chunks.
|
||||
var tag = new ModbusTagDefinition("LongStringWrite", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||
StringLength: 220); // 110 regs.
|
||||
var transport = new RecordingTransport();
|
||||
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], MaxRegistersPerWrite = 100, Probe = new ModbusProbeOptions { Enabled = false } };
|
||||
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("LongStringWrite", new string('A', 220))],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Driver catches the internal exception and surfaces BadInternalError — the Fc16Requests
|
||||
// list must still be empty because nothing was sent.
|
||||
results[0].StatusCode.ShouldNotBe(0u);
|
||||
transport.Fc16Requests.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_within_cap_proceeds_normally()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("ShortStringWrite", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||
StringLength: 40); // 20 regs.
|
||||
var transport = new RecordingTransport();
|
||||
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], MaxRegistersPerWrite = 100, Probe = new ModbusProbeOptions { Enabled = false } };
|
||||
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("ShortStringWrite", "HELLO")],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
transport.Fc16Requests.Count.ShouldBe(1);
|
||||
transport.Fc16Requests[0].Quantity.ShouldBe((ushort)20);
|
||||
}
|
||||
}
|
||||
@@ -172,4 +172,144 @@ public sealed class ModbusDataTypeTests
|
||||
wire[1].ShouldBe((byte)'i');
|
||||
for (var i = 2; i < 8; i++) wire[i].ShouldBe((byte)0);
|
||||
}
|
||||
|
||||
// --- DL205 low-byte-first strings (AutomationDirect DirectLOGIC quirk) ---
|
||||
|
||||
[Fact]
|
||||
public void String_LowByteFirst_decodes_DL205_packed_Hello()
|
||||
{
|
||||
// HR[1040] = 0x6548 (wire BE bytes [0x65, 0x48]) decodes first char from low byte = 'H',
|
||||
// second from high byte = 'e'. HR[1041] = 0x6C6C → 'l','l'. HR[1042] = 0x006F → 'o', nul.
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||
StringLength: 5, StringByteOrder: ModbusStringByteOrder.LowByteFirst);
|
||||
var wire = new byte[] { 0x65, 0x48, 0x6C, 0x6C, 0x00, 0x6F };
|
||||
ModbusDriver.DecodeRegister(wire, tag).ShouldBe("Hello");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void String_LowByteFirst_decode_truncates_at_first_nul()
|
||||
{
|
||||
// Low-byte-first with only 2 real chars in register 0 (lo='H', hi='i') and the rest nul.
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||
StringLength: 6, StringByteOrder: ModbusStringByteOrder.LowByteFirst);
|
||||
var wire = new byte[] { 0x69, 0x48, 0x00, 0x00, 0x00, 0x00 };
|
||||
ModbusDriver.DecodeRegister(wire, tag).ShouldBe("Hi");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void String_LowByteFirst_encode_round_trips_with_decode()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||
StringLength: 5, StringByteOrder: ModbusStringByteOrder.LowByteFirst);
|
||||
var wire = ModbusDriver.EncodeRegister("Hello", tag);
|
||||
// Expect exactly the DL205-documented byte sequence.
|
||||
wire.ShouldBe(new byte[] { 0x65, 0x48, 0x6C, 0x6C, 0x00, 0x6F });
|
||||
ModbusDriver.DecodeRegister(wire, tag).ShouldBe("Hello");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void String_HighByteFirst_and_LowByteFirst_differ_on_same_wire()
|
||||
{
|
||||
// Same wire buffer, different byte order → first char switches 'H' vs 'e'.
|
||||
var wire = new byte[] { 0x48, 0x65 };
|
||||
var hi = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||
StringLength: 2, StringByteOrder: ModbusStringByteOrder.HighByteFirst);
|
||||
var lo = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||
StringLength: 2, StringByteOrder: ModbusStringByteOrder.LowByteFirst);
|
||||
ModbusDriver.DecodeRegister(wire, hi).ShouldBe("He");
|
||||
ModbusDriver.DecodeRegister(wire, lo).ShouldBe("eH");
|
||||
}
|
||||
|
||||
// --- BCD (binary-coded decimal, DL205/DL260 default numeric encoding) ---
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x0000u, 0u)]
|
||||
[InlineData(0x0001u, 1u)]
|
||||
[InlineData(0x0009u, 9u)]
|
||||
[InlineData(0x0010u, 10u)]
|
||||
[InlineData(0x1234u, 1234u)]
|
||||
[InlineData(0x9999u, 9999u)]
|
||||
public void DecodeBcd_16_bit_decodes_expected_decimal(uint raw, uint expected)
|
||||
=> ModbusDriver.DecodeBcd(raw, nibbles: 4).ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public void DecodeBcd_rejects_nibbles_above_nine()
|
||||
{
|
||||
Should.Throw<InvalidDataException>(() => ModbusDriver.DecodeBcd(0x00A5u, nibbles: 4))
|
||||
.Message.ShouldContain("Non-BCD nibble");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0u, 0x0000u)]
|
||||
[InlineData(5u, 0x0005u)]
|
||||
[InlineData(42u, 0x0042u)]
|
||||
[InlineData(1234u, 0x1234u)]
|
||||
[InlineData(9999u, 0x9999u)]
|
||||
public void EncodeBcd_16_bit_encodes_expected_nibbles(uint value, uint expected)
|
||||
=> ModbusDriver.EncodeBcd(value, nibbles: 4).ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public void Bcd16_decodes_DL205_register_1234_as_decimal_1234()
|
||||
{
|
||||
// HR[1072] = 0x1234 on the DL205 profile represents decimal 1234. A plain Int16 decode
|
||||
// would return 0x04D2 = 4660 — proof the BCD path is different.
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Bcd16);
|
||||
ModbusDriver.DecodeRegister(new byte[] { 0x12, 0x34 }, tag).ShouldBe(1234);
|
||||
|
||||
var int16Tag = tag with { DataType = ModbusDataType.Int16 };
|
||||
ModbusDriver.DecodeRegister(new byte[] { 0x12, 0x34 }, int16Tag).ShouldBe((short)0x1234);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bcd16_encode_round_trips_with_decode()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Bcd16);
|
||||
var wire = ModbusDriver.EncodeRegister(4321, tag);
|
||||
wire.ShouldBe(new byte[] { 0x43, 0x21 });
|
||||
ModbusDriver.DecodeRegister(wire, tag).ShouldBe(4321);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bcd16_encode_rejects_out_of_range_values()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Bcd16);
|
||||
Should.Throw<OverflowException>(() => ModbusDriver.EncodeRegister(10000, tag))
|
||||
.Message.ShouldContain("4 decimal digits");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bcd32_decodes_8_digits_big_endian()
|
||||
{
|
||||
// 0x12345678 as BCD = decimal 12_345_678.
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Bcd32);
|
||||
ModbusDriver.DecodeRegister(new byte[] { 0x12, 0x34, 0x56, 0x78 }, tag).ShouldBe(12_345_678);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bcd32_word_swap_handles_CDAB_layout()
|
||||
{
|
||||
// PLC stored 12_345_678 with word swap: low-word 0x5678 first, high-word 0x1234 second.
|
||||
// Wire bytes [0x56, 0x78, 0x12, 0x34] + WordSwap → decode to decimal 12_345_678.
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Bcd32,
|
||||
ByteOrder: ModbusByteOrder.WordSwap);
|
||||
ModbusDriver.DecodeRegister(new byte[] { 0x56, 0x78, 0x12, 0x34 }, tag).ShouldBe(12_345_678);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bcd32_encode_round_trips_with_decode()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Bcd32);
|
||||
var wire = ModbusDriver.EncodeRegister(87_654_321u, tag);
|
||||
wire.ShouldBe(new byte[] { 0x87, 0x65, 0x43, 0x21 });
|
||||
ModbusDriver.DecodeRegister(wire, tag).ShouldBe(87_654_321);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bcd_RegisterCount_matches_underlying_width()
|
||||
{
|
||||
var b16 = new ModbusTagDefinition("A", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Bcd16);
|
||||
var b32 = new ModbusTagDefinition("B", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Bcd32);
|
||||
ModbusDriver.RegisterCount(b16).ShouldBe((ushort)1);
|
||||
ModbusDriver.RegisterCount(b32).ShouldBe((ushort)2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the Modbus-exception-code → OPC UA StatusCode mapping added in PR 52.
|
||||
/// Before PR 52 every server exception + every transport failure collapsed to
|
||||
/// BadInternalError (0x80020000), which made field diagnosis "is this a bad tag or a bad
|
||||
/// driver?" impossible. These tests lock in the translation table documented on
|
||||
/// <see cref="ModbusDriver.MapModbusExceptionToStatus"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusExceptionMapperTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData((byte)0x01, 0x803D0000u)] // Illegal Function → BadNotSupported
|
||||
[InlineData((byte)0x02, 0x803C0000u)] // Illegal Data Address → BadOutOfRange
|
||||
[InlineData((byte)0x03, 0x803C0000u)] // Illegal Data Value → BadOutOfRange
|
||||
[InlineData((byte)0x04, 0x80550000u)] // Server Failure → BadDeviceFailure
|
||||
[InlineData((byte)0x05, 0x80550000u)] // Acknowledge (long op) → BadDeviceFailure
|
||||
[InlineData((byte)0x06, 0x80550000u)] // Server Busy → BadDeviceFailure
|
||||
[InlineData((byte)0x0A, 0x80050000u)] // Gateway path unavailable → BadCommunicationError
|
||||
[InlineData((byte)0x0B, 0x80050000u)] // Gateway target failed to respond → BadCommunicationError
|
||||
[InlineData((byte)0xFF, 0x80020000u)] // Unknown code → BadInternalError fallback
|
||||
public void MapModbusExceptionToStatus_returns_informative_status(byte code, uint expected)
|
||||
=> ModbusDriver.MapModbusExceptionToStatus(code).ShouldBe(expected);
|
||||
|
||||
private sealed class ExceptionRaisingTransport(byte exceptionCode) : IModbusTransport
|
||||
{
|
||||
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
=> Task.FromException<byte[]>(new ModbusException(pdu[0], exceptionCode, $"fc={pdu[0]} code={exceptionCode}"));
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_surface_exception_02_as_BadOutOfRange_not_BadInternalError()
|
||||
{
|
||||
var transport = new ExceptionRaisingTransport(exceptionCode: 0x02);
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16);
|
||||
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], Probe = new ModbusProbeOptions { Enabled = false } };
|
||||
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await drv.ReadAsync(["T"], TestContext.Current.CancellationToken);
|
||||
results[0].StatusCode.ShouldBe(0x803C0000u, "FC03 at an unmapped register must bubble out as BadOutOfRange so operators can spot a bad tag config");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_surface_exception_04_as_BadDeviceFailure()
|
||||
{
|
||||
var transport = new ExceptionRaisingTransport(exceptionCode: 0x04);
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16);
|
||||
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], Probe = new ModbusProbeOptions { Enabled = false } };
|
||||
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var writes = await drv.WriteAsync(
|
||||
[new WriteRequest("T", (short)42)],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
writes[0].StatusCode.ShouldBe(0x80550000u, "FC06 returning exception 04 (CPU in PROGRAM mode) maps to BadDeviceFailure");
|
||||
}
|
||||
|
||||
private sealed class NonModbusFailureTransport : IModbusTransport
|
||||
{
|
||||
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
=> Task.FromException<byte[]>(new EndOfStreamException("socket closed mid-response"));
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_non_modbus_failure_maps_to_BadCommunicationError_not_BadInternalError()
|
||||
{
|
||||
// Socket drop / timeout / malformed frame → transport-layer failure. Should surface
|
||||
// distinctly from tag-level faults so operators know to check the network, not the config.
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16);
|
||||
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], Probe = new ModbusProbeOptions { Enabled = false } };
|
||||
await using var drv = new ModbusDriver(opts, "modbus-1", _ => new NonModbusFailureTransport());
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await drv.ReadAsync(["T"], TestContext.Current.CancellationToken);
|
||||
results[0].StatusCode.ShouldBe(0x80050000u);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Exercises <see cref="ModbusTcpTransport"/> against a real TCP listener that can close
|
||||
/// its socket mid-session on demand. Verifies the PR 53 reconnect-on-drop behavior: after
|
||||
/// the "first" socket is forcibly torn down, the next SendAsync must re-establish the
|
||||
/// connection and complete the PDU without bubbling an error to the caller.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusTcpReconnectTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimal in-process Modbus-TCP stub. Accepts one TCP connection at a time, reads an
|
||||
/// MBAP + PDU, replies with a canned FC03 response echoing the request quantity of
|
||||
/// zeroed bytes, then optionally closes the socket to simulate a NAT/firewall drop.
|
||||
/// </summary>
|
||||
private sealed class FlakeyModbusServer : IAsyncDisposable
|
||||
{
|
||||
private readonly TcpListener _listener;
|
||||
public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
|
||||
public int DropAfterNTransactions { get; set; } = int.MaxValue;
|
||||
private readonly CancellationTokenSource _stop = new();
|
||||
private int _txCount;
|
||||
|
||||
public FlakeyModbusServer()
|
||||
{
|
||||
_listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
_listener.Start();
|
||||
_ = Task.Run(AcceptLoopAsync);
|
||||
}
|
||||
|
||||
private async Task AcceptLoopAsync()
|
||||
{
|
||||
while (!_stop.IsCancellationRequested)
|
||||
{
|
||||
TcpClient? client = null;
|
||||
try { client = await _listener.AcceptTcpClientAsync(_stop.Token); }
|
||||
catch { return; }
|
||||
|
||||
_ = Task.Run(() => ServeAsync(client!));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ServeAsync(TcpClient client)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var _ = client;
|
||||
var stream = client.GetStream();
|
||||
while (!_stop.IsCancellationRequested && client.Connected)
|
||||
{
|
||||
var header = new byte[7];
|
||||
if (!await ReadExactly(stream, header)) return;
|
||||
var len = (ushort)((header[4] << 8) | header[5]);
|
||||
var pdu = new byte[len - 1];
|
||||
if (!await ReadExactly(stream, pdu)) return;
|
||||
|
||||
var fc = pdu[0];
|
||||
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
var respPdu = new byte[2 + qty * 2];
|
||||
respPdu[0] = fc;
|
||||
respPdu[1] = (byte)(qty * 2);
|
||||
// data bytes stay 0
|
||||
|
||||
var respLen = (ushort)(1 + respPdu.Length);
|
||||
var adu = new byte[7 + respPdu.Length];
|
||||
adu[0] = header[0]; adu[1] = header[1];
|
||||
adu[4] = (byte)(respLen >> 8); adu[5] = (byte)(respLen & 0xFF);
|
||||
adu[6] = header[6];
|
||||
Buffer.BlockCopy(respPdu, 0, adu, 7, respPdu.Length);
|
||||
await stream.WriteAsync(adu);
|
||||
await stream.FlushAsync();
|
||||
|
||||
_txCount++;
|
||||
if (_txCount >= DropAfterNTransactions)
|
||||
{
|
||||
// Simulate NAT/firewall silent close: slam the socket without a
|
||||
// protocol-level goodbye, which is what DL260 + an intermediate
|
||||
// middlebox would look like from the client's perspective.
|
||||
client.Client.Shutdown(SocketShutdown.Both);
|
||||
client.Close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
private static async Task<bool> ReadExactly(NetworkStream s, byte[] buf)
|
||||
{
|
||||
var read = 0;
|
||||
while (read < buf.Length)
|
||||
{
|
||||
var n = await s.ReadAsync(buf.AsMemory(read));
|
||||
if (n == 0) return false;
|
||||
read += n;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_stop.Cancel();
|
||||
_listener.Stop();
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Transport_recovers_from_mid_session_drop_and_retries_successfully()
|
||||
{
|
||||
await using var server = new FlakeyModbusServer { DropAfterNTransactions = 1 };
|
||||
await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromSeconds(2), autoReconnect: true);
|
||||
await transport.ConnectAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// First transaction succeeds; server then closes the socket.
|
||||
var pdu = new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 };
|
||||
var first = await transport.SendAsync(unitId: 1, pdu, TestContext.Current.CancellationToken);
|
||||
first[0].ShouldBe((byte)0x03);
|
||||
|
||||
// Second transaction: the connection is dead, but auto-reconnect must transparently
|
||||
// spin up a new socket, resend, and produce a valid response. Before PR 53 this would
|
||||
// surface as EndOfStreamException / IOException to the caller.
|
||||
var second = await transport.SendAsync(unitId: 1, pdu, TestContext.Current.CancellationToken);
|
||||
second[0].ShouldBe((byte)0x03);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Transport_without_AutoReconnect_propagates_drop_to_caller()
|
||||
{
|
||||
await using var server = new FlakeyModbusServer { DropAfterNTransactions = 1 };
|
||||
await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromSeconds(2), autoReconnect: false);
|
||||
await transport.ConnectAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var pdu = new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 };
|
||||
_ = await transport.SendAsync(unitId: 1, pdu, TestContext.Current.CancellationToken);
|
||||
|
||||
await Should.ThrowAsync<Exception>(async () =>
|
||||
await transport.SendAsync(unitId: 1, pdu, TestContext.Current.CancellationToken));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientCertAuthTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildCertificateIdentity_rejects_missing_path()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions { AuthType = OpcUaAuthType.Certificate };
|
||||
Should.Throw<InvalidOperationException>(() => OpcUaClientDriver.BuildCertificateIdentity(opts))
|
||||
.Message.ShouldContain("UserCertificatePath");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCertificateIdentity_rejects_nonexistent_file()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
AuthType = OpcUaAuthType.Certificate,
|
||||
UserCertificatePath = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.pfx"),
|
||||
};
|
||||
Should.Throw<FileNotFoundException>(() => OpcUaClientDriver.BuildCertificateIdentity(opts));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCertificateIdentity_loads_a_valid_PFX_with_private_key()
|
||||
{
|
||||
// Generate a self-signed cert on the fly so the test doesn't ship a static PFX.
|
||||
// The driver doesn't care about the issuer — just needs a cert with a private key.
|
||||
using var rsa = RSA.Create(2048);
|
||||
var req = new CertificateRequest("CN=OpcUaClientCertAuthTests", rsa,
|
||||
HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1));
|
||||
|
||||
var tmpPath = Path.Combine(Path.GetTempPath(), $"opcua-cert-test-{Guid.NewGuid():N}.pfx");
|
||||
File.WriteAllBytes(tmpPath, cert.Export(X509ContentType.Pfx, "testpw"));
|
||||
try
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
AuthType = OpcUaAuthType.Certificate,
|
||||
UserCertificatePath = tmpPath,
|
||||
UserCertificatePassword = "testpw",
|
||||
};
|
||||
var identity = OpcUaClientDriver.BuildCertificateIdentity(opts);
|
||||
identity.ShouldNotBeNull();
|
||||
identity.TokenType.ShouldBe(Opc.Ua.UserTokenType.Certificate);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { File.Delete(tmpPath); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Scaffold tests for <see cref="OpcUaClientDriver"/>'s <see cref="ITagDiscovery"/>
|
||||
/// surface that don't require a live remote server. Live-browse coverage lands in a
|
||||
/// follow-up PR once the in-process OPC UA server fixture is scaffolded.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientDiscoveryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_without_initialize_throws_InvalidOperationException()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-disco");
|
||||
var builder = new NullAddressSpaceBuilder();
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscoverAsync_rejects_null_builder()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-disco");
|
||||
Should.ThrowAsync<ArgumentNullException>(async () =>
|
||||
await drv.DiscoverAsync(null!, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Discovery_caps_are_sensible_defaults()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions();
|
||||
opts.MaxDiscoveredNodes.ShouldBe(10_000, "bounds memory on runaway servers without clipping normal models");
|
||||
opts.MaxBrowseDepth.ShouldBe(10, "deep enough for realistic info models; shallow enough for cycle safety");
|
||||
opts.BrowseRoot.ShouldBeNull("null = default to ObjectsFolder i=85");
|
||||
}
|
||||
|
||||
private sealed class NullAddressSpaceBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
=> new StubHandle();
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
||||
public void AttachAlarmCondition(IVariableHandle sourceVariable, string alarmName, DriverAttributeInfo alarmInfo) { }
|
||||
|
||||
private sealed class StubHandle : IVariableHandle
|
||||
{
|
||||
public string FullReference => "stub";
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Scaffold-level tests for <see cref="OpcUaClientDriver"/> that don't require a live
|
||||
/// remote OPC UA server. PR 67+ adds IReadable/IWritable/ITagDiscovery/ISubscribable
|
||||
/// tests against a local in-process OPC UA server fixture.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientDriverScaffoldTests
|
||||
{
|
||||
[Fact]
|
||||
public void Default_options_target_standard_opcua_port_and_anonymous_auth()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions();
|
||||
opts.EndpointUrl.ShouldBe("opc.tcp://localhost:4840", "4840 is the IANA-assigned OPC UA port");
|
||||
opts.SecurityMode.ShouldBe(OpcUaSecurityMode.None);
|
||||
opts.SecurityPolicy.ShouldBe(OpcUaSecurityPolicy.None);
|
||||
opts.AuthType.ShouldBe(OpcUaAuthType.Anonymous);
|
||||
opts.AutoAcceptCertificates.ShouldBeFalse("production default must reject untrusted server certs");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_timeouts_match_driver_specs_section_8()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions();
|
||||
opts.SessionTimeout.ShouldBe(TimeSpan.FromSeconds(120));
|
||||
opts.KeepAliveInterval.ShouldBe(TimeSpan.FromSeconds(5));
|
||||
opts.ReconnectPeriod.ShouldBe(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Driver_reports_type_and_id_before_connect()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-test");
|
||||
drv.DriverType.ShouldBe("OpcUaClient");
|
||||
drv.DriverInstanceId.ShouldBe("opcua-test");
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_against_unreachable_endpoint_transitions_to_Faulted_and_throws()
|
||||
{
|
||||
// RFC 5737 reserved-for-documentation IP; won't route anywhere. Pick opc.tcp:// so
|
||||
// endpoint selection hits the transport-layer connection rather than a DNS lookup.
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
// Port 1 on loopback is effectively guaranteed to be closed — the OS responds
|
||||
// with TCP RST immediately instead of hanging on connect, which keeps the
|
||||
// unreachable-host tests snappy. Don't use an RFC 5737 reserved IP; those get
|
||||
// routed to a black-hole + time out only after the SDK's internal retry/backoff
|
||||
// fully elapses (~60s even with Options.Timeout=500ms).
|
||||
EndpointUrl = "opc.tcp://127.0.0.1:1",
|
||||
Timeout = TimeSpan.FromMilliseconds(500),
|
||||
AutoAcceptCertificates = true, // dev-mode to bypass cert validation in the test
|
||||
};
|
||||
using var drv = new OpcUaClientDriver(opts, "opcua-unreach");
|
||||
|
||||
await Should.ThrowAsync<Exception>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
|
||||
var health = drv.GetHealth();
|
||||
health.State.ShouldBe(DriverState.Faulted);
|
||||
health.LastError.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reinitialize_against_unreachable_endpoint_re_throws()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
// Port 1 on loopback is effectively guaranteed to be closed — the OS responds
|
||||
// with TCP RST immediately instead of hanging on connect, which keeps the
|
||||
// unreachable-host tests snappy. Don't use an RFC 5737 reserved IP; those get
|
||||
// routed to a black-hole + time out only after the SDK's internal retry/backoff
|
||||
// fully elapses (~60s even with Options.Timeout=500ms).
|
||||
EndpointUrl = "opc.tcp://127.0.0.1:1",
|
||||
Timeout = TimeSpan.FromMilliseconds(500),
|
||||
AutoAcceptCertificates = true,
|
||||
};
|
||||
using var drv = new OpcUaClientDriver(opts, "opcua-reinit");
|
||||
|
||||
await Should.ThrowAsync<Exception>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
await Should.ThrowAsync<Exception>(async () =>
|
||||
await drv.ReinitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientFailoverTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResolveEndpointCandidates_prefers_EndpointUrls_when_provided()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
EndpointUrl = "opc.tcp://fallback:4840",
|
||||
EndpointUrls = ["opc.tcp://primary:4840", "opc.tcp://backup:4841"],
|
||||
};
|
||||
var list = OpcUaClientDriver.ResolveEndpointCandidates(opts);
|
||||
list.Count.ShouldBe(2);
|
||||
list[0].ShouldBe("opc.tcp://primary:4840");
|
||||
list[1].ShouldBe("opc.tcp://backup:4841");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEndpointCandidates_falls_back_to_single_EndpointUrl_when_list_empty()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions { EndpointUrl = "opc.tcp://only:4840" };
|
||||
var list = OpcUaClientDriver.ResolveEndpointCandidates(opts);
|
||||
list.Count.ShouldBe(1);
|
||||
list[0].ShouldBe("opc.tcp://only:4840");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEndpointCandidates_empty_list_treated_as_fallback_to_EndpointUrl()
|
||||
{
|
||||
// Explicit empty list should still fall back to the single-URL shortcut rather than
|
||||
// producing a zero-candidate sweep that would immediately throw with no URLs tried.
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
EndpointUrl = "opc.tcp://single:4840",
|
||||
EndpointUrls = [],
|
||||
};
|
||||
OpcUaClientDriver.ResolveEndpointCandidates(opts).Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostName_uses_first_candidate_before_connect()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
EndpointUrls = ["opc.tcp://primary:4840", "opc.tcp://backup:4841"],
|
||||
};
|
||||
using var drv = new OpcUaClientDriver(opts, "opcua-host");
|
||||
drv.HostName.ShouldBe("opc.tcp://primary:4840",
|
||||
"pre-connect the dashboard should show the first candidate URL so operators can link back");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_against_all_unreachable_endpoints_throws_AggregateException_listing_each()
|
||||
{
|
||||
// Port 1 + port 2 + port 3 on loopback are all guaranteed closed (TCP RST immediate).
|
||||
// Failover sweep should attempt all three and throw AggregateException naming each URL
|
||||
// so operators see exactly which candidates were tried.
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
EndpointUrls = ["opc.tcp://127.0.0.1:1", "opc.tcp://127.0.0.1:2", "opc.tcp://127.0.0.1:3"],
|
||||
PerEndpointConnectTimeout = TimeSpan.FromMilliseconds(500),
|
||||
Timeout = TimeSpan.FromMilliseconds(500),
|
||||
AutoAcceptCertificates = true,
|
||||
};
|
||||
using var drv = new OpcUaClientDriver(opts, "opcua-failover");
|
||||
|
||||
var ex = await Should.ThrowAsync<AggregateException>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
|
||||
ex.Message.ShouldContain("127.0.0.1:1");
|
||||
ex.Message.ShouldContain("127.0.0.1:2");
|
||||
ex.Message.ShouldContain("127.0.0.1:3");
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the IReadable/IWritable surface that don't need a live remote OPC UA
|
||||
/// server. Wire-level round-trips against a local in-process server fixture land in a
|
||||
/// follow-up PR once we have one scaffolded.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientReadWriteTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReadAsync_without_initialize_throws_InvalidOperationException()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-uninit");
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await drv.ReadAsync(["ns=2;s=Demo"], TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_without_initialize_throws_InvalidOperationException()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-uninit");
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await drv.WriteAsync(
|
||||
[new WriteRequest("ns=2;s=Demo", 42)],
|
||||
TestContext.Current.CancellationToken));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientSecurityPolicyTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(OpcUaSecurityPolicy.None)]
|
||||
[InlineData(OpcUaSecurityPolicy.Basic128Rsa15)]
|
||||
[InlineData(OpcUaSecurityPolicy.Basic256)]
|
||||
[InlineData(OpcUaSecurityPolicy.Basic256Sha256)]
|
||||
[InlineData(OpcUaSecurityPolicy.Aes128_Sha256_RsaOaep)]
|
||||
[InlineData(OpcUaSecurityPolicy.Aes256_Sha256_RsaPss)]
|
||||
public void MapSecurityPolicy_returns_known_non_empty_uri_for_every_enum_value(OpcUaSecurityPolicy policy)
|
||||
{
|
||||
var uri = OpcUaClientDriver.MapSecurityPolicy(policy);
|
||||
uri.ShouldNotBeNullOrEmpty();
|
||||
// Each URI should end in the enum name (for the non-None policies) so a driver
|
||||
// operator reading logs can correlate the URI back to the config value.
|
||||
if (policy != OpcUaSecurityPolicy.None)
|
||||
uri.ShouldContain(policy.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapSecurityPolicy_None_matches_SDK_None_URI()
|
||||
{
|
||||
OpcUaClientDriver.MapSecurityPolicy(OpcUaSecurityPolicy.None)
|
||||
.ShouldBe(SecurityPolicies.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapSecurityPolicy_Basic256Sha256_matches_SDK_URI()
|
||||
{
|
||||
OpcUaClientDriver.MapSecurityPolicy(OpcUaSecurityPolicy.Basic256Sha256)
|
||||
.ShouldBe(SecurityPolicies.Basic256Sha256);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapSecurityPolicy_Aes256_Sha256_RsaPss_matches_SDK_URI()
|
||||
{
|
||||
OpcUaClientDriver.MapSecurityPolicy(OpcUaSecurityPolicy.Aes256_Sha256_RsaPss)
|
||||
.ShouldBe(SecurityPolicies.Aes256_Sha256_RsaPss);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_enum_value_has_a_mapping()
|
||||
{
|
||||
foreach (OpcUaSecurityPolicy p in Enum.GetValues<OpcUaSecurityPolicy>())
|
||||
Should.NotThrow(() => OpcUaClientDriver.MapSecurityPolicy(p));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Scaffold tests for <c>ISubscribable</c> + <c>IHostConnectivityProbe</c> that don't
|
||||
/// need a live remote server. Live-session tests (subscribe/unsubscribe round-trip,
|
||||
/// keep-alive transitions) land in a follow-up PR once the in-process OPC UA server
|
||||
/// fixture is scaffolded.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientSubscribeAndProbeTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_without_initialize_throws_InvalidOperationException()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-sub-uninit");
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await drv.SubscribeAsync(["ns=2;s=Demo"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnsubscribeAsync_with_unknown_handle_is_noop()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-sub-unknown");
|
||||
// UnsubscribeAsync returns cleanly for handles it doesn't recognise — protects against
|
||||
// the caller's race with server-side cleanup after a session drop.
|
||||
await drv.UnsubscribeAsync(new FakeHandle(), TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHostStatuses_returns_endpoint_url_row_pre_init()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(
|
||||
new OpcUaClientDriverOptions { EndpointUrl = "opc.tcp://plc.example:4840" },
|
||||
"opcua-hosts");
|
||||
var rows = drv.GetHostStatuses();
|
||||
rows.Count.ShouldBe(1);
|
||||
rows[0].HostName.ShouldBe("opc.tcp://plc.example:4840",
|
||||
"host identity mirrors the endpoint URL so the Admin /hosts dashboard can link back to the remote server");
|
||||
rows[0].State.ShouldBe(HostState.Unknown);
|
||||
}
|
||||
|
||||
private sealed class FakeHandle : ISubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId => "fake";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<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.OpcUaClient.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.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
119
tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7AddressParserTests.cs
Normal file
119
tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7AddressParserTests.cs
Normal file
@@ -0,0 +1,119 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class S7AddressParserTests
|
||||
{
|
||||
// --- Data blocks ---
|
||||
|
||||
[Theory]
|
||||
[InlineData("DB1.DBX0.0", 1, S7Size.Bit, 0, 0)]
|
||||
[InlineData("DB1.DBX0.7", 1, S7Size.Bit, 0, 7)]
|
||||
[InlineData("DB1.DBB0", 1, S7Size.Byte, 0, 0)]
|
||||
[InlineData("DB1.DBW0", 1, S7Size.Word, 0, 0)]
|
||||
[InlineData("DB1.DBD4", 1, S7Size.DWord, 4, 0)]
|
||||
[InlineData("DB10.DBW100", 10, S7Size.Word, 100, 0)]
|
||||
[InlineData("DB1.DBX15.3", 1, S7Size.Bit, 15, 3)]
|
||||
public void Parse_data_block_addresses(string input, int db, S7Size size, int byteOff, int bitOff)
|
||||
{
|
||||
var r = S7AddressParser.Parse(input);
|
||||
r.Area.ShouldBe(S7Area.DataBlock);
|
||||
r.DbNumber.ShouldBe(db);
|
||||
r.Size.ShouldBe(size);
|
||||
r.ByteOffset.ShouldBe(byteOff);
|
||||
r.BitOffset.ShouldBe(bitOff);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("db1.dbw0", 1, S7Size.Word, 0)]
|
||||
[InlineData(" DB1.DBW0 ", 1, S7Size.Word, 0)] // trim whitespace
|
||||
public void Parse_is_case_insensitive_and_trims(string input, int db, S7Size size, int off)
|
||||
{
|
||||
var r = S7AddressParser.Parse(input);
|
||||
r.Area.ShouldBe(S7Area.DataBlock);
|
||||
r.DbNumber.ShouldBe(db);
|
||||
r.Size.ShouldBe(size);
|
||||
r.ByteOffset.ShouldBe(off);
|
||||
}
|
||||
|
||||
// --- M / I / Q ---
|
||||
|
||||
[Theory]
|
||||
[InlineData("MB0", S7Area.Memory, S7Size.Byte, 0, 0)]
|
||||
[InlineData("MW10", S7Area.Memory, S7Size.Word, 10, 0)]
|
||||
[InlineData("MD4", S7Area.Memory, S7Size.DWord, 4, 0)]
|
||||
[InlineData("M0.0", S7Area.Memory, S7Size.Bit, 0, 0)]
|
||||
[InlineData("M255.7", S7Area.Memory, S7Size.Bit, 255, 7)]
|
||||
[InlineData("IB0", S7Area.Input, S7Size.Byte, 0, 0)]
|
||||
[InlineData("IW0", S7Area.Input, S7Size.Word, 0, 0)]
|
||||
[InlineData("I0.0", S7Area.Input, S7Size.Bit, 0, 0)]
|
||||
[InlineData("QB0", S7Area.Output, S7Size.Byte, 0, 0)]
|
||||
[InlineData("QW0", S7Area.Output, S7Size.Word, 0, 0)]
|
||||
[InlineData("Q0.0", S7Area.Output, S7Size.Bit, 0, 0)]
|
||||
[InlineData("QD4", S7Area.Output, S7Size.DWord, 4, 0)]
|
||||
public void Parse_MIQ_addresses(string input, S7Area area, S7Size size, int byteOff, int bitOff)
|
||||
{
|
||||
var r = S7AddressParser.Parse(input);
|
||||
r.Area.ShouldBe(area);
|
||||
r.DbNumber.ShouldBe(0);
|
||||
r.Size.ShouldBe(size);
|
||||
r.ByteOffset.ShouldBe(byteOff);
|
||||
r.BitOffset.ShouldBe(bitOff);
|
||||
}
|
||||
|
||||
// --- Timers / counters ---
|
||||
|
||||
[Theory]
|
||||
[InlineData("T0", S7Area.Timer, 0)]
|
||||
[InlineData("T15", S7Area.Timer, 15)]
|
||||
[InlineData("C0", S7Area.Counter, 0)]
|
||||
[InlineData("C10", S7Area.Counter, 10)]
|
||||
public void Parse_timer_and_counter(string input, S7Area area, int number)
|
||||
{
|
||||
var r = S7AddressParser.Parse(input);
|
||||
r.Area.ShouldBe(area);
|
||||
r.ByteOffset.ShouldBe(number);
|
||||
r.Size.ShouldBe(S7Size.Word, "timers + counters are 16-bit opaque");
|
||||
}
|
||||
|
||||
// --- Reject garbage ---
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("Z0")] // unknown area
|
||||
[InlineData("DB")] // no number or tail
|
||||
[InlineData("DB1")] // no tail
|
||||
[InlineData("DB1.")] // empty tail
|
||||
[InlineData("DB1.DBX0")] // bit size without .bit
|
||||
[InlineData("DB1.DBX0.8")] // bit 8 out of range
|
||||
[InlineData("DB1.DBW0.0")] // word with bit suffix
|
||||
[InlineData("DB0.DBW0")] // db 0 invalid
|
||||
[InlineData("DBA.DBW0")] // non-numeric db
|
||||
[InlineData("DB1.DBQ0")] // invalid size letter
|
||||
[InlineData("M")] // no offset
|
||||
[InlineData("M0")] // bit access needs .bit
|
||||
[InlineData("M0.8")] // bit 8
|
||||
[InlineData("MB-1")] // negative offset
|
||||
[InlineData("MW")] // no offset digits
|
||||
[InlineData("TA")] // non-numeric timer
|
||||
public void Parse_rejects_invalid(string bad)
|
||||
=> Should.Throw<FormatException>(() => S7AddressParser.Parse(bad));
|
||||
|
||||
[Fact]
|
||||
public void TryParse_returns_false_for_garbage_without_throwing()
|
||||
{
|
||||
S7AddressParser.TryParse("not-an-address", out var r).ShouldBeFalse();
|
||||
r.ShouldBe(default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_returns_true_for_valid_address()
|
||||
{
|
||||
S7AddressParser.TryParse("DB1.DBW0", out var r).ShouldBeTrue();
|
||||
r.DbNumber.ShouldBe(1);
|
||||
r.Size.ShouldBe(S7Size.Word);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Shape tests for <see cref="S7Driver"/>'s <see cref="ITagDiscovery"/>,
|
||||
/// <see cref="ISubscribable"/>, and <see cref="IHostConnectivityProbe"/> surfaces that
|
||||
/// don't need a live PLC. Wire-level polling round-trips and probe transitions land in a
|
||||
/// follow-up PR once we have a mock S7 server.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class S7DiscoveryAndSubscribeTests
|
||||
{
|
||||
private sealed class RecordingAddressSpaceBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public readonly List<string> Folders = new();
|
||||
public readonly List<(string Name, DriverAttributeInfo Attr)> Variables = new();
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{
|
||||
Folders.Add(browseName);
|
||||
return this;
|
||||
}
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
{
|
||||
Variables.Add((browseName, attributeInfo));
|
||||
return new StubHandle();
|
||||
}
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
||||
public void AttachAlarmCondition(IVariableHandle sourceVariable, string alarmName, DriverAttributeInfo alarmInfo) { }
|
||||
|
||||
private sealed class StubHandle : IVariableHandle
|
||||
{
|
||||
public string FullReference => "stub";
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
|
||||
=> throw new NotImplementedException("S7 driver never calls this — no alarm surfacing");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_projects_every_tag_into_the_address_space()
|
||||
{
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Tags =
|
||||
[
|
||||
new("TempSetpoint", "DB1.DBW0", S7DataType.Int16, Writable: true),
|
||||
new("FaultBit", "M0.0", S7DataType.Bool, Writable: false),
|
||||
new("PIDOutput", "DB5.DBD12", S7DataType.Float32, Writable: true),
|
||||
],
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-disco");
|
||||
|
||||
var builder = new RecordingAddressSpaceBuilder();
|
||||
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
|
||||
|
||||
builder.Folders.ShouldContain("S7");
|
||||
builder.Variables.Count.ShouldBe(3);
|
||||
builder.Variables[0].Name.ShouldBe("TempSetpoint");
|
||||
builder.Variables[0].Attr.SecurityClass.ShouldBe(SecurityClassification.Operate, "writable tags get Operate security class");
|
||||
builder.Variables[1].Attr.SecurityClass.ShouldBe(SecurityClassification.ViewOnly, "read-only tags get ViewOnly");
|
||||
builder.Variables[2].Attr.DriverDataType.ShouldBe(DriverDataType.Float32);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHostStatuses_returns_one_row_with_host_port_identity_pre_init()
|
||||
{
|
||||
var opts = new S7DriverOptions { Host = "plc1.internal", Port = 102 };
|
||||
using var drv = new S7Driver(opts, "s7-host");
|
||||
|
||||
var rows = drv.GetHostStatuses();
|
||||
rows.Count.ShouldBe(1);
|
||||
rows[0].HostName.ShouldBe("plc1.internal:102");
|
||||
rows[0].State.ShouldBe(HostState.Unknown, "pre-init / pre-probe state is Unknown");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_returns_unique_handles_and_UnsubscribeAsync_accepts_them()
|
||||
{
|
||||
var opts = new S7DriverOptions { Host = "192.0.2.1" };
|
||||
using var drv = new S7Driver(opts, "s7-sub");
|
||||
|
||||
// SubscribeAsync does not itself call ReadAsync (the poll task does), so this works
|
||||
// even though the driver isn't initialized. The poll task catches the resulting
|
||||
// InvalidOperationException and the loop quietly continues — same pattern as the
|
||||
// Modbus driver's poll loop tolerating transient transport failures.
|
||||
var h1 = await drv.SubscribeAsync(["T1"], TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
|
||||
var h2 = await drv.SubscribeAsync(["T2"], TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
|
||||
|
||||
h1.DiagnosticId.ShouldStartWith("s7-sub-");
|
||||
h2.DiagnosticId.ShouldStartWith("s7-sub-");
|
||||
h1.DiagnosticId.ShouldNotBe(h2.DiagnosticId);
|
||||
|
||||
await drv.UnsubscribeAsync(h1, TestContext.Current.CancellationToken);
|
||||
await drv.UnsubscribeAsync(h2, TestContext.Current.CancellationToken);
|
||||
// UnsubscribeAsync with an unknown handle must be a no-op, not throw.
|
||||
await drv.UnsubscribeAsync(h1, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_publishing_interval_is_floored_at_100ms()
|
||||
{
|
||||
var opts = new S7DriverOptions { Host = "192.0.2.1", Probe = new S7ProbeOptions { Enabled = false } };
|
||||
using var drv = new S7Driver(opts, "s7-floor");
|
||||
|
||||
// 50 ms requested — the floor protects the S7 CPU from sub-scan polling that would
|
||||
// just queue wire-side. Test that the subscription is accepted (the floor is applied
|
||||
// internally; the floor value isn't exposed, so we're really just asserting that the
|
||||
// driver doesn't reject small intervals).
|
||||
var h = await drv.SubscribeAsync(["T"], TimeSpan.FromMilliseconds(50), TestContext.Current.CancellationToken);
|
||||
h.ShouldNotBeNull();
|
||||
await drv.UnsubscribeAsync(h, TestContext.Current.CancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="S7Driver"/>'s <c>IReadable</c>/<c>IWritable</c> surface
|
||||
/// that don't require a live PLC — covers error paths (not-initialized, unknown tag,
|
||||
/// read-only write rejection, unsupported data types). Wire-level round-trip tests
|
||||
/// against a live S7 or a mock-server land in a follow-up PR since S7.Net doesn't ship
|
||||
/// an in-process fake and an adequate mock is non-trivial.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class S7DriverReadWriteTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Initialize_rejects_invalid_tag_address_and_fails_fast()
|
||||
{
|
||||
// Bad address at init time must throw; the alternative (deferring the parse to the
|
||||
// first read) would surface the config bug as BadInternalError on every subsequent
|
||||
// Read which is impossible for an operator to diagnose from the OPC UA client.
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1", // reserved — will never complete TCP handshake
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
Tags = [new S7TagDefinition("BadTag", "NOT-AN-S7-ADDRESS", S7DataType.Int16)],
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-bad-tag");
|
||||
|
||||
// Either the TCP connect fails first (Exception) or the parser fails (FormatException)
|
||||
// — both are acceptable since both are init-time fail-fast. What matters is that we
|
||||
// don't return a "healthy" driver with a latent bad tag.
|
||||
await Should.ThrowAsync<Exception>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_without_initialize_throws_InvalidOperationException()
|
||||
{
|
||||
using var drv = new S7Driver(new S7DriverOptions { Host = "192.0.2.1" }, "s7-uninit");
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await drv.ReadAsync(["Any"], TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_without_initialize_throws_InvalidOperationException()
|
||||
{
|
||||
using var drv = new S7Driver(new S7DriverOptions { Host = "192.0.2.1" }, "s7-uninit");
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await drv.WriteAsync(
|
||||
[new(FullReference: "Any", Value: (short)0)],
|
||||
TestContext.Current.CancellationToken));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Scaffold-level tests that don't need a live S7 PLC — exercise driver lifecycle shape,
|
||||
/// default option values, and failure-mode transitions. PR 64 adds IReadable/IWritable
|
||||
/// tests against a mock-server, PR 65 adds discovery + subscribe.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class S7DriverScaffoldTests
|
||||
{
|
||||
[Fact]
|
||||
public void Default_options_target_S7_1500_slot_0_on_port_102()
|
||||
{
|
||||
var opts = new S7DriverOptions();
|
||||
opts.Port.ShouldBe(102, "ISO-on-TCP is always 102 for S7; documented in driver-specs.md §5");
|
||||
opts.CpuType.ShouldBe(global::S7.Net.CpuType.S71500);
|
||||
opts.Rack.ShouldBe((short)0);
|
||||
opts.Slot.ShouldBe((short)0, "S7-1200/1500 onboard PN ports are slot 0 by convention");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_probe_interval_is_reasonable_for_S7_scan_cycle()
|
||||
{
|
||||
// S7 PLCs scan 2-10 ms but comms mailbox typically processed once per scan.
|
||||
// 5 s default probe is lightweight — ~0.001% of comms budget.
|
||||
new S7ProbeOptions().Interval.ShouldBe(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tag_definition_defaults_to_writable_with_S7_max_string_length()
|
||||
{
|
||||
var tag = new S7TagDefinition("T", "DB1.DBW0", S7DataType.Int16);
|
||||
tag.Writable.ShouldBeTrue();
|
||||
tag.StringLength.ShouldBe(254, "S7 STRING type max length is 254 chars");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Driver_instance_reports_type_and_id_before_connect()
|
||||
{
|
||||
var opts = new S7DriverOptions { Host = "127.0.0.1" };
|
||||
using var drv = new S7Driver(opts, "s7-test");
|
||||
drv.DriverType.ShouldBe("S7");
|
||||
drv.DriverInstanceId.ShouldBe("s7-test");
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown, "health starts Unknown until InitializeAsync runs");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_against_unreachable_host_transitions_to_Faulted_and_throws()
|
||||
{
|
||||
// Pick an RFC 5737 reserved-for-documentation IP so the connect attempt fails fast
|
||||
// (no DNS mismatch, no accidental traffic to a real PLC).
|
||||
var opts = new S7DriverOptions { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250) };
|
||||
using var drv = new S7Driver(opts, "s7-unreach");
|
||||
|
||||
await Should.ThrowAsync<Exception>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
|
||||
var health = drv.GetHealth();
|
||||
health.State.ShouldBe(DriverState.Faulted, "unreachable host must flip the driver to Faulted so operators see it");
|
||||
health.LastError.ShouldNotBeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.S7.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.S7\ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,160 @@
|
||||
using System.Linq;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit coverage for the static helpers <see cref="DriverNodeManager"/> exposes to bridge
|
||||
/// driver-side history data (<see cref="HistoricalEvent"/> + <see cref="DataValueSnapshot"/>)
|
||||
/// to the OPC UA on-wire shape (<c>HistoryData</c> / <c>HistoryEvent</c> wrapped in an
|
||||
/// <see cref="ExtensionObject"/>). Fast, framework-only — no server fixture.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DriverNodeManagerHistoryMappingTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(nameof(HistoryAggregateType.Average), HistoryAggregateType.Average)]
|
||||
[InlineData(nameof(HistoryAggregateType.Minimum), HistoryAggregateType.Minimum)]
|
||||
[InlineData(nameof(HistoryAggregateType.Maximum), HistoryAggregateType.Maximum)]
|
||||
[InlineData(nameof(HistoryAggregateType.Total), HistoryAggregateType.Total)]
|
||||
[InlineData(nameof(HistoryAggregateType.Count), HistoryAggregateType.Count)]
|
||||
public void MapAggregate_translates_each_supported_OPC_UA_aggregate_NodeId(
|
||||
string name, HistoryAggregateType expected)
|
||||
{
|
||||
// Resolve the ObjectIds.AggregateFunction_<name> constant via reflection so the test
|
||||
// keeps working if the stack ever renames them — failure means the stack broke its
|
||||
// naming convention, worth surfacing loudly.
|
||||
var field = typeof(ObjectIds).GetField("AggregateFunction_" + name);
|
||||
field.ShouldNotBeNull();
|
||||
var nodeId = (NodeId)field!.GetValue(null)!;
|
||||
|
||||
DriverNodeManager.MapAggregate(nodeId).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapAggregate_returns_null_for_unknown_aggregate()
|
||||
{
|
||||
// AggregateFunction_TimeAverage is a valid OPC UA aggregate but not one the driver
|
||||
// surfaces. Null here means the service handler will translate to BadAggregateNotSupported
|
||||
// — the right behavior per Part 13 when the requested aggregate isn't implemented.
|
||||
DriverNodeManager.MapAggregate(ObjectIds.AggregateFunction_TimeAverage).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapAggregate_returns_null_for_null_input()
|
||||
{
|
||||
// Processed requests that omit the aggregate list (or pass a single null) must not crash.
|
||||
DriverNodeManager.MapAggregate(null).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildHistoryData_wraps_samples_as_HistoryData_extension_object()
|
||||
{
|
||||
var samples = new[]
|
||||
{
|
||||
new DataValueSnapshot(Value: 42, StatusCode: StatusCodes.Good,
|
||||
SourceTimestampUtc: new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
ServerTimestampUtc: new DateTime(2024, 1, 1, 0, 0, 1, DateTimeKind.Utc)),
|
||||
new DataValueSnapshot(Value: 99, StatusCode: StatusCodes.Good,
|
||||
SourceTimestampUtc: new DateTime(2024, 1, 1, 0, 0, 5, DateTimeKind.Utc),
|
||||
ServerTimestampUtc: new DateTime(2024, 1, 1, 0, 0, 6, DateTimeKind.Utc)),
|
||||
};
|
||||
|
||||
var ext = DriverNodeManager.BuildHistoryData(samples);
|
||||
|
||||
ext.Body.ShouldBeOfType<HistoryData>();
|
||||
var hd = (HistoryData)ext.Body;
|
||||
hd.DataValues.Count.ShouldBe(2);
|
||||
hd.DataValues[0].Value.ShouldBe(42);
|
||||
hd.DataValues[1].Value.ShouldBe(99);
|
||||
hd.DataValues[0].SourceTimestamp.ShouldBe(new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildHistoryEvent_wraps_events_with_BaseEventType_field_ordering()
|
||||
{
|
||||
// BuildHistoryEvent populates a fixed field set in BaseEventType's conventional order:
|
||||
// EventId, SourceName, Message, Severity, Time, ReceiveTime. Pinning this so a later
|
||||
// "respect the client's SelectClauses" change can't silently break older clients that
|
||||
// rely on the default layout.
|
||||
var events = new[]
|
||||
{
|
||||
new HistoricalEvent(
|
||||
EventId: "e-1",
|
||||
SourceName: "Tank1.HiAlarm",
|
||||
EventTimeUtc: new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc),
|
||||
ReceivedTimeUtc: new DateTime(2024, 1, 1, 12, 0, 0, 5, DateTimeKind.Utc),
|
||||
Message: "High level reached",
|
||||
Severity: 750),
|
||||
};
|
||||
|
||||
var ext = DriverNodeManager.BuildHistoryEvent(events);
|
||||
|
||||
ext.Body.ShouldBeOfType<HistoryEvent>();
|
||||
var he = (HistoryEvent)ext.Body;
|
||||
he.Events.Count.ShouldBe(1);
|
||||
var fields = he.Events[0].EventFields;
|
||||
fields.Count.ShouldBe(6);
|
||||
fields[0].Value.ShouldBe("e-1"); // EventId
|
||||
fields[1].Value.ShouldBe("Tank1.HiAlarm"); // SourceName
|
||||
((LocalizedText)fields[2].Value).Text.ShouldBe("High level reached"); // Message
|
||||
fields[3].Value.ShouldBe((ushort)750); // Severity
|
||||
((DateTime)fields[4].Value).ShouldBe(new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc));
|
||||
((DateTime)fields[5].Value).ShouldBe(new DateTime(2024, 1, 1, 12, 0, 0, 5, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildHistoryEvent_substitutes_empty_string_for_null_SourceName_and_Message()
|
||||
{
|
||||
// Driver-side nulls are preserved through the wire contract by design (distinguishes
|
||||
// "system event with no source" from "source unknown"), but OPC UA Variants of type
|
||||
// String must not carry null — the stack serializes null-string as empty. This test
|
||||
// pins the choice so a nullable-Variant refactor doesn't break clients that display
|
||||
// the field without a null check.
|
||||
var events = new[]
|
||||
{
|
||||
new HistoricalEvent("sys", null, DateTime.UtcNow, DateTime.UtcNow, null, 1),
|
||||
};
|
||||
|
||||
var ext = DriverNodeManager.BuildHistoryEvent(events);
|
||||
var fields = ((HistoryEvent)ext.Body).Events[0].EventFields;
|
||||
fields[1].Value.ShouldBe(string.Empty);
|
||||
((LocalizedText)fields[2].Value).Text.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToDataValue_preserves_status_code_and_timestamps()
|
||||
{
|
||||
var snap = new DataValueSnapshot(
|
||||
Value: 123.45,
|
||||
StatusCode: StatusCodes.UncertainSubstituteValue,
|
||||
SourceTimestampUtc: new DateTime(2024, 5, 1, 10, 0, 0, DateTimeKind.Utc),
|
||||
ServerTimestampUtc: new DateTime(2024, 5, 1, 10, 0, 1, DateTimeKind.Utc));
|
||||
|
||||
var dv = DriverNodeManager.ToDataValue(snap);
|
||||
|
||||
dv.Value.ShouldBe(123.45);
|
||||
dv.StatusCode.Code.ShouldBe(StatusCodes.UncertainSubstituteValue);
|
||||
dv.SourceTimestamp.ShouldBe(new DateTime(2024, 5, 1, 10, 0, 0, DateTimeKind.Utc));
|
||||
dv.ServerTimestamp.ShouldBe(new DateTime(2024, 5, 1, 10, 0, 1, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToDataValue_leaves_SourceTimestamp_default_when_snapshot_has_no_source_time()
|
||||
{
|
||||
// Galaxy's raw-history rows often carry only a ServerTimestamp (the historian knows
|
||||
// when it wrote the row, not when the process sampled it). The mapping must not
|
||||
// synthesize a bogus SourceTimestamp from ServerTimestamp — that would lie to the
|
||||
// client about the measurement's actual time.
|
||||
var snap = new DataValueSnapshot(Value: 1, StatusCode: 0,
|
||||
SourceTimestampUtc: null,
|
||||
ServerTimestampUtc: new DateTime(2024, 5, 1, 10, 0, 1, DateTimeKind.Utc));
|
||||
|
||||
var dv = DriverNodeManager.ToDataValue(snap);
|
||||
dv.SourceTimestamp.ShouldBe(default);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Opc.Ua.Configuration;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
// Core.Abstractions.HistoryReadResult (driver-side samples) collides with Opc.Ua.HistoryReadResult
|
||||
// (service-layer per-node result). Alias the driver type so the stub's interface implementations
|
||||
// are unambiguous.
|
||||
using DriverHistoryReadResult = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end test that a real OPC UA client's HistoryRead service reaches a fake driver's
|
||||
/// <see cref="IHistoryProvider"/> via <see cref="DriverNodeManager"/>'s
|
||||
/// <c>HistoryReadRawModified</c> / <c>HistoryReadProcessed</c> / <c>HistoryReadAtTime</c> /
|
||||
/// <c>HistoryReadEvents</c> overrides. Boots the full OPC UA stack + a stub
|
||||
/// <see cref="IHistoryProvider"/> driver, opens a client session, issues each HistoryRead
|
||||
/// variant, and asserts the client receives the expected per-kind payload.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class HistoryReadIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private static readonly int Port = 48600 + Random.Shared.Next(0, 99);
|
||||
private readonly string _endpoint = $"opc.tcp://localhost:{Port}/OtOpcUaHistoryTest";
|
||||
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-history-test-{Guid.NewGuid():N}");
|
||||
|
||||
private DriverHost _driverHost = null!;
|
||||
private OpcUaApplicationHost _server = null!;
|
||||
private HistoryDriver _driver = null!;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
_driverHost = new DriverHost();
|
||||
_driver = new HistoryDriver();
|
||||
await _driverHost.RegisterAsync(_driver, "{}", CancellationToken.None);
|
||||
|
||||
var options = new OpcUaServerOptions
|
||||
{
|
||||
EndpointUrl = _endpoint,
|
||||
ApplicationName = "OtOpcUaHistoryTest",
|
||||
ApplicationUri = "urn:OtOpcUa:Server:HistoryTest",
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
AutoAcceptUntrustedClientCertificates = true,
|
||||
};
|
||||
|
||||
_server = new OpcUaApplicationHost(options, _driverHost, new DenyAllUserAuthenticator(),
|
||||
NullLoggerFactory.Instance, NullLogger<OpcUaApplicationHost>.Instance);
|
||||
await _server.StartAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _server.DisposeAsync();
|
||||
await _driverHost.DisposeAsync();
|
||||
try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HistoryReadRaw_round_trips_driver_samples_to_the_client()
|
||||
{
|
||||
using var session = await OpenSessionAsync();
|
||||
var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:history-driver");
|
||||
var nodeId = new NodeId("raw.var", nsIndex);
|
||||
|
||||
// The Opc.Ua client exposes HistoryRead via Session.HistoryRead. We construct a
|
||||
// ReadRawModifiedDetails (IsReadModified=false → raw path) and a single
|
||||
// HistoryReadValueId targeting the driver-backed variable.
|
||||
var details = new ReadRawModifiedDetails
|
||||
{
|
||||
StartTime = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
EndTime = new DateTime(2024, 1, 1, 0, 0, 10, DateTimeKind.Utc),
|
||||
NumValuesPerNode = 100,
|
||||
IsReadModified = false,
|
||||
ReturnBounds = false,
|
||||
};
|
||||
var extObj = new ExtensionObject(details);
|
||||
var nodesToRead = new HistoryReadValueIdCollection { new() { NodeId = nodeId } };
|
||||
|
||||
session.HistoryRead(null, extObj, TimestampsToReturn.Both, false, nodesToRead,
|
||||
out var results, out _);
|
||||
|
||||
results.Count.ShouldBe(1);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good, $"HistoryReadRaw returned {results[0].StatusCode}");
|
||||
var hd = (HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData);
|
||||
hd.DataValues.Count.ShouldBe(_driver.RawSamplesReturned, "one DataValue per driver sample");
|
||||
hd.DataValues[0].Value.ShouldBe(_driver.FirstRawValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HistoryReadProcessed_maps_Average_aggregate_and_routes_to_ReadProcessedAsync()
|
||||
{
|
||||
using var session = await OpenSessionAsync();
|
||||
var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:history-driver");
|
||||
var nodeId = new NodeId("proc.var", nsIndex);
|
||||
|
||||
var details = new ReadProcessedDetails
|
||||
{
|
||||
StartTime = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
EndTime = new DateTime(2024, 1, 1, 0, 1, 0, DateTimeKind.Utc),
|
||||
ProcessingInterval = 10_000, // 10s buckets
|
||||
AggregateType = [ObjectIds.AggregateFunction_Average],
|
||||
};
|
||||
var extObj = new ExtensionObject(details);
|
||||
var nodesToRead = new HistoryReadValueIdCollection { new() { NodeId = nodeId } };
|
||||
|
||||
session.HistoryRead(null, extObj, TimestampsToReturn.Both, false, nodesToRead,
|
||||
out var results, out _);
|
||||
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
_driver.LastProcessedAggregate.ShouldBe(HistoryAggregateType.Average,
|
||||
"MapAggregate must translate ObjectIds.AggregateFunction_Average → driver enum");
|
||||
_driver.LastProcessedInterval.ShouldBe(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HistoryReadProcessed_returns_BadAggregateNotSupported_for_unmapped_aggregate()
|
||||
{
|
||||
using var session = await OpenSessionAsync();
|
||||
var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:history-driver");
|
||||
var nodeId = new NodeId("proc.var", nsIndex);
|
||||
|
||||
var details = new ReadProcessedDetails
|
||||
{
|
||||
StartTime = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
EndTime = new DateTime(2024, 1, 1, 0, 1, 0, DateTimeKind.Utc),
|
||||
ProcessingInterval = 10_000,
|
||||
// TimeAverage is a valid OPC UA aggregate NodeId but not one the driver implements —
|
||||
// the override returns BadAggregateNotSupported per Part 13 rather than coercing.
|
||||
AggregateType = [ObjectIds.AggregateFunction_TimeAverage],
|
||||
};
|
||||
var extObj = new ExtensionObject(details);
|
||||
var nodesToRead = new HistoryReadValueIdCollection { new() { NodeId = nodeId } };
|
||||
|
||||
session.HistoryRead(null, extObj, TimestampsToReturn.Both, false, nodesToRead,
|
||||
out var results, out _);
|
||||
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadAggregateNotSupported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HistoryReadAtTime_forwards_timestamp_list_to_driver()
|
||||
{
|
||||
using var session = await OpenSessionAsync();
|
||||
var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:history-driver");
|
||||
var nodeId = new NodeId("atTime.var", nsIndex);
|
||||
|
||||
var t1 = new DateTime(2024, 3, 1, 10, 0, 0, DateTimeKind.Utc);
|
||||
var t2 = new DateTime(2024, 3, 1, 10, 0, 30, DateTimeKind.Utc);
|
||||
var details = new ReadAtTimeDetails { ReqTimes = new DateTimeCollection { t1, t2 } };
|
||||
var extObj = new ExtensionObject(details);
|
||||
var nodesToRead = new HistoryReadValueIdCollection { new() { NodeId = nodeId } };
|
||||
|
||||
session.HistoryRead(null, extObj, TimestampsToReturn.Both, false, nodesToRead,
|
||||
out var results, out _);
|
||||
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
_driver.LastAtTimeRequestedTimes.ShouldNotBeNull();
|
||||
_driver.LastAtTimeRequestedTimes!.Count.ShouldBe(2);
|
||||
_driver.LastAtTimeRequestedTimes[0].ShouldBe(t1);
|
||||
_driver.LastAtTimeRequestedTimes[1].ShouldBe(t2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HistoryReadEvents_returns_HistoryEvent_with_BaseEventType_field_list()
|
||||
{
|
||||
using var session = await OpenSessionAsync();
|
||||
// Events target the driver-root notifier (not a specific variable) which is the
|
||||
// conventional pattern for alarm-history browse.
|
||||
var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:history-driver");
|
||||
var nodeId = new NodeId("history-driver", nsIndex);
|
||||
|
||||
// EventFilter must carry at least one SelectClause or the stack rejects it as
|
||||
// BadEventFilterInvalid before our override runs — empty filters are spec-forbidden.
|
||||
// We populate the standard BaseEventType selectors any real client would send; my
|
||||
// override's BuildHistoryEvent ignores the specific clauses and emits the canonical
|
||||
// field list anyway (the richer "respect exact SelectClauses" behavior is on the PR 38
|
||||
// follow-up list).
|
||||
var filter = new EventFilter();
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.EventId);
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.SourceName);
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Message);
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Severity);
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Time);
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.ReceiveTime);
|
||||
|
||||
var details = new ReadEventDetails
|
||||
{
|
||||
StartTime = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
EndTime = new DateTime(2024, 12, 31, 0, 0, 0, DateTimeKind.Utc),
|
||||
NumValuesPerNode = 10,
|
||||
Filter = filter,
|
||||
};
|
||||
var extObj = new ExtensionObject(details);
|
||||
var nodesToRead = new HistoryReadValueIdCollection { new() { NodeId = nodeId } };
|
||||
|
||||
session.HistoryRead(null, extObj, TimestampsToReturn.Both, false, nodesToRead,
|
||||
out var results, out _);
|
||||
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
var he = (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData);
|
||||
he.Events.Count.ShouldBe(_driver.EventsReturned);
|
||||
he.Events[0].EventFields.Count.ShouldBe(6, "BaseEventType default field layout is 6 entries");
|
||||
}
|
||||
|
||||
private async Task<ISession> OpenSessionAsync()
|
||||
{
|
||||
var cfg = new ApplicationConfiguration
|
||||
{
|
||||
ApplicationName = "OtOpcUaHistoryTestClient",
|
||||
ApplicationUri = "urn:OtOpcUa:HistoryTestClient",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
SecurityConfiguration = new SecurityConfiguration
|
||||
{
|
||||
ApplicationCertificate = new CertificateIdentifier
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = Path.Combine(_pkiRoot, "client-own"),
|
||||
SubjectName = "CN=OtOpcUaHistoryTestClient",
|
||||
},
|
||||
TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-issuers") },
|
||||
TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-trusted") },
|
||||
RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-rejected") },
|
||||
AutoAcceptUntrustedCertificates = true,
|
||||
AddAppCertToTrustedStore = true,
|
||||
},
|
||||
TransportConfigurations = new TransportConfigurationCollection(),
|
||||
TransportQuotas = new TransportQuotas { OperationTimeout = 15000 },
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 },
|
||||
};
|
||||
await cfg.Validate(ApplicationType.Client);
|
||||
cfg.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||
|
||||
var instance = new ApplicationInstance { ApplicationConfiguration = cfg, ApplicationType = ApplicationType.Client };
|
||||
await instance.CheckApplicationInstanceCertificate(true, CertificateFactory.DefaultKeySize);
|
||||
|
||||
var selected = CoreClientUtils.SelectEndpoint(cfg, _endpoint, useSecurity: false);
|
||||
var endpointConfig = EndpointConfiguration.Create(cfg);
|
||||
var configuredEndpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
|
||||
|
||||
return await Session.Create(cfg, configuredEndpoint, false, "OtOpcUaHistoryTestClientSession", 60000,
|
||||
new UserIdentity(new AnonymousIdentityToken()), null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stub driver that implements <see cref="IHistoryProvider"/> so the service dispatch
|
||||
/// can be verified without bringing up a real Galaxy or Historian. Captures the last-
|
||||
/// seen arguments so tests can assert what the service handler forwarded.
|
||||
/// </summary>
|
||||
private sealed class HistoryDriver : IDriver, ITagDiscovery, IReadable, IHistoryProvider
|
||||
{
|
||||
public string DriverInstanceId => "history-driver";
|
||||
public string DriverType => "HistoryStub";
|
||||
|
||||
public int RawSamplesReturned => 3;
|
||||
public int FirstRawValue => 100;
|
||||
public int EventsReturned => 2;
|
||||
|
||||
public HistoryAggregateType? LastProcessedAggregate { get; private set; }
|
||||
public TimeSpan? LastProcessedInterval { get; private set; }
|
||||
public IReadOnlyList<DateTime>? LastAtTimeRequestedTimes { get; private set; }
|
||||
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct)
|
||||
{
|
||||
// Every variable must be Historized for HistoryRead to route — the node-manager's
|
||||
// stack base class checks the bit before dispatching.
|
||||
builder.Variable("raw", "raw",
|
||||
new DriverAttributeInfo("raw.var", DriverDataType.Int32, false, null,
|
||||
SecurityClassification.FreeAccess, IsHistorized: true, IsAlarm: false));
|
||||
builder.Variable("proc", "proc",
|
||||
new DriverAttributeInfo("proc.var", DriverDataType.Float64, false, null,
|
||||
SecurityClassification.FreeAccess, IsHistorized: true, IsAlarm: false));
|
||||
builder.Variable("atTime", "atTime",
|
||||
new DriverAttributeInfo("atTime.var", DriverDataType.Int32, false, null,
|
||||
SecurityClassification.FreeAccess, IsHistorized: true, IsAlarm: false));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
IReadOnlyList<DataValueSnapshot> r =
|
||||
[.. fullReferences.Select(_ => new DataValueSnapshot(0, 0u, now, now))];
|
||||
return Task.FromResult(r);
|
||||
}
|
||||
|
||||
public Task<DriverHistoryReadResult> ReadRawAsync(
|
||||
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var samples = new List<DataValueSnapshot>();
|
||||
for (var i = 0; i < RawSamplesReturned; i++)
|
||||
{
|
||||
samples.Add(new DataValueSnapshot(
|
||||
Value: FirstRawValue + i,
|
||||
StatusCode: StatusCodes.Good,
|
||||
SourceTimestampUtc: startUtc.AddSeconds(i),
|
||||
ServerTimestampUtc: startUtc.AddSeconds(i)));
|
||||
}
|
||||
return Task.FromResult(new DriverHistoryReadResult(samples, null));
|
||||
}
|
||||
|
||||
public Task<DriverHistoryReadResult> ReadProcessedAsync(
|
||||
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
|
||||
HistoryAggregateType aggregate, CancellationToken cancellationToken)
|
||||
{
|
||||
LastProcessedAggregate = aggregate;
|
||||
LastProcessedInterval = interval;
|
||||
return Task.FromResult(new DriverHistoryReadResult(
|
||||
[new DataValueSnapshot(1.0, StatusCodes.Good, startUtc, startUtc)],
|
||||
null));
|
||||
}
|
||||
|
||||
public Task<DriverHistoryReadResult> ReadAtTimeAsync(
|
||||
string fullReference, IReadOnlyList<DateTime> timestampsUtc,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
LastAtTimeRequestedTimes = timestampsUtc;
|
||||
var samples = timestampsUtc
|
||||
.Select(t => new DataValueSnapshot(42, StatusCodes.Good, t, t))
|
||||
.ToArray();
|
||||
return Task.FromResult(new DriverHistoryReadResult(samples, null));
|
||||
}
|
||||
|
||||
public Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var events = new List<HistoricalEvent>();
|
||||
for (var i = 0; i < EventsReturned; i++)
|
||||
{
|
||||
events.Add(new HistoricalEvent(
|
||||
EventId: $"e{i}",
|
||||
SourceName: sourceName,
|
||||
EventTimeUtc: startUtc.AddHours(i),
|
||||
ReceivedTimeUtc: startUtc.AddHours(i).AddSeconds(1),
|
||||
Message: $"Event {i}",
|
||||
Severity: (ushort)(500 + i)));
|
||||
}
|
||||
return Task.FromResult(new HistoricalEventsResult(events, null));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user