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>
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 sizesetup 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,uptimefor declarative dynamic registers. No Python script alongside the config required. - Custom actions — point
--custom_actions_moduleat a.pyfile 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 8080adds 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
float32type stores in pymodbus's word order; for explicit DL205 CDAB control we seed two rawuint16entries instead. Documented inline indl205.json. incrementaction 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.pywith atime.time()-based callable.dl205.jsonusesshared blocks: truebecause it matches DL series memory model;standard.jsonusesshared blocks: falseso 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
- pymodbus on PyPI — install, version pin
- Simulator config docs — full schema reference
- Simulator REST API — for the optional web UI
docs/v2/dl205.md— what each DL205 profile entry simulatesdocs/v2/modbus-test-plan.md— theDL205_<behavior>test naming convention