diff --git a/docs/v2/modbus-test-plan.md b/docs/v2/modbus-test-plan.md index 8009b43..58abee0 100644 --- a/docs/v2/modbus-test-plan.md +++ b/docs/v2/modbus-test-plan.md @@ -13,22 +13,30 @@ confirmed DL205 quirk lands in a follow-up PR as a named test in that project. ## Harness -**Chosen simulator: ModbusPal** (Java, scriptable). Rationale: -- Scriptable enough to mimic device-specific behaviors (non-standard register - layouts, custom exception codes, intentional response delays). -- Runs locally, no CI dependency. Tests skip when `localhost:502` (or the configured - simulator endpoint) isn't reachable. -- Free + long-maintained — physical PLC bench is unavailable in most dev - environments, and renting cloud PLCs isn't worth the per-test cost. +**Chosen simulator: pymodbus 3.13.0** (`pip install 'pymodbus[simulator]==3.13.0'`). +Replaced ModbusPal in PR 43 — see `tests/.../Pymodbus/README.md` for the +trade-off rationale. Headline reasons: -**Setup pattern** (not yet codified in a script — will land alongside the integration -test project): -1. Install ModbusPal, load the per-device `.xmpp` profile from - `tests/Driver.Modbus.IntegrationTests/ModbusPal/` (TBD directory). -2. Start the simulator listening on `localhost:502` (or override via - `MODBUS_SIM_ENDPOINT` env var). -3. `dotnet test` the integration project — tests auto-skip when the endpoint is - unreachable, so forgetting to start the simulator doesn't wedge CI. +- **Headless** pure-Python CLI; no Java GUI, runs cleanly on a CI runner. +- **Maintained** — current stable 3.13.0; ModbusPal 1.6b is abandoned. +- **All four standard tables** (HR, IR, coils, DI) configurable; ModbusPal + 1.6b only exposed HR + coils. +- **Built-in actions** (`increment`, `random`, `timestamp`, `uptime`) + + optional custom-Python actions for declarative dynamic behaviors. +- **Per-register raw uint16 seeding** — encoding the DL205 string-byte-order + / BCD / CDAB-float quirks stays explicit (the quirk math lives in the + `_quirk` JSON-comment fields next to each register). +- Pip-installable on Windows; sidesteps the privileged-port admin + requirement by defaulting to TCP **5020** instead of 502. + +**Setup pattern**: +1. `pip install "pymodbus[simulator]==3.13.0"`. +2. Start the simulator with one of the in-repo profiles: + `tests\.../Pymodbus\serve.ps1 -Profile standard` (or `-Profile dl205`). +3. `dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests` — + tests auto-skip when the endpoint is unreachable. Default endpoint is + `localhost:5020`; override via `MODBUS_SIM_ENDPOINT` for a real PLC on its + native port 502. ## Per-device quirk catalog @@ -87,20 +95,27 @@ vendors get promoted into driver defaults or opt-in options: protocol end-to-end. The in-memory `FakeTransport` from the unit test suite is deliberately not used here — its value is speed + determinism, which doesn't help reproduce device-specific issues. -- **Don't depend on ModbusPal state between tests.** Each test resets the +- **Don't depend on simulator state between tests.** Each test resets the simulator's register bank or uses a unique address range. Avoid relying on "previous test left value at register 10" setups that flake when tests run in - parallel or re-order. + parallel or re-order. Either the test mutates the scratch ranges and restores + on finally, or it uses pymodbus's REST API to reset state between facts. ## Next concrete PRs - **PR 30 — Integration test project + DL205 profile scaffold** — **DONE**. Shipped `tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests` with `ModbusSimulatorFixture` (TCP-probe, skips with a clear `SkipReason` when the - endpoint is unreachable), `DL205/DL205Profile.cs` (tag map stub — one - writable holding register at address 100), and `DL205/DL205SmokeTests.cs` - (write-then-read round-trip). `ModbusPal/` directory holds the README - pointing at the to-be-committed `DL205.xmpp` profile. -- **PR 31+**: one PR per confirmed DL205 quirk, landing the named test + any - driver-side adjustment (e.g., retry on dropped TxId) needed to pass it. Drop - the `DL205.xmpp` profile into `ModbusPal/` alongside the first quirk PR. + endpoint is unreachable), `DL205/DL205Profile.cs` (tag map stub), and + `DL205/DL205SmokeTests.cs` (write-then-read round-trip). +- **PR 41 — DL205 quirk catalog doc** — **DONE**. `docs/v2/dl205.md` + documents every DL205/DL260 Modbus divergence with primary-source citations. +- **PR 42 — ModbusPal `.xmpp` profiles** — **SUPERSEDED by PR 43**. Replaced + with pymodbus JSON because ModbusPal 1.6b is abandoned, GUI-only, and only + exposes 2 of the 4 standard tables. +- **PR 43 — pymodbus JSON profiles** — **DONE**. `Pymodbus/standard.json` + + `Pymodbus/dl205.json` + `Pymodbus/serve.ps1` runner. Both bind TCP 5020. +- **PR 44+**: one PR per confirmed DL205 quirk, landing the named test + any + driver-side adjustment (string byte order, BCD decoder, V-memory address + helper, FC16 cap-per-device-family) needed to pass it. Each quirk's value + is already pre-encoded in `Pymodbus/dl205.json`. diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205Profile.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205Profile.cs index 97b5cc3..4b26a4c 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205Profile.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205Profile.cs @@ -1,15 +1,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205; /// -/// Tag map for the AutomationDirect DL205 device class. Mirrors what the ModbusPal -/// .xmpp profile in ModbusPal/DL205.xmpp exposes (or the real PLC, when +/// Tag map for the AutomationDirect DL205 device class. Mirrors what the pymodbus +/// dl205.json profile in Pymodbus/dl205.json exposes (or the real PLC, when /// is pointed at one). /// /// /// This is the scaffold — each tag is deliberately generic so the smoke test has stable /// addresses to read. Device-specific quirk tests (word order, max-register, register-zero /// access, etc.) will land in their own test classes alongside this profile as the user -/// validates each behavior in ModbusPal; see docs/v2/modbus-test-plan.md §per-device +/// validates each behavior in pymodbus; see docs/v2/modbus-test-plan.md §per-device /// quirk catalog for the checklist. /// public static class DL205Profile @@ -18,8 +18,8 @@ public static class DL205Profile /// register-zero quirk (pending confirmation) — see modbus-test-plan.md. public const ushort SmokeHoldingRegister = 100; - /// Expected value the ModbusPal profile seeds into register 100. When running - /// against a real DL205 (or a ModbusPal profile where this register is writable), the smoke + /// Expected value the pymodbus profile seeds into register 100. When running + /// against a real DL205 (or a pymodbus profile where this register is writable), the smoke /// test seeds this value first, then reads it back. public const short SmokeHoldingValue = 1234; diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusPal/DL205.xmpp b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusPal/DL205.xmpp deleted file mode 100644 index 46ac26f..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusPal/DL205.xmpp +++ /dev/null @@ -1,192 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusPal/README.md b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusPal/README.md deleted file mode 100644 index 6d062eb..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusPal/README.md +++ /dev/null @@ -1,105 +0,0 @@ -# ModbusPal simulator profiles - -Two hand-authored `.xmpp` profiles you load into ModbusPal to drive the -integration-test suite without a real PLC: - -| File | What it simulates | Test category | -|---|---|---| -| [`Standard.xmpp`](Standard.xmpp) | Generic Modbus TCP server — HR[0..31] = address-as-value, alternating coils, one auto-incrementing register at HR[100] for subscribe tests, scratch ranges for write-roundtrip tests. | `Trait=Standard` | -| [`DL205.xmpp`](DL205.xmpp) | AutomationDirect DirectLOGIC DL205 / DL260 quirks per [`docs/v2/dl205.md`](../../../docs/v2/dl205.md): low-byte-first string packing, CDAB Float32, BCD numerics, V-memory address markers, Y/C coil mappings. | `Trait=DL205` | - -Both listen on TCP **port 502** (the standard Modbus port — change in the -ModbusPal GUI if a port conflict). Run **only one at a time** since they -share the port. - -## Getting started - -1. Download ModbusPal 1.6b from - [SourceForge](https://sourceforge.net/projects/modbuspal/) — `modbuspal.jar`. - Requires Java 8+ (Java 17/21 work but emit Swing deprecation warnings). -2. `java -jar modbuspal.jar` to launch the GUI. -3. **File > Load** → pick `Standard.xmpp` (or `DL205.xmpp`). -4. Click the **Run** button (top-right of the toolbar) to start serving on TCP 502. -5. `dotnet test tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests` — - tests auto-skip with a clear `SkipReason` if the TCP probe at the - configured endpoint fails within 2 seconds (`ModbusSimulatorFixture`). - -## Switching between Standard and DL205 - -Stop the running simulator (toolbar's **Stop** button), **File > Load** -the other profile, **Run**. - -## Environment variables - -- `MODBUS_SIM_ENDPOINT` — override the simulator endpoint - (`host:port`). Defaults to `localhost:502`. Useful when pointing the suite - at a real PLC on the bench, or running ModbusPal on a non-default port. - -## What's encoded in each profile - -### Standard - -- HR[0..31]: each register's value equals its address. -- HR[100]: bound to a `LinearGenerator` (0..65535 over 60s, looping) — drives - subscribe-and-receive tests. -- HR[200..209]: scratch range for write-roundtrip tests. -- Coils[0..31]: alternating on/off (even=on). -- Coils[100..109]: scratch range. - -### DL205 (per `docs/v2/dl205.md`) - -| HR address | Quirk demonstrated | Raw value | Decoded value | -|---|---|---|---| -| `0` | Register zero is valid (rejects-register-0 rumour disproved) | `-13570` (0xCAFE) | marker | -| `1024` (= V2000 octal) | V-memory octal-to-decimal mapping | `8192` (0x2000) | marker | -| `8448` (= V40400 octal) | V40400 → PDU 0x2100 (NOT register 0) | `16448` (0x4040) | marker | -| `1040..1042` | String "Hello" packed first-char-low-byte | `25928, 27756, 111` | `"Hello"` | -| `1056..1057` | Float32 1.5f in CDAB word order | `0, 16320` | `1.5f` | -| `1072` | Decimal 1234 in BCD encoding | `4660` (0x1234) | `1234` | -| `1280..1407` | 128-register block (FC03 cap = 128 above spec's 125) | address − 1280 | for FC03 cap test | - -| Coil address | Quirk demonstrated | -|---|---| -| `2048` | Y0 maps to coil 2048 (DL260 layout) | -| `3072` | C0 maps to coil 3072 (DL260 layout) | -| `4000..4007` | Scratch C-relay range for write-roundtrip tests | - -## Limitations of ModbusPal 1.6b - -- **Only `holding_registers` + `coils`** sections in the official build — - no `input_registers` (FC04) and no `discrete_inputs` (FC02). DL205's - X-input markers can't be encoded faithfully here. Tests for FC02 / FC04 - wait for a fork (e.g. `SCADA-LTS/ModbusPal`) or a pymodbus rewrite. -- **No semantic bindings** for strings / BCD / arbitrary byte layouts. The - DL205 profile encodes everything as pre-computed raw 16-bit integers - with the math worked out in inline comments. Anything fancier becomes - unreadable above ~50 quirky registers — switch to pymodbus when that - threshold approaches. -- **Project is abandoned** since 1.6b on the official SourceForge listing. - Active forks: `SCADA-LTS/ModbusPal`, `ControlThings-io/modbuspal`, - `mrhenrike/ModbusPalEnhanced`. -- **No headless mode** in the official 1.6b JAR (`-loadFile` / `-hide` - flags exist only in source-built forks). For CI use, plan to switch to - pymodbus's `ModbusSimulatorServer` (JSON config, scriptable callbacks, - first-class headless). -- **CVE-2018-10832** XXE in `.xmpp` import. Don't import `.xmpp` files from - untrusted sources. Profiles in this repo are author-controlled; safe. - -## Alternatives if ModbusPal stops working - -| Tool | Pros | Cons | -|---|---|---| -| **pymodbus `ModbusSimulatorServer`** | Headless-first, JSON config, per-register seeding, custom callbacks for byte-level layouts. Best CI fit. | Python dependency. | -| **diagslave** | Simple, headless, fast. | Flat register banks; no per-address seeding from config; no scripting. | -| **ModbusMechanic** | Headless config-file mode. | Lightly documented. | -| **ModRSsim2** | Windows GUI, CSV import, scripting. | GUI-centric. | - -## File format reference - -ModbusPal `.xmpp` is XML with a DTD reference (`modbuspal.dtd`). Root element -`` with three children: -- `` — internal id counter (start at 100+) -- `` — `` for TCP listen, plus a `` placeholder -- One or more `` containing `` (``), `` (``), `` - -Per-register `` ties a register to a `LinearGenerator` / `RandomGenerator` / `SineGenerator` automation declared at the project level. `order="0"` = LSW, `order="1"` = MSW for 32-bit types. There is **no string binding** and **no byte-swap-within-word** binding. diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusPal/Standard.xmpp b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusPal/Standard.xmpp deleted file mode 100644 index ec89678..0000000 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusPal/Standard.xmpp +++ /dev/null @@ -1,166 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusSimulatorFixture.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusSimulatorFixture.cs index 5f55e2d..7e34236 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusSimulatorFixture.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusSimulatorFixture.cs @@ -3,8 +3,9 @@ using System.Net.Sockets; namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests; /// -/// Reachability probe for a Modbus TCP simulator (ModbusPal or a real PLC). Parses -/// MODBUS_SIM_ENDPOINT (default localhost:502) and TCP-connects once at +/// Reachability probe for a Modbus TCP simulator (pymodbus-driven, see +/// Pymodbus/serve.ps1) or a real PLC. Parses +/// MODBUS_SIM_ENDPOINT (default localhost:5020 per PR 43) and TCP-connects once at /// fixture construction. Each test checks and calls /// Assert.Skip when the endpoint was unreachable, so a dev box without a running /// simulator still passes `dotnet test` cleanly — matches the Galaxy live-smoke pattern in @@ -25,7 +26,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests; /// public sealed class ModbusSimulatorFixture : IAsyncDisposable { - private const string DefaultEndpoint = "localhost:502"; + // PR 43: default port is 5020 (pymodbus convention) instead of 502 (Modbus standard). + // Picking 5020 sidesteps the privileged-port admin requirement on Windows + matches the + // port baked into the pymodbus simulator JSON profiles in Pymodbus/. Override with + // MODBUS_SIM_ENDPOINT to point at a real PLC on its native port 502. + private const string DefaultEndpoint = "localhost:5020"; private const string EndpointEnvVar = "MODBUS_SIM_ENDPOINT"; public string Host { get; } @@ -46,13 +51,15 @@ public sealed class ModbusSimulatorFixture : IAsyncDisposable if (!task.Wait(TimeSpan.FromSeconds(2)) || !client.Connected) { SkipReason = $"Modbus simulator at {Host}:{Port} did not accept a TCP connection within 2s. " + - $"Start ModbusPal (or override {EndpointEnvVar}) and re-run."; + $"Start the pymodbus simulator (Pymodbus\\serve.ps1 -Profile standard) " + + $"or override {EndpointEnvVar}, then re-run."; } } catch (Exception ex) { SkipReason = $"Modbus simulator at {Host}:{Port} unreachable: {ex.GetType().Name}: {ex.Message}. " + - $"Start ModbusPal (or override {EndpointEnvVar}) and re-run."; + $"Start the pymodbus simulator (Pymodbus\\serve.ps1 -Profile standard) " + + $"or override {EndpointEnvVar}, then re-run."; } } diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/README.md b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/README.md new file mode 100644 index 0000000..98352c4 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/README.md @@ -0,0 +1,163 @@ +# pymodbus simulator profiles + +Two JSON-config profiles for pymodbus's `ModbusSimulatorServer`. Replaces the +ModbusPal `.xmpp` profiles that lived here in PR 42 — pymodbus is headless, +maintained, semantic about register layout, and pip-installable on Windows. + +| File | What it simulates | Test category | +|---|---|---| +| [`standard.json`](standard.json) | Generic Modbus TCP server — HR[0..31] = address-as-value, HR[100] declarative auto-increment via `"action": "increment"`, alternating coils, scratch ranges for write tests. | `Trait=Standard` | +| [`dl205.json`](dl205.json) | AutomationDirect DirectLOGIC DL205 / DL260 quirks per [`docs/v2/dl205.md`](../../../docs/v2/dl205.md): low-byte-first string packing, CDAB Float32, BCD numerics, V-memory address markers, Y/C coil mappings. Inline `_quirk` comments per register name the behavior. | `Trait=DL205` | + +Both bind TCP **5020** (pymodbus convention; sidesteps the Windows admin +requirement for privileged port 502). The integration-test fixture +(`ModbusSimulatorFixture`) defaults to `localhost:5020` to match — override +via `MODBUS_SIM_ENDPOINT` to point at a real PLC on its native port 502. + +Run only **one profile at a time** (they share TCP 5020). + +## Install + +```powershell +pip install "pymodbus[simulator]==3.13.0" +``` + +The `[simulator]` extra pulls in `aiohttp` for the optional web UI / REST API. +Pinned to 3.13.0 for reproducibility — avoid 4.x dev releases until stabilized. +Requires Python ≥ 3.10. Windows Firewall will prompt on first bind; allow +Private network. + +## Run + +Foreground (Ctrl+C to stop). Use the `serve.ps1` wrapper: + +```powershell +.\serve.ps1 -Profile standard +.\serve.ps1 -Profile dl205 +``` + +Or invoke pymodbus directly: + +```powershell +pymodbus.simulator ` + --modbus_server srv ` + --modbus_device dev ` + --json_file .\standard.json ` + --http_port 8080 +``` + +Web UI at `http://localhost:8080` lets you inspect + poke registers manually. +Pass `--no_http` (or `-HttpPort 0` to `serve.ps1`) to disable. + +## Run the integration tests + +In a separate shell, with the simulator running: + +```powershell +cd C:\Users\dohertj2\Desktop\lmxopcua +dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests +``` + +Tests auto-skip with a clear `SkipReason` if `localhost:5020` isn't reachable +within 2 seconds. Filter by trait when both profiles' tests coexist: + +```powershell +dotnet test ... --filter "Trait=Standard" +dotnet test ... --filter "Trait=DL205" +``` + +## What's encoded in each profile + +### standard.json + +- HR[0..31]: each register's value equals its address. Easy mental map. +- HR[100]: `"action": "increment"` ticks 0..65535 on every register access — drives subscribe-and-receive tests so they have a register that changes without a write. +- HR[200..209]: scratch range for write-roundtrip tests. +- Coils[0..31]: alternating on/off (even=on). +- Coils[100..109]: scratch. +- All addresses 0..1023 are writable (`"write": [[0, 1023]]`). + +### dl205.json (per `docs/v2/dl205.md`) + +| HR address | Quirk demonstrated | Raw value | Decoded | +|---|---|---|---| +| `0` (V0) | Register 0 is valid (rejects-register-0 rumour disproved) | `51966` (0xCAFE) | marker | +| `1024` (V2000 octal) | V-memory octal-to-decimal mapping | `8192` (0x2000) | marker | +| `8448` (V40400 octal) | V40400 → PDU 0x2100 (NOT register 0) | `16448` (0x4040) | marker | +| `1040..1042` | String "Hello" packed first-char-low-byte | `25928, 27756, 111` | `"Hello"` | +| `1056..1057` | Float32 1.5f in CDAB word order | `0, 16320` | `1.5f` | +| `1072` | Decimal 1234 in BCD encoding | `4660` (0x1234) | `1234` | +| `1280..1407` | 128-register block (FC03 cap = 128 above spec's 125) | first/last/mid markers; rest defaults to 0 | for FC03 cap test | + +| Coil address | Quirk demonstrated | +|---|---| +| `2048` | Y0 maps to coil 2048 (DL260 layout) | +| `3072` | C0 maps to coil 3072 (DL260 layout) | +| `4000..4007` | Scratch C-relay range for write-roundtrip tests | + +The DL260 X-input markers (FC02 discrete inputs) **are not encoded separately** +because the profile uses `shared blocks: true` (matches DL series memory +model) — coils/DI/HR/IR overlay the same word address space. Tests that +target FC02 against this profile end up reading the same bit positions as +the coils they share with. + +## What's IN pymodbus that wasn't in ModbusPal + +- **All four standard tables** (HR, IR, coils, DI) configurable via `co size` / `di size` / `hr size` / `ir size` setup keys. +- **Per-register raw uint16 seeding** — `{"addr": 1040, "value": 25928}` puts exactly that 16-bit value on the wire. No interpretation. +- **Built-in actions**: `increment`, `random`, `timestamp`, `reset`, `uptime` for declarative dynamic registers. No Python script alongside the config required. +- **Custom actions** — point `--custom_actions_module` at a `.py` file exposing callables to express anything more complex (per-second wall-clock ticks, BCD synthesis, etc.). +- **Headless** — pure CLI process, no Java, no Swing. Pip-installable. Plays well with CI runners. +- **Web UI / REST API** — `--http_port 8080` adds an aiohttp server for live inspection. Optional. +- **Maintained** — current stable 3.13.0 (April 2026), active development on 4.0 dev branch. + +## Trade-offs vs the hand-authored ModbusPal profiles + +- pymodbus's built-in `float32` type stores in pymodbus's word order; for explicit DL205 CDAB control we seed two raw `uint16` entries instead. Documented inline in `dl205.json`. +- `increment` action ticks per-access, not wall-clock. A 250ms-poll integration test sees variation either way; for strict 1Hz cadence add `--custom_actions_module my_actions.py` with a `time.time()`-based callable. +- `dl205.json` uses `shared blocks: true` because it matches DL series memory model; `standard.json` uses `shared blocks: false` so coils and HR address spaces are independent (more like a textbook PLC). + +## File format reference + +```json +{ + "server_list": { + "": { + "comm": "tcp", + "host": "0.0.0.0", + "port": 5020, + "framer": "socket", + "device_id": 1 + } + }, + "device_list": { + "": { + "setup": { + "co size": N, "di size": N, "hr size": N, "ir size": N, + "shared blocks": false, + "type exception": false, + "defaults": { "value": {...}, "action": {...} } + }, + "invalid": [], + "write": [[, ]], + "bits": [{"addr": N, "value": 0|1}], + "uint16": [{"addr": N, "value": <0..65535>, "action"?: "increment", "parameters"?: {...}}], + "uint32": [{"addr": N, "value": }], + "float32": [{"addr": N, "value": }], + "string": [{"addr": N, "value": ""}], + "repeat": [] + } + } +} +``` + +The CLI args `--modbus_server --modbus_device ` +pick which entries the simulator binds. + +## References + +- [pymodbus on PyPI](https://pypi.org/project/pymodbus/) — install, version pin +- [Simulator config docs](https://pymodbus.readthedocs.io/en/dev/source/library/simulator/config.html) — full schema reference +- [Simulator REST API](https://pymodbus.readthedocs.io/en/latest/source/library/simulator/restapi.html) — for the optional web UI +- [`docs/v2/dl205.md`](../../../docs/v2/dl205.md) — what each DL205 profile entry simulates +- [`docs/v2/modbus-test-plan.md`](../../../docs/v2/modbus-test-plan.md) — the `DL205_` test naming convention diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/dl205.json b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/dl205.json new file mode 100644 index 0000000..0bd67cb --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/dl205.json @@ -0,0 +1,98 @@ +{ + "_comment": "DL205.json — AutomationDirect DirectLOGIC DL205/DL260 quirk simulator. Models each behavior in docs/v2/dl205.md as concrete register values so DL205_ integration tests can assert against this profile WITHOUT a live PLC. Loaded by `pymodbus.simulator`. See ../README.md. Per-quirk address layout matches the table in dl205.md exactly. `shared blocks: true` matches DL series behavior — coils/HR overlay the same word address space (a Y-output is both a discrete bit AND part of a system V-memory register).", + + "server_list": { + "srv": { + "comm": "tcp", + "host": "0.0.0.0", + "port": 5020, + "framer": "socket", + "device_id": 1 + } + }, + + "device_list": { + "dev": { + "setup": { + "co size": 16384, + "di size": 8192, + "hr size": 16384, + "ir size": 1024, + "shared blocks": true, + "type exception": false, + "defaults": { + "value": {"bits": 0, "uint16": 0, "uint32": 0, "float32": 0.0, "string": " "}, + "action": {"bits": null, "uint16": null, "uint32": null, "float32": null, "string": null} + } + }, + "invalid": [], + "write": [ + [0, 16383] + ], + + "_comment_uint16": "Holding-register seeds. Every quirky value is a raw uint16 with the byte math worked out in dl205.md so the simulator serves it verbatim — pymodbus does NOT decode strings, BCD, or float-CDAB on its own; that's the driver's job.", + + "uint16": [ + {"_quirk": "V0 marker. HR[0] = 0xCAFE proves register 0 is valid on DL205/DL260 (rejects-register-0 was a DL05/DL06 relative-mode artefact). 0xCAFE = 51966.", + "addr": 0, "value": 51966}, + + {"_quirk": "V2000 marker. V2000 octal = decimal 1024 = PDU 0x0400. Marker 0x2000 = 8192.", + "addr": 1024, "value": 8192}, + + {"_quirk": "V40400 marker. V40400 octal = decimal 8448 = PDU 0x2100 (NOT register 0). Marker 0x4040 = 16448.", + "addr": 8448, "value": 16448}, + + {"_quirk": "String 'Hello' first char in LOW byte. HR[0x410] = 'H'(0x48) lo + 'e'(0x65) hi = 0x6548 = 25928.", + "addr": 1040, "value": 25928}, + {"_quirk": "String 'Hello' second char-pair: 'l'(0x6C) lo + 'l'(0x6C) hi = 0x6C6C = 27756.", + "addr": 1041, "value": 27756}, + {"_quirk": "String 'Hello' third char-pair: 'o'(0x6F) lo + null(0x00) hi = 0x006F = 111.", + "addr": 1042, "value": 111}, + + {"_quirk": "Float32 1.5f in CDAB word order. IEEE 754 1.5 = 0x3FC00000. CDAB = low word first: HR[0x420]=0x0000, HR[0x421]=0x3FC0=16320.", + "addr": 1056, "value": 0}, + {"_quirk": "Float32 1.5f CDAB high word.", + "addr": 1057, "value": 16320}, + + {"_quirk": "BCD register. Decimal 1234 stored as BCD nibbles 0x1234 = 4660. NOT binary 1234 (= 0x04D2).", + "addr": 1072, "value": 4660}, + + {"_quirk": "FC03 cap test. Real DL205/DL260 FC03 caps at 128 registers (above spec's 125). HR[1280..1407] is 128 contiguous registers; rest of block defaults to 0.", + "addr": 1280, "value": 0}, + {"addr": 1281, "value": 1}, + {"addr": 1282, "value": 2}, + {"addr": 1343, "value": 63, "_marker": "FC03Block_mid"}, + {"addr": 1407, "value": 127, "_marker": "FC03Block_last"} + ], + + "_comment_bits": "Coils — Y outputs at 2048+, C relays at 3072+, scratch C at 4000-4007 for write tests. DL260 X inputs would be at discrete-input addresses 0..511 but pymodbus's shared-blocks mode + same-table-as-coils means those would conflict with HR seeds; FC02 tests against this profile use a separate discrete-input block instead — that's why `di size` is large but the X-input markers live in `bits` only when `shared blocks=false`. Document trade-off in README.", + + "bits": [ + {"_quirk": "Y0 marker. DL260 maps Y0 to coil 2048 (0-based). Coil 2048 = ON proves the mapping.", + "addr": 2048, "value": 1}, + {"addr": 2049, "value": 0}, + {"addr": 2050, "value": 1}, + + {"_quirk": "C0 marker. DL260 maps C0 to coil 3072 (0-based). Coil 3072 = ON proves the mapping.", + "addr": 3072, "value": 1}, + {"addr": 3073, "value": 0}, + {"addr": 3074, "value": 1}, + + {"_quirk": "Scratch C-relays for write-roundtrip tests against the writable C range.", + "addr": 4000, "value": 0}, + {"addr": 4001, "value": 0}, + {"addr": 4002, "value": 0}, + {"addr": 4003, "value": 0}, + {"addr": 4004, "value": 0}, + {"addr": 4005, "value": 0}, + {"addr": 4006, "value": 0}, + {"addr": 4007, "value": 0} + ], + + "uint32": [], + "float32": [], + "string": [], + "repeat": [] + } + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/serve.ps1 b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/serve.ps1 new file mode 100644 index 0000000..6cb9195 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/serve.ps1 @@ -0,0 +1,60 @@ +<# +.SYNOPSIS + Launches the pymodbus simulator with one of the integration-test profiles + (Standard or DL205). Foreground process — Ctrl+C to stop. + +.PARAMETER Profile + Which simulator profile to run: 'standard' or 'dl205'. Both bind TCP 5020 by + default so they can't run simultaneously on the same box. + +.PARAMETER HttpPort + Port for pymodbus's optional web UI / REST API. Default 8080. Pass 0 to + disable (passes --no_http). + +.EXAMPLE + .\serve.ps1 -Profile standard + Starts the standard server on TCP 5020 with web UI on 8080. + +.EXAMPLE + .\serve.ps1 -Profile dl205 -HttpPort 0 + Starts the DL205 server on TCP 5020, no web UI. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] [ValidateSet('standard', 'dl205')] [string]$Profile, + [int]$HttpPort = 8080 +) + +$ErrorActionPreference = 'Stop' +$here = $PSScriptRoot + +# Confirm pymodbus.simulator is on PATH — clearer message than the +# 'CommandNotFoundException' dotnet style. +$cmd = Get-Command pymodbus.simulator -ErrorAction SilentlyContinue +if (-not $cmd) { + Write-Error "pymodbus.simulator not found. Install with: pip install 'pymodbus[simulator]==3.13.0'" + exit 1 +} + +$jsonFile = Join-Path $here "$Profile.json" +if (-not (Test-Path $jsonFile)) { + Write-Error "Profile config not found: $jsonFile" + exit 1 +} + +$args = @( + '--modbus_server', 'srv', + '--modbus_device', 'dev', + '--json_file', $jsonFile +) + +if ($HttpPort -gt 0) { + $args += @('--http_port', $HttpPort) + Write-Host "Web UI will be at http://localhost:$HttpPort" +} else { + $args += '--no_http' +} + +Write-Host "Starting pymodbus simulator: profile=$Profile TCP=localhost:5020" +Write-Host "Ctrl+C to stop." +& pymodbus.simulator @args diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/standard.json b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/standard.json new file mode 100644 index 0000000..2738d6f --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/standard.json @@ -0,0 +1,81 @@ +{ + "_comment": "Standard.json — generic Modbus TCP server for the integration suite. Loaded by `pymodbus.simulator`. See ../README.md for the launch command. Holding registers 0..31 are seeded with their address as value (HR[5]=5) for easy mental-map diagnostics. HR[100] auto-increments via pymodbus's built-in `increment` action so subscribe-and-receive integration tests have a register that ticks without a write. HR[200..209] is a scratch range left at 0 for write-roundtrip tests. Coils 0..31 alternate on/off (even=on); coils 100..109 scratch.", + + "server_list": { + "srv": { + "comm": "tcp", + "host": "0.0.0.0", + "port": 5020, + "framer": "socket", + "device_id": 1 + } + }, + + "device_list": { + "dev": { + "setup": { + "co size": 1024, + "di size": 1024, + "hr size": 1024, + "ir size": 1024, + "shared blocks": false, + "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, 1023] + ], + + "bits": [ + {"addr": 0, "value": 1}, {"addr": 1, "value": 0}, + {"addr": 2, "value": 1}, {"addr": 3, "value": 0}, + {"addr": 4, "value": 1}, {"addr": 5, "value": 0}, + {"addr": 6, "value": 1}, {"addr": 7, "value": 0}, + {"addr": 8, "value": 1}, {"addr": 9, "value": 0}, + {"addr": 10, "value": 1}, {"addr": 11, "value": 0}, + {"addr": 12, "value": 1}, {"addr": 13, "value": 0}, + {"addr": 14, "value": 1}, {"addr": 15, "value": 0}, + {"addr": 16, "value": 1}, {"addr": 17, "value": 0}, + {"addr": 18, "value": 1}, {"addr": 19, "value": 0}, + {"addr": 20, "value": 1}, {"addr": 21, "value": 0}, + {"addr": 22, "value": 1}, {"addr": 23, "value": 0}, + {"addr": 24, "value": 1}, {"addr": 25, "value": 0}, + {"addr": 26, "value": 1}, {"addr": 27, "value": 0}, + {"addr": 28, "value": 1}, {"addr": 29, "value": 0}, + {"addr": 30, "value": 1}, {"addr": 31, "value": 0} + ], + + "uint16": [ + {"addr": 0, "value": 0}, {"addr": 1, "value": 1}, + {"addr": 2, "value": 2}, {"addr": 3, "value": 3}, + {"addr": 4, "value": 4}, {"addr": 5, "value": 5}, + {"addr": 6, "value": 6}, {"addr": 7, "value": 7}, + {"addr": 8, "value": 8}, {"addr": 9, "value": 9}, + {"addr": 10, "value": 10}, {"addr": 11, "value": 11}, + {"addr": 12, "value": 12}, {"addr": 13, "value": 13}, + {"addr": 14, "value": 14}, {"addr": 15, "value": 15}, + {"addr": 16, "value": 16}, {"addr": 17, "value": 17}, + {"addr": 18, "value": 18}, {"addr": 19, "value": 19}, + {"addr": 20, "value": 20}, {"addr": 21, "value": 21}, + {"addr": 22, "value": 22}, {"addr": 23, "value": 23}, + {"addr": 24, "value": 24}, {"addr": 25, "value": 25}, + {"addr": 26, "value": 26}, {"addr": 27, "value": 27}, + {"addr": 28, "value": 28}, {"addr": 29, "value": 29}, + {"addr": 30, "value": 30}, {"addr": 31, "value": 31}, + + {"addr": 100, "value": 0, + "action": "increment", + "parameters": {"minval": 0, "maxval": 65535}} + ], + + "uint32": [], + "float32": [], + "string": [], + "repeat": [] + } + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj index 0d192b5..0b0dad0 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj @@ -24,7 +24,7 @@ - +