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 @@
-
+