Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus
Joseph Doherty 10c724b5b6 Phase 3 PR 56 -- Siemens S7-1500 pymodbus profile + smoke integration test. Adds tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/s7_1500.json modelling the SIMATIC S7-1500 + MB_SERVER default deployment documented in docs/v2/s7.md: DB1.DBW0 = 0xABCD fingerprint marker (operators reserve this so clients can verify they're talking to the right DB), scratch HR range 200..209 for write-roundtrip tests mirroring dl205.json + standard.json, Float32 1.5f at HR[100..101] in ABCD word order (high word first -- OPPOSITE of DL260 CDAB), Int32 0x12345678 at HR[300..301] in ABCD. Also seeds a coil at bit-addr 400 (= cell 25 bit 0) and a discrete input at bit-addr 500 (= cell 31 bit 0) so future S7-specific tests for FC01/FC02 have stable markers. shared blocks=true to match the proven dl205.json pattern (pymodbus's bits/uint16 cells coexist cleanly when addresses don't collide). Write list references cells (0, 25, 100-101, 200-209, 300-301), not bit addresses -- pymodbus's write-range entries are cell-indexed, not bit-indexed. Adds tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/S7/ directory with S7_1500Profile.cs (mirrors DL205Profile pattern: SmokeHoldingRegister=200, SmokeHoldingValue=4321, BuildOptions tags + probe-disabled + 2s timeout) and S7_1500SmokeTests.cs (single fact S7_1500_roundtrip_write_then_read_of_holding_register that writes SmokeHoldingValue then reads it back, asserting both write status 0 and read status 0 + value equality). Gates on MODBUS_SIM_PROFILE=s7_1500 so the test skips cleanly against other profiles. csproj updated to copy S7/** to test output as PreserveNewest (pattern matching DL205/**). Pymodbus/serve.ps1 ValidateSet extended from {standard,dl205} to {standard,dl205,s7_1500,mitsubishi} -- mitsubishi.json lands in PR 58 but the validator slot is claimed now so the serve.ps1 diff is one line in this PR and zero lines in future PRs. Verified end-to-end: smoke test 1/1 passes against the running pymodbus s7_1500 profile (localhost:5020 FC06 write of 4321 at HR[200] + FC03 read back). 143/143 Modbus.Tests pass, no regression in driver code because this PR is purely test-asset. Per-quirk S7 integration tests (ABCD word order default, FC23 IllegalFunction, MB_SERVER STATUS 0x8383 behaviour, port-per-connection semantics) land in PR 57+.
2026-04-18 22:57:03 -04:00
..
Phase 3 PR 51 -- DL260 X-input FC02 discrete-input mapping end-to-end test. Integration test DL205XInputTests reads FC02 at the DirectLogicAddress.XInputToDiscrete-resolved address and asserts two behaviors against the dl205.json pymodbus profile: (1) X20 octal (=decimal 16 = Modbus DI 16) reads ON, proving the helper correctly octal-parses the trailing number and adds it to the 0 base; (2) X21 octal reads OFF (not exception) -- per docs/v2/dl205.md §I/O-mapping, 'reading a non-populated X input returns zero, not an exception' on DL260, because the CPU sizes the discrete-input table to the configured I/O not the installed hardware. Pymodbus models this by returning the default 0 value for any DI bit in the configured 'di size' range that wasn't explicitly seeded, matching real DL260 behaviour. Test uses X20 rather than X0 to sidestep a shared-blocks conflict: pymodbus places FC01/FC02 bit-address 0..15 into cell 0, but cell 0 is already uint16-typed (V0 marker = 0xCAFE) per the register-zero quirk test, and shared-blocks semantics allow only one type per cell. X20 octal = DI 16 lands in cell 1 which is free, so both the V0 quirk AND the X-input quirk can coexist in one profile. dl205.json: bits cell 1 seeded value=9 (bits 0 and 3 set -> X20, X23 octal = ON), write-range extended to include cell 1 (though X-inputs are read-only; the write-range entry is required by pymodbus for ANY cell referenced in a bits section even if only reads are expected -- pymodbus validates write-access uniformly). 10/10 DL205 integration tests pass with MODBUS_SIM_PROFILE=dl205. No driver code changes -- the XInputToDiscrete helper + FC02 read path already landed in PRs 50 and 21 respectively. This PR closes the integration-test gap that docs/v2/dl205.md called out under test name DL205_Xinput_unpopulated_reads_as_zero.
2026-04-18 22:25:13 -04:00
Phase 3 PR 56 -- Siemens S7-1500 pymodbus profile + smoke integration test. Adds tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/s7_1500.json modelling the SIMATIC S7-1500 + MB_SERVER default deployment documented in docs/v2/s7.md: DB1.DBW0 = 0xABCD fingerprint marker (operators reserve this so clients can verify they're talking to the right DB), scratch HR range 200..209 for write-roundtrip tests mirroring dl205.json + standard.json, Float32 1.5f at HR[100..101] in ABCD word order (high word first -- OPPOSITE of DL260 CDAB), Int32 0x12345678 at HR[300..301] in ABCD. Also seeds a coil at bit-addr 400 (= cell 25 bit 0) and a discrete input at bit-addr 500 (= cell 31 bit 0) so future S7-specific tests for FC01/FC02 have stable markers. shared blocks=true to match the proven dl205.json pattern (pymodbus's bits/uint16 cells coexist cleanly when addresses don't collide). Write list references cells (0, 25, 100-101, 200-209, 300-301), not bit addresses -- pymodbus's write-range entries are cell-indexed, not bit-indexed. Adds tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/S7/ directory with S7_1500Profile.cs (mirrors DL205Profile pattern: SmokeHoldingRegister=200, SmokeHoldingValue=4321, BuildOptions tags + probe-disabled + 2s timeout) and S7_1500SmokeTests.cs (single fact S7_1500_roundtrip_write_then_read_of_holding_register that writes SmokeHoldingValue then reads it back, asserting both write status 0 and read status 0 + value equality). Gates on MODBUS_SIM_PROFILE=s7_1500 so the test skips cleanly against other profiles. csproj updated to copy S7/** to test output as PreserveNewest (pattern matching DL205/**). Pymodbus/serve.ps1 ValidateSet extended from {standard,dl205} to {standard,dl205,s7_1500,mitsubishi} -- mitsubishi.json lands in PR 58 but the validator slot is claimed now so the serve.ps1 diff is one line in this PR and zero lines in future PRs. Verified end-to-end: smoke test 1/1 passes against the running pymodbus s7_1500 profile (localhost:5020 FC06 write of 4321 at HR[200] + FC03 read back). 143/143 Modbus.Tests pass, no regression in driver code because this PR is purely test-asset. Per-quirk S7 integration tests (ABCD word order default, FC23 IllegalFunction, MB_SERVER STATUS 0x8383 behaviour, port-per-connection semantics) land in PR 57+.
2026-04-18 22:57:03 -04:00
Phase 3 PR 56 -- Siemens S7-1500 pymodbus profile + smoke integration test. Adds tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/s7_1500.json modelling the SIMATIC S7-1500 + MB_SERVER default deployment documented in docs/v2/s7.md: DB1.DBW0 = 0xABCD fingerprint marker (operators reserve this so clients can verify they're talking to the right DB), scratch HR range 200..209 for write-roundtrip tests mirroring dl205.json + standard.json, Float32 1.5f at HR[100..101] in ABCD word order (high word first -- OPPOSITE of DL260 CDAB), Int32 0x12345678 at HR[300..301] in ABCD. Also seeds a coil at bit-addr 400 (= cell 25 bit 0) and a discrete input at bit-addr 500 (= cell 31 bit 0) so future S7-specific tests for FC01/FC02 have stable markers. shared blocks=true to match the proven dl205.json pattern (pymodbus's bits/uint16 cells coexist cleanly when addresses don't collide). Write list references cells (0, 25, 100-101, 200-209, 300-301), not bit addresses -- pymodbus's write-range entries are cell-indexed, not bit-indexed. Adds tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/S7/ directory with S7_1500Profile.cs (mirrors DL205Profile pattern: SmokeHoldingRegister=200, SmokeHoldingValue=4321, BuildOptions tags + probe-disabled + 2s timeout) and S7_1500SmokeTests.cs (single fact S7_1500_roundtrip_write_then_read_of_holding_register that writes SmokeHoldingValue then reads it back, asserting both write status 0 and read status 0 + value equality). Gates on MODBUS_SIM_PROFILE=s7_1500 so the test skips cleanly against other profiles. csproj updated to copy S7/** to test output as PreserveNewest (pattern matching DL205/**). Pymodbus/serve.ps1 ValidateSet extended from {standard,dl205} to {standard,dl205,s7_1500,mitsubishi} -- mitsubishi.json lands in PR 58 but the validator slot is claimed now so the serve.ps1 diff is one line in this PR and zero lines in future PRs. Verified end-to-end: smoke test 1/1 passes against the running pymodbus s7_1500 profile (localhost:5020 FC06 write of 4321 at HR[200] + FC03 read back). 143/143 Modbus.Tests pass, no regression in driver code because this PR is purely test-asset. Per-quirk S7 integration tests (ABCD word order default, FC23 IllegalFunction, MB_SERVER STATUS 0x8383 behaviour, port-per-connection semantics) land in PR 57+.
2026-04-18 22:57:03 -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