Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/README.md
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

164 lines
7.2 KiB
Markdown

# 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