Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus
Joseph Doherty 02fccbc762 Phase 3 PR 43 — followup commit: validate pymodbus simulator end-to-end + fix three real bugs surfaced by running it. winget-installed Python 3.12.10 + pip-installed pymodbus[simulator]==3.13.0 on the dev box; both profiles boot cleanly, the integration-suite smoke test passes against either profile.
Three substantive issues caught + fixed during the validation pass:
1. pymodbus rejects unknown keys at device-list / setup level. My PR 43 commit had `_layout_note`, `_uint16_layout`, `_bits_layout`, `_write_note` device-level JSON-comment fields that crashed pymodbus startup with `INVALID key in setup`. Removed all device-level _* fields. Inline `_quirk` keys WITHIN individual register entries are tolerated by pymodbus 3.13.0 — kept those in dl205.json since they document the byte math per quirk and the README + git history aren't enough context for a hand-author reading raw integer values. Documented the constraint in the top-level _comment of each profile.
2. pymodbus rejects sweeping `write` ranges that include any cell not assigned a type. My initial standard.json had `write: [[0, 2047]]` but only seeded HR[0..31] + HR[100] + HR[200..209] + bits[1024..1109] — pymodbus blew up on cell 32 (gap between HR[31] and HR[100]). Fixed by listing per-block write ranges that exactly mirror the seeded ranges. Same fix in dl205.json (was `[[0, 16383]]`).
3. pymodbus simulator stores all 4 standard Modbus tables in ONE underlying cell array — each cell can only be typed once (BITS or UINT16, not both). My initial standard.json had `bits[0..31]` AND `uint16[0..31]` overlapping at the same addresses; pymodbus crashed with `ERROR "uint16" <Cell> used`. Fixed by relocating coils to address 1024+, well clear of the uint16 entries at 0..209. Documented the layout constraint in the standard.json top-level _comment.
Substantive driver bug fixed: ModbusTcpTransport.ConnectAsync was using `new TcpClient()` (default constructor — dual-stack, IPv6 first) then `ConnectAsync(host, port)` with the user's hostname. .NET's TcpClient default-resolves "localhost" to ::1 first, fails to connect to pymodbus (which binds 0.0.0.0 IPv4-only), and only then retries IPv4 — the failure surfaces as the entire ConnectAsync timeout (2s by default) before the IPv4 attempt even starts. PR 30's smoke test silently SKIPPED because the fixture's TCP probe hit the same dual-stack ordering and timed out. Both fixed: ModbusSimulatorFixture probe now resolves Dns.GetHostAddresses, prefers AddressFamily.InterNetwork, dials IPv4 explicitly. ModbusTcpTransport does the same — resolves first, prefers IPv4, falls back to whatever Dns returns (handles IPv6-only hosts in the future). This is a real production-readiness fix because most Modbus PLCs are IPv4-only — a generic dual-stack TcpClient would burn the entire connect timeout against any IPv4-only PLC, masquerading as a connection failure when the PLC is actually fine.
Smoke-test address shifted HR[100] -> HR[200]. Standard.json's HR[100] is the auto-incrementing register that drives subscribe-and-receive tests, so write-then-read against it would race the increment. HR[200] is the first cell of a writable scratch range present in BOTH simulator profiles. DL205Profile.cs xml-doc updated to explain the shift; tag name "DL205_Smoke_HReg100" -> "Smoke_HReg200" + smoke test references updated. dl205.json gains a matching scratch HR[200..209] range so the smoke test runs identically against either profile.
Validation matrix:
- standard.json boot: clean (TCP 5020 listening within ~3s of pymodbus.simulator launch).
- dl205.json boot: clean.
- pymodbus client direct FC06 to HR[200]=1234 + FC03 read: round-trip OK.
- raw-bytes PowerShell TcpClient FC06 + 12-byte response: matches FC06 spec (echo of address + value).
- DL205SmokeTest against standard.json: 1/1 pass (was failing as 'BadInternalError' due to the dual-stack timeout + tag-name typo — both fixed).
- DL205SmokeTest against dl205.json: 1/1 pass.
- Modbus.Tests Unit suite: 52/52 pass — dual-stack transport fix is non-breaking.
- Solution build clean.
Memory + future-PR setup: pymodbus install + activation pattern is now bullet-pointed at the top of Pymodbus/README.md so future PRs (the per-quirk DL205_<behavior> tests in PR 44+) don't have to repeat the trial-and-error of getting the simulator + integration tests cooperating. The three bugs above are documented inline in the JSON profiles + ModbusTcpTransport so they don't bite again.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:14:02 -04:00
..

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 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 AutomationDirect DirectLOGIC DL205 / DL260 quirks per 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

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:

.\serve.ps1 -Profile standard
.\serve.ps1 -Profile dl205

Or invoke pymodbus directly:

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:

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:

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

{
  "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