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>
164 lines
7.2 KiB
Markdown
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
|