Compare commits

...

4 Commits

Author SHA1 Message Date
Joseph Doherty
a05b84858d Phase 3 PR 43 — Swap ModbusPal to pymodbus for the integration-test simulator. Replaces the .xmpp profiles shipped in PR 42 with pymodbus 3.13.0 ModbusSimulatorServer JSON configs in tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/. Substantive reasons for the swap (rationale block in the test-plan doc): ModbusPal 1.6b is abandoned (last release ~2019), Java GUI-only with no headless mode in the official JAR, and only exposes 2 of the 4 standard Modbus tables (holding_registers + coils — no input_registers, no discrete_inputs). pymodbus is current stable, pure Python CLI (pip install pymodbus[simulator]==3.13.0), exposes all four tables, has built-in declarative actions (increment / random / timestamp / uptime) for dynamic registers, supports custom Python actions for anything more complex, and ships an optional aiohttp-based web UI / REST API for live inspection. Pip-installable on Windows; sidesteps the privileged-port admin requirement by defaulting to TCP 5020.
ModbusSimulatorFixture default port bumped from 502 to 5020 to match the pymodbus convention. Override via MODBUS_SIM_ENDPOINT for a real PLC on its native 502. Skip-message updated to point at the new Pymodbus\serve.ps1 wrapper instead of 'start ModbusPal'. csproj <None Update> rule swapped from ModbusPal/** to Pymodbus/** so the new JSON profiles + serve.ps1 + README copy to test-output as PreserveNewest.
standard.json — generic Modbus TCP server, slave id 1, port 5020, shared blocks=false (independent coils + HR address spaces, more textbook-PLC-like). HR[0..31] seeded with address-as-value via per-register uint16 entries, HR[100] auto-increments via the built-in increment action with parameters minval=0/maxval=65535 (drives subscribe-and-receive integration tests so they have a register that ticks without a write — pymodbus's increment ticks per-access not wall-clock, which is good enough for a 250ms-poll test), HR[200..209] scratch range left at 0 for write tests, coils 0..31 alternating, coils 100..109 scratch. write list covers 0..1023 so any test address is mutable.
dl205.json — AutomationDirect DirectLOGIC DL205/DL260 quirk simulator, slave id 1, port 5020, shared blocks=true (matches DL series memory model where coils/DI/HR overlay the same word address space). Each quirky register seeded with the pre-computed raw uint16 value documented in docs/v2/dl205.md, with an inline _quirk JSON-comment naming the behavior so future-me reading the file knows why HR[1040]=25928 means 'H' lo / 'e' hi (the user's headline string-byte-order finding). Encoded quirks: V0 marker at HR[0]=0xCAFE; V2000 at HR[1024]=0x2000; V40400 at HR[8448]=0x4040; 'Hello' string at HR[1040..1042] first-char-low-byte; Float32 1.5f at HR[1056..1057] in CDAB word order (low word first); BCD register at HR[1072]=0x1234; FC03-128-cap block at HR[1280..1407]; Y0/C0 coil markers at 2048/3072; scratch C-relays at 4000..4007.
serve.ps1 wrapper — pwsh script with a -Profile {standard|dl205} parameter switch. Validates pymodbus.simulator is on PATH (clearer message than the raw CommandNotFoundException), validates the profile JSON exists, builds the right --modbus_server/--modbus_device/--json_file/--http_port arg list, and execs pymodbus.simulator in the foreground. -HttpPort 0 disables the web UI. Foreground exec lets the operator Ctrl+C to stop without an extra control script.
README.md fully rewritten for pymodbus: install command (pip install 'pymodbus[simulator]==3.13.0' — pinned for reproducibility, [simulator] extra pulls aiohttp), per-profile reference tables, the same DL205 quirk → register table from PR 42 but adjusted for pymodbus paths, what's-NEW-vs-ModbusPal section (all four tables, raw uint16 seeding, declarative actions, custom Python action modules, headless, web UI, maintained), trade-offs section (float32-as-two-uint16s for explicit CDAB control, increment ticks per-access not wall-clock, shared-blocks mode for DL205 vs separate for Standard), file-format quick reference for hand-authoring more profiles. References pinned to the pymodbus readthedocs simulator/config + REST API pages.
docs/v2/modbus-test-plan.md harness section rewritten with the swap rationale; PR-history list updated to mark PR 42 SUPERSEDED by PR 43 and call out PR 44+ as the per-quirk implementation track. Test-conventions bullet about 'don't depend on ModbusPal state between tests' generalized to 'don't depend on simulator state' and a note added that pymodbus's REST API can reset state between facts if a test ever needs it.
DL205Profile.cs and DL205SmokeTests.cs xml-doc updated to reference pymodbus / dl205.json instead of ModbusPal / DL205.xmpp.
Functional validation deferred — Python isn't installed on this dev box (winget search returned no matches for Python.Python.3 exact). JSON parses structurally (PowerShell ConvertFrom-Json clean on both files), build clean, .json + serve.ps1 + README all copy to test-output as expected. User installs pymodbus when they want to actually run the simulator end-to-end; if pymodbus rejects the config the README's reference link to pymodbus's simulator/config schema doc is the right next stop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 20:35:26 -04:00
c59ac9e52d Merge pull request 'Phase 3 PR 42 — ModbusPal simulator profiles for Standard + DL205/DL260' (#41) from phase-3-pr42-modbuspal-profiles into v2 2026-04-18 20:12:39 -04:00
Joseph Doherty
02a0e8efd1 Phase 3 PR 42 — ModbusPal simulator profiles for Standard Modbus + DL205/DL260 quirks. Two hand-authored .xmpp profiles in tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusPal/ that integration tests load via the GUI to drive the suite without a real PLC. Both well-formed XML (verified via PowerShell [xml] cast); both copied to test-output as PreserveNewest content per the existing csproj rule.
Standard.xmpp — generic Modbus TCP server on port 502, slave id 1. HR[0..31] seeded with address-as-value (HR[5]=5 — easy mental map for diagnostics), HR[100] auto-incrementing via a 1Hz LinearGenerator binding (drives subscribe-and-receive integration tests so they have a register that actually changes without a write), HR[200..209] scratch range for write-roundtrip tests, coils 0..31 alternating on/off, coils 100..109 scratch. The Tick automation runs 0..65535 over 60s looping; bound to HR[100] via Binding_SINT16 — slow enough that a 250ms-poll integration test sees discrete jumps, fast enough that a 5s subscribe test sees several change notifications.
DL205.xmpp — AutomationDirect DirectLOGIC DL205/DL260 quirk simulator on port 502, slave id 1, modeling the behaviors documented in docs/v2/dl205.md as concrete register values so DL205 integration tests can assert each quirk WITHOUT a live PLC. Per-quirk encoding: V0 marker at HR[0]=0xCAFE proves register 0 is valid (rejects-register-0 rumour disproved); V2000 marker at HR[1024]=0x2000 proves V-memory octal-to-decimal mapping; V40400 marker at HR[8448]=0x4040 proves V40400→PDU 0x2100 (NOT register 0, contrary to the widespread shorthand); 'Hello' string at HR[1040..1042] packed first-char-low-byte (HR[1040]=0x6548 = 'H' lo + 'e' hi, HR[1041]=0x6C6C, HR[1042]=0x006F) — the headline string-byte-order quirk the user flagged; Float32 1.5f at HR[1056..1057] in CDAB word order (low word first: 0, then 0x3FC0); BCD register at HR[1072]=0x1234 representing decimal 1234 in BCD nibbles (NOT binary 0x04D2); 128-register block at HR[1280..1407] for FC03-128-cap testing; Y0 marker at coil 2048, C0 marker at coil 3072, scratch C-coils at 4000..4007 for write tests.
Critical limitation flagged inline + in README: ModbusPal 1.6b CANNOT represent the DL205 quirks semantically — it has no string binding, no BCD binding, no arbitrary-byte-layout binding (only SINT16/SINT32/FLOAT32 with word-order). So every DL205 quirk is encoded as a pre-computed raw 16-bit integer with the math worked out in inline comments above each register. Becomes unreadable past ~50 quirky registers; the README's 'alternatives' section recommends switching to pymodbus when that threshold approaches (pymodbus's ModbusSimulatorServer has first-class headless + scriptable callbacks for byte-level layouts).
Other ModbusPal 1.6b limitations called out in README: only holding_registers + coils sections in the official build (no input_registers / discrete_inputs — DL260 X-input markers can't be encoded faithfully here, FC02/FC04 tests wait for a fork or pymodbus); abandoned project (last release 1.6b, active forks at SCADA-LTS/ModbusPal, ControlThings-io/modbuspal, mrhenrike/ModbusPalEnhanced); no headless mode in the official JAR (-loadFile / -hide flags only in source-built forks); CVE-2018-10832 XXE on .xmpp import (don't import untrusted profiles — the in-repo ones are author-controlled).
README.md updated with: per-profile description tables, getting-started (download jar + java -jar + GUI File>Load>Run), MODBUS_SIM_ENDPOINT env-var override doc, two reference tables documenting which HR / coil address encodes which DL205 quirk + which test name asserts it (the same DL205_<behavior> naming convention from docs/v2/modbus-test-plan.md), 4-row alternatives comparison (pymodbus / diagslave / ModbusMechanic / ModRSsim2) for when ModbusPal can no longer carry the load, and a quick-reference XML format table at the bottom for future-me hand-authoring more profiles.
Pure documentation + test-asset PR — no code changes. The integration tests that consume these profiles (the actual DL205_<behavior> facts) land one at a time in PR 43+ as user validates each quirk via ModbusPal on the bench.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 20:05:20 -04:00
7009483d16 Merge pull request 'Phase 3 PR 41 — Document AutomationDirect DL205 / DL260 Modbus quirks' (#40) from phase-3-pr41-dl205-quirks-doc into v2 2026-04-18 19:52:20 -04:00
9 changed files with 459 additions and 65 deletions

View File

@@ -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`.

View File

@@ -1,15 +1,15 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
/// <summary>
/// Tag map for the AutomationDirect DL205 device class. Mirrors what the ModbusPal
/// <c>.xmpp</c> profile in <c>ModbusPal/DL205.xmpp</c> exposes (or the real PLC, when
/// Tag map for the AutomationDirect DL205 device class. Mirrors what the pymodbus
/// <c>dl205.json</c> profile in <c>Pymodbus/dl205.json</c> exposes (or the real PLC, when
/// <see cref="ModbusSimulatorFixture"/> is pointed at one).
/// </summary>
/// <remarks>
/// This is the scaffold — each tag is deliberately generic so the smoke test has stable
/// addresses to read. Device-specific quirk tests (word order, max-register, register-zero
/// access, etc.) will land in their own test classes alongside this profile as the user
/// validates each behavior in ModbusPal; see <c>docs/v2/modbus-test-plan.md</c> §per-device
/// validates each behavior in pymodbus; see <c>docs/v2/modbus-test-plan.md</c> §per-device
/// quirk catalog for the checklist.
/// </remarks>
public static class DL205Profile
@@ -18,8 +18,8 @@ public static class DL205Profile
/// register-zero quirk (pending confirmation) — see modbus-test-plan.md.</summary>
public const ushort SmokeHoldingRegister = 100;
/// <summary>Expected value the ModbusPal profile seeds into register 100. When running
/// against a real DL205 (or a ModbusPal profile where this register is writable), the smoke
/// <summary>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.</summary>
public const short SmokeHoldingValue = 1234;

View File

@@ -1,30 +0,0 @@
# ModbusPal simulator profiles
Drop device-specific `.xmpp` profiles here. The integration tests connect to the
endpoint in `MODBUS_SIM_ENDPOINT` (default `localhost:502`) and expect the
simulator to already be running — tests do not launch ModbusPal themselves,
because its Java GUI + JRE requirement is heavier than the harness is worth.
## Getting started
1. Download ModbusPal from SourceForge (`modbuspal.jar`).
2. `java -jar modbuspal.jar` to launch the GUI.
3. Load a profile from this directory (or configure one manually) and start the
simulator on TCP port 502.
4. `dotnet test tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests` — tests
auto-skip with a clear `SkipReason` if the TCP probe at the configured
endpoint fails within 2 seconds.
## Profile files
- `DL205.xmpp`_to be added_ — register map reflecting the AutomationDirect
DL205 quirks tracked in `docs/v2/modbus-test-plan.md`. The scaffolded smoke
test in `DL205/DL205SmokeTests.cs` needs holding register 100 writable and
present; a minimal ModbusPal profile with a single holding-register bank at
address 100 is sufficient.
## Environment variables
- `MODBUS_SIM_ENDPOINT` — override the simulator endpoint. Accepts `host:port`;
defaults to `localhost:502`. Useful when pointing the suite at a real PLC on
the bench.

View File

@@ -3,8 +3,9 @@ using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
/// <summary>
/// Reachability probe for a Modbus TCP simulator (ModbusPal or a real PLC). Parses
/// <c>MODBUS_SIM_ENDPOINT</c> (default <c>localhost:502</c>) and TCP-connects once at
/// Reachability probe for a Modbus TCP simulator (pymodbus-driven, see
/// <c>Pymodbus/serve.ps1</c>) or a real PLC. Parses
/// <c>MODBUS_SIM_ENDPOINT</c> (default <c>localhost:5020</c> per PR 43) and TCP-connects once at
/// fixture construction. Each test checks <see cref="SkipReason"/> and calls
/// <c>Assert.Skip</c> when the endpoint was unreachable, so a dev box without a running
/// simulator still passes `dotnet test` cleanly — matches the Galaxy live-smoke pattern in
@@ -25,7 +26,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
/// </remarks>
public sealed class ModbusSimulatorFixture : IAsyncDisposable
{
private const string DefaultEndpoint = "localhost:502";
// PR 43: default port is 5020 (pymodbus convention) instead of 502 (Modbus standard).
// Picking 5020 sidesteps the privileged-port admin requirement on Windows + matches the
// port baked into the pymodbus simulator JSON profiles in Pymodbus/. Override with
// MODBUS_SIM_ENDPOINT to point at a real PLC on its native port 502.
private const string DefaultEndpoint = "localhost:5020";
private const string EndpointEnvVar = "MODBUS_SIM_ENDPOINT";
public string Host { get; }
@@ -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.";
}
}

View File

@@ -0,0 +1,163 @@
# pymodbus simulator profiles
Two JSON-config profiles for pymodbus's `ModbusSimulatorServer`. Replaces the
ModbusPal `.xmpp` profiles that lived here in PR 42 — pymodbus is headless,
maintained, semantic about register layout, and pip-installable on Windows.
| File | What it simulates | Test category |
|---|---|---|
| [`standard.json`](standard.json) | Generic Modbus TCP server — HR[0..31] = address-as-value, HR[100] declarative auto-increment via `"action": "increment"`, alternating coils, scratch ranges for write tests. | `Trait=Standard` |
| [`dl205.json`](dl205.json) | AutomationDirect DirectLOGIC DL205 / DL260 quirks per [`docs/v2/dl205.md`](../../../docs/v2/dl205.md): low-byte-first string packing, CDAB Float32, BCD numerics, V-memory address markers, Y/C coil mappings. Inline `_quirk` comments per register name the behavior. | `Trait=DL205` |
Both bind TCP **5020** (pymodbus convention; sidesteps the Windows admin
requirement for privileged port 502). The integration-test fixture
(`ModbusSimulatorFixture`) defaults to `localhost:5020` to match — override
via `MODBUS_SIM_ENDPOINT` to point at a real PLC on its native port 502.
Run only **one profile at a time** (they share TCP 5020).
## Install
```powershell
pip install "pymodbus[simulator]==3.13.0"
```
The `[simulator]` extra pulls in `aiohttp` for the optional web UI / REST API.
Pinned to 3.13.0 for reproducibility — avoid 4.x dev releases until stabilized.
Requires Python ≥ 3.10. Windows Firewall will prompt on first bind; allow
Private network.
## Run
Foreground (Ctrl+C to stop). Use the `serve.ps1` wrapper:
```powershell
.\serve.ps1 -Profile standard
.\serve.ps1 -Profile dl205
```
Or invoke pymodbus directly:
```powershell
pymodbus.simulator `
--modbus_server srv `
--modbus_device dev `
--json_file .\standard.json `
--http_port 8080
```
Web UI at `http://localhost:8080` lets you inspect + poke registers manually.
Pass `--no_http` (or `-HttpPort 0` to `serve.ps1`) to disable.
## Run the integration tests
In a separate shell, with the simulator running:
```powershell
cd C:\Users\dohertj2\Desktop\lmxopcua
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests
```
Tests auto-skip with a clear `SkipReason` if `localhost:5020` isn't reachable
within 2 seconds. Filter by trait when both profiles' tests coexist:
```powershell
dotnet test ... --filter "Trait=Standard"
dotnet test ... --filter "Trait=DL205"
```
## What's encoded in each profile
### standard.json
- HR[0..31]: each register's value equals its address. Easy mental map.
- HR[100]: `"action": "increment"` ticks 0..65535 on every register access — drives subscribe-and-receive tests so they have a register that changes without a write.
- HR[200..209]: scratch range for write-roundtrip tests.
- Coils[0..31]: alternating on/off (even=on).
- Coils[100..109]: scratch.
- All addresses 0..1023 are writable (`"write": [[0, 1023]]`).
### dl205.json (per `docs/v2/dl205.md`)
| HR address | Quirk demonstrated | Raw value | Decoded |
|---|---|---|---|
| `0` (V0) | Register 0 is valid (rejects-register-0 rumour disproved) | `51966` (0xCAFE) | marker |
| `1024` (V2000 octal) | V-memory octal-to-decimal mapping | `8192` (0x2000) | marker |
| `8448` (V40400 octal) | V40400 → PDU 0x2100 (NOT register 0) | `16448` (0x4040) | marker |
| `1040..1042` | String "Hello" packed first-char-low-byte | `25928, 27756, 111` | `"Hello"` |
| `1056..1057` | Float32 1.5f in CDAB word order | `0, 16320` | `1.5f` |
| `1072` | Decimal 1234 in BCD encoding | `4660` (0x1234) | `1234` |
| `1280..1407` | 128-register block (FC03 cap = 128 above spec's 125) | first/last/mid markers; rest defaults to 0 | for FC03 cap test |
| Coil address | Quirk demonstrated |
|---|---|
| `2048` | Y0 maps to coil 2048 (DL260 layout) |
| `3072` | C0 maps to coil 3072 (DL260 layout) |
| `4000..4007` | Scratch C-relay range for write-roundtrip tests |
The DL260 X-input markers (FC02 discrete inputs) **are not encoded separately**
because the profile uses `shared blocks: true` (matches DL series memory
model) — coils/DI/HR/IR overlay the same word address space. Tests that
target FC02 against this profile end up reading the same bit positions as
the coils they share with.
## What's IN pymodbus that wasn't in ModbusPal
- **All four standard tables** (HR, IR, coils, DI) configurable via `co size` / `di size` / `hr size` / `ir size` setup keys.
- **Per-register raw uint16 seeding** — `{"addr": 1040, "value": 25928}` puts exactly that 16-bit value on the wire. No interpretation.
- **Built-in actions**: `increment`, `random`, `timestamp`, `reset`, `uptime` for declarative dynamic registers. No Python script alongside the config required.
- **Custom actions** — point `--custom_actions_module` at a `.py` file exposing callables to express anything more complex (per-second wall-clock ticks, BCD synthesis, etc.).
- **Headless** — pure CLI process, no Java, no Swing. Pip-installable. Plays well with CI runners.
- **Web UI / REST API** — `--http_port 8080` adds an aiohttp server for live inspection. Optional.
- **Maintained** — current stable 3.13.0 (April 2026), active development on 4.0 dev branch.
## Trade-offs vs the hand-authored ModbusPal profiles
- pymodbus's built-in `float32` type stores in pymodbus's word order; for explicit DL205 CDAB control we seed two raw `uint16` entries instead. Documented inline in `dl205.json`.
- `increment` action ticks per-access, not wall-clock. A 250ms-poll integration test sees variation either way; for strict 1Hz cadence add `--custom_actions_module my_actions.py` with a `time.time()`-based callable.
- `dl205.json` uses `shared blocks: true` because it matches DL series memory model; `standard.json` uses `shared blocks: false` so coils and HR address spaces are independent (more like a textbook PLC).
## File format reference
```json
{
"server_list": {
"<server-name>": {
"comm": "tcp",
"host": "0.0.0.0",
"port": 5020,
"framer": "socket",
"device_id": 1
}
},
"device_list": {
"<device-name>": {
"setup": {
"co size": N, "di size": N, "hr size": N, "ir size": N,
"shared blocks": false,
"type exception": false,
"defaults": { "value": {...}, "action": {...} }
},
"invalid": [],
"write": [[<from>, <to>]],
"bits": [{"addr": N, "value": 0|1}],
"uint16": [{"addr": N, "value": <0..65535>, "action"?: "increment", "parameters"?: {...}}],
"uint32": [{"addr": N, "value": <int>}],
"float32": [{"addr": N, "value": <float>}],
"string": [{"addr": N, "value": "<text>"}],
"repeat": []
}
}
}
```
The CLI args `--modbus_server <server-name> --modbus_device <device-name>`
pick which entries the simulator binds.
## References
- [pymodbus on PyPI](https://pypi.org/project/pymodbus/) — install, version pin
- [Simulator config docs](https://pymodbus.readthedocs.io/en/dev/source/library/simulator/config.html) — full schema reference
- [Simulator REST API](https://pymodbus.readthedocs.io/en/latest/source/library/simulator/restapi.html) — for the optional web UI
- [`docs/v2/dl205.md`](../../../docs/v2/dl205.md) — what each DL205 profile entry simulates
- [`docs/v2/modbus-test-plan.md`](../../../docs/v2/modbus-test-plan.md) — the `DL205_<behavior>` test naming convention

View File

@@ -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_<behavior> 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": []
}
}
}

View File

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

View File

@@ -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": []
}
}
}

View File

@@ -24,7 +24,7 @@
</ItemGroup>
<ItemGroup>
<None Update="ModbusPal\**\*" CopyToOutputDirectory="PreserveNewest"/>
<None Update="Pymodbus\**\*" CopyToOutputDirectory="PreserveNewest"/>
<None Update="DL205\**\*" CopyToOutputDirectory="PreserveNewest"/>
</ItemGroup>