Compare commits
19 Commits
phase-3-pr
...
phase-3-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
394d126b2e | ||
|
|
d5034c40f7 | ||
| 5e67c49f7c | |||
|
|
0575280a3b | ||
| 8150177296 | |||
|
|
56d8af8bdb | ||
| be8261a4ac | |||
| 65de2b4a09 | |||
| fccb566a30 | |||
| 9ccc7338b8 | |||
| e33783e042 | |||
|
|
a44fc7a610 | ||
|
|
d4c1873998 | ||
|
|
f52b7d8979 | ||
|
|
b54724a812 | ||
|
|
10c724b5b6 | ||
| 8c89d603e8 | |||
| 299bd4a932 | |||
|
|
9e2b5b330f |
@@ -9,6 +9,7 @@
|
||||
<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.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"/>
|
||||
@@ -26,6 +27,7 @@
|
||||
<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.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.
|
||||
|
||||
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
|
||||
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;
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
307
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
Normal file
307
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
Normal file
@@ -0,0 +1,307 @@
|
||||
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, IReadable, IWritable, IDisposable, IAsyncDisposable
|
||||
{
|
||||
/// <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);
|
||||
}
|
||||
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 { 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");
|
||||
|
||||
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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [ValidateSet('standard', 'dl205')] [string]$Profile,
|
||||
[Parameter(Mandatory)] [ValidateSet('standard', 'dl205', 's7_1500', 'mitsubishi')] [string]$Profile,
|
||||
[int]$HttpPort = 8080
|
||||
)
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,8 @@
|
||||
<ItemGroup>
|
||||
<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,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));
|
||||
}
|
||||
}
|
||||
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,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>
|
||||
Reference in New Issue
Block a user