Compare commits

...

3 Commits

Author SHA1 Message Date
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
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
c59ac9e52d Merge pull request 'Phase 3 PR 42 — ModbusPal simulator profiles for Standard + DL205/DL260' (#41) from phase-3-pr42-modbuspal-profiles into v2 2026-04-18 20:12:39 -04:00
13 changed files with 530 additions and 509 deletions

View File

@@ -13,22 +13,30 @@ confirmed DL205 quirk lands in a follow-up PR as a named test in that project.
## Harness ## Harness
**Chosen simulator: ModbusPal** (Java, scriptable). Rationale: **Chosen simulator: pymodbus 3.13.0** (`pip install 'pymodbus[simulator]==3.13.0'`).
- Scriptable enough to mimic device-specific behaviors (non-standard register Replaced ModbusPal in PR 43 — see `tests/.../Pymodbus/README.md` for the
layouts, custom exception codes, intentional response delays). trade-off rationale. Headline reasons:
- Runs locally, no CI dependency. Tests skip when `localhost:502` (or the configured
simulator endpoint) isn't reachable.
- Free + long-maintained — physical PLC bench is unavailable in most dev
environments, and renting cloud PLCs isn't worth the per-test cost.
**Setup pattern** (not yet codified in a script — will land alongside the integration - **Headless** pure-Python CLI; no Java GUI, runs cleanly on a CI runner.
test project): - **Maintained** — current stable 3.13.0; ModbusPal 1.6b is abandoned.
1. Install ModbusPal, load the per-device `.xmpp` profile from - **All four standard tables** (HR, IR, coils, DI) configurable; ModbusPal
`tests/Driver.Modbus.IntegrationTests/ModbusPal/` (TBD directory). 1.6b only exposed HR + coils.
2. Start the simulator listening on `localhost:502` (or override via - **Built-in actions** (`increment`, `random`, `timestamp`, `uptime`) +
`MODBUS_SIM_ENDPOINT` env var). optional custom-Python actions for declarative dynamic behaviors.
3. `dotnet test` the integration project — tests auto-skip when the endpoint is - **Per-register raw uint16 seeding** — encoding the DL205 string-byte-order
unreachable, so forgetting to start the simulator doesn't wedge CI. / BCD / CDAB-float quirks stays explicit (the quirk math lives in the
`_quirk` JSON-comment fields next to each register).
- Pip-installable on Windows; sidesteps the privileged-port admin
requirement by defaulting to TCP **5020** instead of 502.
**Setup pattern**:
1. `pip install "pymodbus[simulator]==3.13.0"`.
2. Start the simulator with one of the in-repo profiles:
`tests\.../Pymodbus\serve.ps1 -Profile standard` (or `-Profile dl205`).
3. `dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests`
tests auto-skip when the endpoint is unreachable. Default endpoint is
`localhost:5020`; override via `MODBUS_SIM_ENDPOINT` for a real PLC on its
native port 502.
## Per-device quirk catalog ## Per-device quirk catalog
@@ -87,20 +95,27 @@ vendors get promoted into driver defaults or opt-in options:
protocol end-to-end. The in-memory `FakeTransport` from the unit test suite is protocol end-to-end. The in-memory `FakeTransport` from the unit test suite is
deliberately not used here — its value is speed + determinism, which doesn't deliberately not used here — its value is speed + determinism, which doesn't
help reproduce device-specific issues. help reproduce device-specific issues.
- **Don't depend on ModbusPal state between tests.** Each test resets the - **Don't depend on simulator state between tests.** Each test resets the
simulator's register bank or uses a unique address range. Avoid relying on simulator's register bank or uses a unique address range. Avoid relying on
"previous test left value at register 10" setups that flake when tests run in "previous test left value at register 10" setups that flake when tests run in
parallel or re-order. parallel or re-order. Either the test mutates the scratch ranges and restores
on finally, or it uses pymodbus's REST API to reset state between facts.
## Next concrete PRs ## Next concrete PRs
- **PR 30 — Integration test project + DL205 profile scaffold** — **DONE**. - **PR 30 — Integration test project + DL205 profile scaffold** — **DONE**.
Shipped `tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests` with Shipped `tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests` with
`ModbusSimulatorFixture` (TCP-probe, skips with a clear `SkipReason` when the `ModbusSimulatorFixture` (TCP-probe, skips with a clear `SkipReason` when the
endpoint is unreachable), `DL205/DL205Profile.cs` (tag map stub — one endpoint is unreachable), `DL205/DL205Profile.cs` (tag map stub), and
writable holding register at address 100), and `DL205/DL205SmokeTests.cs` `DL205/DL205SmokeTests.cs` (write-then-read round-trip).
(write-then-read round-trip). `ModbusPal/` directory holds the README - **PR 41 — DL205 quirk catalog doc** — **DONE**. `docs/v2/dl205.md`
pointing at the to-be-committed `DL205.xmpp` profile. documents every DL205/DL260 Modbus divergence with primary-source citations.
- **PR 31+**: one PR per confirmed DL205 quirk, landing the named test + any - **PR 42 — ModbusPal `.xmpp` profiles** — **SUPERSEDED by PR 43**. Replaced
driver-side adjustment (e.g., retry on dropped TxId) needed to pass it. Drop with pymodbus JSON because ModbusPal 1.6b is abandoned, GUI-only, and only
the `DL205.xmpp` profile into `ModbusPal/` alongside the first quirk PR. exposes 2 of the 4 standard tables.
- **PR 43 — pymodbus JSON profiles** — **DONE**. `Pymodbus/standard.json` +
`Pymodbus/dl205.json` + `Pymodbus/serve.ps1` runner. Both bind TCP 5020.
- **PR 44+**: one PR per confirmed DL205 quirk, landing the named test + any
driver-side adjustment (string byte order, BCD decoder, V-memory address
helper, FC16 cap-per-device-family) needed to pass it. Each quirk's value
is already pre-encoded in `Pymodbus/dl205.json`.

View File

@@ -28,10 +28,20 @@ public sealed class ModbusTcpTransport : IModbusTransport
public async Task ConnectAsync(CancellationToken ct) public async Task ConnectAsync(CancellationToken ct)
{ {
_client = new TcpClient(); // Resolve the host explicitly + prefer IPv4. .NET's TcpClient default-constructor is
// dual-stack (IPv6 first, fallback to IPv4) — but most Modbus TCP devices (PLCs and
// simulators like pymodbus) bind 0.0.0.0 only, so the IPv6 attempt times out and we
// burn the entire ConnectAsync budget before even trying IPv4. Resolving first +
// dialing the IPv4 address directly sidesteps that.
var addresses = await System.Net.Dns.GetHostAddressesAsync(_host, ct).ConfigureAwait(false);
var ipv4 = System.Linq.Enumerable.FirstOrDefault(addresses,
a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork);
var target = ipv4 ?? (addresses.Length > 0 ? addresses[0] : System.Net.IPAddress.Loopback);
_client = new TcpClient(target.AddressFamily);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(_timeout); cts.CancelAfter(_timeout);
await _client.ConnectAsync(_host, _port, cts.Token).ConfigureAwait(false); await _client.ConnectAsync(target, _port, cts.Token).ConfigureAwait(false);
_stream = _client.GetStream(); _stream = _client.GetStream();
} }

View File

@@ -1,26 +1,30 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205; namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
/// <summary> /// <summary>
/// Tag map for the AutomationDirect DL205 device class. Mirrors what the ModbusPal /// Tag map for the AutomationDirect DL205 device class. Mirrors what the pymodbus
/// <c>.xmpp</c> profile in <c>ModbusPal/DL205.xmpp</c> exposes (or the real PLC, when /// <c>dl205.json</c> profile in <c>Pymodbus/dl205.json</c> exposes (or the real PLC, when
/// <see cref="ModbusSimulatorFixture"/> is pointed at one). /// <see cref="ModbusSimulatorFixture"/> is pointed at one).
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// This is the scaffold — each tag is deliberately generic so the smoke test has stable /// This is the scaffold — each tag is deliberately generic so the smoke test has stable
/// addresses to read. Device-specific quirk tests (word order, max-register, register-zero /// addresses to read. Device-specific quirk tests (word order, max-register, register-zero
/// access, etc.) will land in their own test classes alongside this profile as the user /// access, etc.) will land in their own test classes alongside this profile as the user
/// validates each behavior in ModbusPal; see <c>docs/v2/modbus-test-plan.md</c> §per-device /// validates each behavior in pymodbus; see <c>docs/v2/modbus-test-plan.md</c> §per-device
/// quirk catalog for the checklist. /// quirk catalog for the checklist.
/// </remarks> /// </remarks>
public static class DL205Profile public static class DL205Profile
{ {
/// <summary>Holding register the smoke test reads. Address 100 sidesteps the DL205 /// <summary>
/// register-zero quirk (pending confirmation) — see modbus-test-plan.md.</summary> /// Holding register the smoke test writes + reads. Address 200 is the first cell of the
public const ushort SmokeHoldingRegister = 100; /// scratch HR range in both <c>Pymodbus/standard.json</c> (HR[200..209] = 0) and
/// <c>Pymodbus/dl205.json</c> (HR[4096..4103] added in PR 43 for the same purpose), so
/// the smoke test runs identically against either simulator profile. Originally
/// targeted HR[100] — moved to HR[200] when the standard profile claimed HR[100] as
/// the auto-incrementing register that drives subscribe-and-receive tests.
/// </summary>
public const ushort SmokeHoldingRegister = 200;
/// <summary>Expected value the ModbusPal profile seeds into register 100. When running /// <summary>Value the smoke test writes then reads back to assert round-trip integrity.</summary>
/// against a real DL205 (or a ModbusPal profile where this register is writable), the smoke
/// test seeds this value first, then reads it back.</summary>
public const short SmokeHoldingValue = 1234; public const short SmokeHoldingValue = 1234;
public static ModbusDriverOptions BuildOptions(string host, int port) => new() public static ModbusDriverOptions BuildOptions(string host, int port) => new()
@@ -32,7 +36,7 @@ public static class DL205Profile
Tags = Tags =
[ [
new ModbusTagDefinition( new ModbusTagDefinition(
Name: "DL205_Smoke_HReg100", Name: "Smoke_HReg200",
Region: ModbusRegion.HoldingRegisters, Region: ModbusRegion.HoldingRegisters,
Address: SmokeHoldingRegister, Address: SmokeHoldingRegister,
DataType: ModbusDataType.Int16, DataType: ModbusDataType.Int16,

View File

@@ -38,13 +38,13 @@ public sealed class DL205SmokeTests(ModbusSimulatorFixture sim)
// zeroed at simulator start, and tests must not depend on prior-test state per the // zeroed at simulator start, and tests must not depend on prior-test state per the
// test-plan conventions. // test-plan conventions.
var writeResults = await driver.WriteAsync( var writeResults = await driver.WriteAsync(
[new(FullReference: "DL205_Smoke_HReg100", Value: (short)DL205Profile.SmokeHoldingValue)], [new(FullReference: "Smoke_HReg200", Value: (short)DL205Profile.SmokeHoldingValue)],
TestContext.Current.CancellationToken); TestContext.Current.CancellationToken);
writeResults.Count.ShouldBe(1); writeResults.Count.ShouldBe(1);
writeResults[0].StatusCode.ShouldBe(0u, "write must succeed against the ModbusPal DL205 profile"); writeResults[0].StatusCode.ShouldBe(0u, "write must succeed against the ModbusPal DL205 profile");
var readResults = await driver.ReadAsync( var readResults = await driver.ReadAsync(
["DL205_Smoke_HReg100"], ["Smoke_HReg200"],
TestContext.Current.CancellationToken); TestContext.Current.CancellationToken);
readResults.Count.ShouldBe(1); readResults.Count.ShouldBe(1);
readResults[0].StatusCode.ShouldBe(0u); readResults[0].StatusCode.ShouldBe(0u);

View File

@@ -1,192 +0,0 @@
<?xml version="1.0" encoding="ISO-8859-1" ?>
<!DOCTYPE modbuspal_project SYSTEM "modbuspal.dtd">
<!--
DL205.xmpp — AutomationDirect DirectLOGIC DL205 / DL260 quirk simulator.
Slave id 1 on TCP 502. Models the real-PLC behaviors documented in
docs/v2/dl205.md as concrete register values, so integration tests can
assert each quirk WITHOUT a live PLC. The driver is correct when reads
against this profile produce the same logical values that an
AutomationDirect-aware client would see.
BIG WARNING: every "interesting" register here is encoded as a raw 16-bit
integer. ModbusPal 1.6b serves whatever you put in `value="..."` straight
onto the wire as a 16-bit big-endian register; it has no String / BCD /
Float / WordSwap binding (only SINT16 / SINT32 / FLOAT32 + word-order, none
of which capture the byte-level packing the DL series uses). So strings,
BCD, and CDAB floats live here as opaque integers with the math worked out
in the comment above each register. That math is reproduced in
docs/v2/dl205.md so the two stay in sync.
If this profile grows beyond ~50 quirky registers, switch to pymodbus
(see ModbusPal/README.md §"alternatives") — the magic-number table will
become unreadable. For the planned 12 DL205_<behavior> tests, raw values
are fine.
Loaded via the ModbusPal GUI: File > Load > pick this file > Run.
Run only ONE simulator at a time (they share TCP 502); to switch between
Standard and DL205, stop one before loading the other.
-->
<modbuspal_project>
<idgen value="200"/>
<links selected="TCP/IP">
<tcpip port="502"/>
<serial com="COM 1" baudrate="9600" parity="even" stops="1">
<flowcontrol xonxoff="false" rtscts="false"/>
</serial>
</links>
<slave id="1" enabled="true" name="DL205Sim" implementation="modbus">
<holding_registers>
<!-- ============================================================
V-MEMORY ADDRESSING MARKERS
============================================================
DirectLOGIC V-memory is octal natively; the CPU translates
V<oct> -> Modbus PDU <decimal>. Tests verify our address
helper produces the right PDU offset for known V-addresses.
Marker values are arbitrary but distinctive so a test that
reads the wrong PDU sees Goodread+wrong-value, not zero.
-->
<!-- V0 (octal) = PDU 0x0000. Decisively proves register 0 is valid
on DL205/DL260 with H2-ECOM100 in absolute mode (the default).
The "rejects register 0" rumour was a DL05/DL06 relative-mode
artefact — see dl205.md §Register Zero. -->
<!-- 0xCAFE = 51966 (signed 16-bit: -13570) -->
<register address="0" value="-13570" name="V0_marker_0xCAFE"/>
<!-- V2000 octal = decimal 1024 = PDU 0x0400. -->
<!-- 0x2000 = 8192 -->
<register address="1024" value="8192" name="V2000_marker_0x2000"/>
<!-- V40400 octal = decimal 8448 = PDU 0x2100. Proves the
"V40400 = register 0" myth wrong on absolute-mode firmware. -->
<!-- 0x4040 = 16448 -->
<register address="8448" value="16448" name="V40400_marker_0x4040"/>
<!-- ============================================================
STRING PACKING (the user's headline quirk)
============================================================
Two ASCII chars per register, FIRST CHAR in the LOW byte.
"Hello" at HR[0x410..0x412]:
HR[0x410] = 'H' (0x48) lo, 'e' (0x65) hi -> 0x6548 = 25928
HR[0x411] = 'l' (0x6C) lo, 'l' (0x6C) hi -> 0x6C6C = 27756
HR[0x412] = 'o' (0x6F) lo, '\0' (0x00) hi -> 0x006F = 111
A textbook (high-byte-first) decoder reads "eH" "ll" "\0o"
and prints "eHll \0o" — that's exactly the failure mode the
DL205 string test asserts NOT happens once we add the
ModbusStringByteOrder=LowFirst option to the driver.
Test: DL205_String_low_byte_first_within_register. -->
<register address="1040" value="25928" name="HelloStr_lo='H'_hi='e'"/>
<register address="1041" value="27756" name="HelloStr_lo='l'_hi='l'"/>
<register address="1042" value="111" name="HelloStr_lo='o'_hi=null"/>
<!-- ============================================================
32-BIT FLOAT IN CDAB WORD ORDER
============================================================
IEEE 754 float 1.5f = 0x3FC00000.
Standard ABCD: HR[N]=0x3FC0, HR[N+1]=0x0000
DL205 CDAB: HR[N]=0x0000, HR[N+1]=0x3FC0 (LOW word first)
Test: DL205_Float32_word_order_is_CDAB.
Driver must use ModbusByteOrder=WordSwap to decode this as 1.5. -->
<register address="1056" value="0" name="FloatCDAB_lo_word"/>
<!-- 0x3FC0 = 16320 -->
<register address="1057" value="16320" name="FloatCDAB_hi_word"/>
<!-- ============================================================
BCD-ENCODED REGISTER (DirectLOGIC default numeric storage)
============================================================
Ladder value 1234 stored as 0x1234 = 4660 (BCD nibbles, NOT
binary 1234 = 0x04D2). A driver in binary-int mode reads 4660
and reports the wrong value; in BCD mode it nibble-decodes
0x1234 -> 1234. Test: DL205_BCD_register_decodes_as_decimal. -->
<!-- 0x1234 = 4660 -->
<register address="1072" value="4660" name="BCD_1234_as_0x1234"/>
<!-- ============================================================
LOAD-LIMIT BOUNDARY MARKERS
============================================================
The DL series caps FC03 at 128 registers (above spec's 125)
and FC16 at 100 (BELOW spec's 123). The cap-tests don't need
specific values — they assert exception 03 IllegalDataValue
on an over-sized request. We pre-seed a contiguous block at
0x500..0x57F (128 regs) so a 128-register read returns Good
and a 129-register read can be tried for the failure case.
Per-register values: address - 0x500 (so HR[0x500]=0,
HR[0x501]=1, ..., HR[0x57F]=127). Easy mental verification.
Test: DL205_FC03_128_registers_returns_Good. -->
<!-- (Generated programmatically below for brevity — first / last + spot-check) -->
<register address="1280" value="0" name="FC03Block_first"/>
<register address="1281" value="1"/>
<register address="1282" value="2"/>
<register address="1343" value="63" name="FC03Block_mid"/>
<register address="1407" value="127" name="FC03Block_last"/>
<!-- Note: ModbusPal serves unlisted addresses as 0 by default for
reads that fall within the configured slave's address space.
The block-test relies on that behavior; the hand-listed
entries above are sanity markers. If the driver later wants
byte-perfect comparison across the whole 128-register range,
expand this section to one element per address (or switch to
pymodbus). -->
</holding_registers>
<coils>
<!-- ============================================================
COIL / DISCRETE-INPUT MAPPING MARKERS (DL260 layout)
============================================================
Per dl205.md, on the DL260:
X inputs -> discrete inputs 0..511 (FC02)
Y outputs -> coils 2048..2559 (FC01/05)
C relays -> coils 3072..4095 (FC01/05)
ModbusPal 1.6b does NOT have a discrete-inputs section in
the official build, so the X-input markers can't be
encoded faithfully (the driver test for FC02 against this
profile will need a fork or pymodbus). The Y and C coil
markers ARE encodable here.
-->
<!-- Y0 marker — coil 2048 ON proves "Y0 maps to coil 2048" mapping.
Test: DL205_Y0_maps_to_coil_2048. -->
<coil address="2048" value="1" name="Y0_marker"/>
<coil address="2049" value="0"/>
<coil address="2050" value="1"/>
<!-- C0 marker — coil 3072 ON proves "C0 maps to coil 3072" mapping.
Test: DL205_C0_maps_to_coil_3072. -->
<coil address="3072" value="1" name="C0_marker"/>
<coil address="3073" value="0"/>
<coil address="3074" value="1"/>
<!-- Scratch coils 4000..4007 for write-roundtrip tests against
the C-relay range. C ranges are writable on the real DL260. -->
<coil address="4000" value="0" name="Cscratch_0"/>
<coil address="4001" value="0"/>
<coil address="4002" value="0"/>
<coil address="4003" value="0"/>
<coil address="4004" value="0"/>
<coil address="4005" value="0"/>
<coil address="4006" value="0"/>
<coil address="4007" value="0"/>
</coils>
<tuning>
<!-- Zero delay / zero error rate. The DL205 H2-ECOM has a typical
2-10ms scan-cycle delay; if a test wants to simulate that,
tune via the ModbusPal GUI (Tuning > Reply delay). -->
<reply_delay min="0" max="0"/>
<error_rates no_reply="0.0"/>
</tuning>
</slave>
</modbuspal_project>

View File

@@ -1,105 +0,0 @@
# ModbusPal simulator profiles
Two hand-authored `.xmpp` profiles you load into ModbusPal to drive the
integration-test suite without a real PLC:
| File | What it simulates | Test category |
|---|---|---|
| [`Standard.xmpp`](Standard.xmpp) | Generic Modbus TCP server — HR[0..31] = address-as-value, alternating coils, one auto-incrementing register at HR[100] for subscribe tests, scratch ranges for write-roundtrip tests. | `Trait=Standard` |
| [`DL205.xmpp`](DL205.xmpp) | 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. | `Trait=DL205` |
Both listen on TCP **port 502** (the standard Modbus port — change in the
ModbusPal GUI if a port conflict). Run **only one at a time** since they
share the port.
## Getting started
1. Download ModbusPal 1.6b from
[SourceForge](https://sourceforge.net/projects/modbuspal/) — `modbuspal.jar`.
Requires Java 8+ (Java 17/21 work but emit Swing deprecation warnings).
2. `java -jar modbuspal.jar` to launch the GUI.
3. **File > Load** → pick `Standard.xmpp` (or `DL205.xmpp`).
4. Click the **Run** button (top-right of the toolbar) to start serving on TCP 502.
5. `dotnet test tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests`
tests auto-skip with a clear `SkipReason` if the TCP probe at the
configured endpoint fails within 2 seconds (`ModbusSimulatorFixture`).
## Switching between Standard and DL205
Stop the running simulator (toolbar's **Stop** button), **File > Load**
the other profile, **Run**.
## Environment variables
- `MODBUS_SIM_ENDPOINT` — override the simulator endpoint
(`host:port`). Defaults to `localhost:502`. Useful when pointing the suite
at a real PLC on the bench, or running ModbusPal on a non-default port.
## What's encoded in each profile
### Standard
- HR[0..31]: each register's value equals its address.
- HR[100]: bound to a `LinearGenerator` (0..65535 over 60s, looping) — drives
subscribe-and-receive tests.
- HR[200..209]: scratch range for write-roundtrip tests.
- Coils[0..31]: alternating on/off (even=on).
- Coils[100..109]: scratch range.
### DL205 (per `docs/v2/dl205.md`)
| HR address | Quirk demonstrated | Raw value | Decoded value |
|---|---|---|---|
| `0` | Register zero is valid (rejects-register-0 rumour disproved) | `-13570` (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) | address 1280 | 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 |
## Limitations of ModbusPal 1.6b
- **Only `holding_registers` + `coils`** sections in the official build —
no `input_registers` (FC04) and no `discrete_inputs` (FC02). DL205's
X-input markers can't be encoded faithfully here. Tests for FC02 / FC04
wait for a fork (e.g. `SCADA-LTS/ModbusPal`) or a pymodbus rewrite.
- **No semantic bindings** for strings / BCD / arbitrary byte layouts. The
DL205 profile encodes everything as pre-computed raw 16-bit integers
with the math worked out in inline comments. Anything fancier becomes
unreadable above ~50 quirky registers — switch to pymodbus when that
threshold approaches.
- **Project is abandoned** since 1.6b on the official SourceForge listing.
Active forks: `SCADA-LTS/ModbusPal`, `ControlThings-io/modbuspal`,
`mrhenrike/ModbusPalEnhanced`.
- **No headless mode** in the official 1.6b JAR (`-loadFile` / `-hide`
flags exist only in source-built forks). For CI use, plan to switch to
pymodbus's `ModbusSimulatorServer` (JSON config, scriptable callbacks,
first-class headless).
- **CVE-2018-10832** XXE in `.xmpp` import. Don't import `.xmpp` files from
untrusted sources. Profiles in this repo are author-controlled; safe.
## Alternatives if ModbusPal stops working
| Tool | Pros | Cons |
|---|---|---|
| **pymodbus `ModbusSimulatorServer`** | Headless-first, JSON config, per-register seeding, custom callbacks for byte-level layouts. Best CI fit. | Python dependency. |
| **diagslave** | Simple, headless, fast. | Flat register banks; no per-address seeding from config; no scripting. |
| **ModbusMechanic** | Headless config-file mode. | Lightly documented. |
| **ModRSsim2** | Windows GUI, CSV import, scripting. | GUI-centric. |
## File format reference
ModbusPal `.xmpp` is XML with a DTD reference (`modbuspal.dtd`). Root element
`<modbuspal_project>` with three children:
- `<idgen value="N"/>` — internal id counter (start at 100+)
- `<links selected="TCP/IP">``<tcpip port="502"/>` for TCP listen, plus a `<serial>` placeholder
- One or more `<slave id="..." enabled="true" name="..." implementation="modbus">` containing `<holding_registers>` (`<register address="N" value="V"/>`), `<coils>` (`<coil address="N" value="0|1"/>`), `<tuning>`
Per-register `<binding automation="..." class="Binding_SINT16|SINT32|FLOAT32" order="0|1"/>` ties a register to a `LinearGenerator` / `RandomGenerator` / `SineGenerator` automation declared at the project level. `order="0"` = LSW, `order="1"` = MSW for 32-bit types. There is **no string binding** and **no byte-swap-within-word** binding.

View File

@@ -1,166 +0,0 @@
<?xml version="1.0" encoding="ISO-8859-1" ?>
<!DOCTYPE modbuspal_project SYSTEM "modbuspal.dtd">
<!--
Standard.xmpp — generic Modbus TCP server.
Slave id 1 on TCP port 502. Holding registers 0..31 seeded with their own
address as value (so HR[0]=0, HR[5]=5, easy mental map for diagnostics).
Coils 0..31 alternate true/false. One auto-incrementing register at HR[100]
bound to the "Tick" automation (1 Hz, wraps 0..65535) so subscribe-and-receive
integration tests have something that actually changes without a write.
Loaded via the ModbusPal GUI: File > Load > pick this file > Run.
The integration test fixture (MODBUS_SIM_ENDPOINT, default localhost:502)
connects on TCP. Tests filter by Trait=Standard.
Limitations of ModbusPal 1.6b that shape this profile:
- Only holding_registers + coils sections (no input_registers, no
discrete_inputs in the official 1.6b build). Tests for FC04 / FC02
wait until we switch to a fork or pymodbus.
- Per-register elements only — sparse maps fine, range form not
supported in the serialized format (the GUI lets you Add range,
but the .xmpp file expands them).
- Listens on all interfaces (no bind-address attribute).
-->
<modbuspal_project>
<!-- Monotonic id generator ModbusPal uses to internally name automations / slaves. -->
<idgen value="100"/>
<!-- TCP listen on 502 (standard Modbus port). Override via ModbusPal GUI if conflicting. -->
<links selected="TCP/IP">
<tcpip port="502"/>
<serial com="COM 1" baudrate="9600" parity="even" stops="1">
<flowcontrol xonxoff="false" rtscts="false"/>
</serial>
</links>
<!--
Tick automation: 0..65535 over 60 seconds, looping. Bound to HR[100]
below so each second the register climbs by ~1092. Slow enough that
a 250ms-poll integration test sees discrete jumps; fast enough that
a 5s subscribe test sees several change notifications.
-->
<automation name="Tick" step="1.0" loop="true" init="0.0">
<generator class="LinearGenerator" duration="60.0">
<start value="0.0" relative="false"/>
<end value="65535.0" relative="false"/>
</generator>
</automation>
<slave id="1" enabled="true" name="StandardSim" implementation="modbus">
<holding_registers>
<!-- HR[0..31] = address-as-value. Easy mental map for diagnostics + read tests. -->
<register address="0" value="0"/>
<register address="1" value="1"/>
<register address="2" value="2"/>
<register address="3" value="3"/>
<register address="4" value="4"/>
<register address="5" value="5"/>
<register address="6" value="6"/>
<register address="7" value="7"/>
<register address="8" value="8"/>
<register address="9" value="9"/>
<register address="10" value="10"/>
<register address="11" value="11"/>
<register address="12" value="12"/>
<register address="13" value="13"/>
<register address="14" value="14"/>
<register address="15" value="15"/>
<register address="16" value="16"/>
<register address="17" value="17"/>
<register address="18" value="18"/>
<register address="19" value="19"/>
<register address="20" value="20"/>
<register address="21" value="21"/>
<register address="22" value="22"/>
<register address="23" value="23"/>
<register address="24" value="24"/>
<register address="25" value="25"/>
<register address="26" value="26"/>
<register address="27" value="27"/>
<register address="28" value="28"/>
<register address="29" value="29"/>
<register address="30" value="30"/>
<register address="31" value="31"/>
<!-- HR[100] auto-increments via the Tick automation. Subscribe tests
read this and expect to see at least 2 change notifications in 5s. -->
<register address="100" value="0" name="AutoIncrement">
<binding automation="Tick" class="Binding_SINT16" order="0"/>
</register>
<!-- HR[200..209] — scratch range left at 0 for write-roundtrip tests
to mutate freely without touching the address-as-value set above. -->
<register address="200" value="0" name="Scratch0"/>
<register address="201" value="0" name="Scratch1"/>
<register address="202" value="0" name="Scratch2"/>
<register address="203" value="0" name="Scratch3"/>
<register address="204" value="0" name="Scratch4"/>
<register address="205" value="0" name="Scratch5"/>
<register address="206" value="0" name="Scratch6"/>
<register address="207" value="0" name="Scratch7"/>
<register address="208" value="0" name="Scratch8"/>
<register address="209" value="0" name="Scratch9"/>
</holding_registers>
<coils>
<!-- Coils 0..31 alternating. Even = on, odd = off. -->
<coil address="0" value="1"/>
<coil address="1" value="0"/>
<coil address="2" value="1"/>
<coil address="3" value="0"/>
<coil address="4" value="1"/>
<coil address="5" value="0"/>
<coil address="6" value="1"/>
<coil address="7" value="0"/>
<coil address="8" value="1"/>
<coil address="9" value="0"/>
<coil address="10" value="1"/>
<coil address="11" value="0"/>
<coil address="12" value="1"/>
<coil address="13" value="0"/>
<coil address="14" value="1"/>
<coil address="15" value="0"/>
<coil address="16" value="1"/>
<coil address="17" value="0"/>
<coil address="18" value="1"/>
<coil address="19" value="0"/>
<coil address="20" value="1"/>
<coil address="21" value="0"/>
<coil address="22" value="1"/>
<coil address="23" value="0"/>
<coil address="24" value="1"/>
<coil address="25" value="0"/>
<coil address="26" value="1"/>
<coil address="27" value="0"/>
<coil address="28" value="1"/>
<coil address="29" value="0"/>
<coil address="30" value="1"/>
<coil address="31" value="0"/>
<!-- Coils 100..109 — scratch range for write-roundtrip tests. -->
<coil address="100" value="0"/>
<coil address="101" value="0"/>
<coil address="102" value="0"/>
<coil address="103" value="0"/>
<coil address="104" value="0"/>
<coil address="105" value="0"/>
<coil address="106" value="0"/>
<coil address="107" value="0"/>
<coil address="108" value="0"/>
<coil address="109" value="0"/>
</coils>
<tuning>
<!-- Zero artificial reply delay or error rate. Set non-zero in the GUI to
simulate a slow / lossy link without re-authoring the file. -->
<reply_delay min="0" max="0"/>
<error_rates no_reply="0.0"/>
</tuning>
</slave>
</modbuspal_project>

View File

@@ -3,8 +3,9 @@ using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests; namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
/// <summary> /// <summary>
/// Reachability probe for a Modbus TCP simulator (ModbusPal or a real PLC). Parses /// Reachability probe for a Modbus TCP simulator (pymodbus-driven, see
/// <c>MODBUS_SIM_ENDPOINT</c> (default <c>localhost:502</c>) and TCP-connects once at /// <c>Pymodbus/serve.ps1</c>) or a real PLC. Parses
/// <c>MODBUS_SIM_ENDPOINT</c> (default <c>localhost:5020</c> per PR 43) and TCP-connects once at
/// fixture construction. Each test checks <see cref="SkipReason"/> and calls /// fixture construction. Each test checks <see cref="SkipReason"/> and calls
/// <c>Assert.Skip</c> when the endpoint was unreachable, so a dev box without a running /// <c>Assert.Skip</c> when the endpoint was unreachable, so a dev box without a running
/// simulator still passes `dotnet test` cleanly — matches the Galaxy live-smoke pattern in /// simulator still passes `dotnet test` cleanly — matches the Galaxy live-smoke pattern in
@@ -25,7 +26,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
/// </remarks> /// </remarks>
public sealed class ModbusSimulatorFixture : IAsyncDisposable public sealed class ModbusSimulatorFixture : IAsyncDisposable
{ {
private const string DefaultEndpoint = "localhost:502"; // PR 43: default port is 5020 (pymodbus convention) instead of 502 (Modbus standard).
// Picking 5020 sidesteps the privileged-port admin requirement on Windows + matches the
// port baked into the pymodbus simulator JSON profiles in Pymodbus/. Override with
// MODBUS_SIM_ENDPOINT to point at a real PLC on its native port 502.
private const string DefaultEndpoint = "localhost:5020";
private const string EndpointEnvVar = "MODBUS_SIM_ENDPOINT"; private const string EndpointEnvVar = "MODBUS_SIM_ENDPOINT";
public string Host { get; } public string Host { get; }
@@ -41,18 +46,30 @@ public sealed class ModbusSimulatorFixture : IAsyncDisposable
try try
{ {
using var client = new TcpClient(); // Force IPv4 family on the probe — pymodbus's TCP server binds 0.0.0.0 (IPv4 only)
var task = client.ConnectAsync(Host, Port); // while .NET's TcpClient default-resolves "localhost" → IPv6 ::1 first, fails to
// connect, and only then tries IPv4. Under .NET 10 the IPv6 fail surfaces as a
// 2s timeout (no graceful fallback by default), so the C# probe times out even
// though a PowerShell probe of the same endpoint succeeds. Resolving + dialing
// explicit IPv4 sidesteps the dual-stack ordering.
using var client = new TcpClient(System.Net.Sockets.AddressFamily.InterNetwork);
var task = client.ConnectAsync(
System.Net.Dns.GetHostAddresses(Host)
.FirstOrDefault(a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
?? System.Net.IPAddress.Loopback,
Port);
if (!task.Wait(TimeSpan.FromSeconds(2)) || !client.Connected) if (!task.Wait(TimeSpan.FromSeconds(2)) || !client.Connected)
{ {
SkipReason = $"Modbus simulator at {Host}:{Port} did not accept a TCP connection within 2s. " + SkipReason = $"Modbus simulator at {Host}:{Port} did not accept a TCP connection within 2s. " +
$"Start ModbusPal (or override {EndpointEnvVar}) and re-run."; $"Start the pymodbus simulator (Pymodbus\\serve.ps1 -Profile standard) " +
$"or override {EndpointEnvVar}, then re-run.";
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
SkipReason = $"Modbus simulator at {Host}:{Port} unreachable: {ex.GetType().Name}: {ex.Message}. " + SkipReason = $"Modbus simulator at {Host}:{Port} unreachable: {ex.GetType().Name}: {ex.Message}. " +
$"Start ModbusPal (or override {EndpointEnvVar}) and re-run."; $"Start the pymodbus simulator (Pymodbus\\serve.ps1 -Profile standard) " +
$"or override {EndpointEnvVar}, then re-run.";
} }
} }

View File

@@ -0,0 +1,163 @@
# 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

View File

@@ -0,0 +1,118 @@
{
"_comment": "DL205.json — DirectLOGIC DL205/DL260 quirk simulator. Models docs/v2/dl205.md as concrete register values. NOTE: pymodbus rejects unknown keys at device-list / setup level; explanatory comments live at top-level _comment + in README + git. Inline _quirk keys WITHIN individual register entries are accepted by pymodbus 3.13.0 (it only validates addr / value / action / parameters per entry). Each quirky uint16 is a pre-computed raw 16-bit value; pymodbus serves it verbatim. shared blocks=true matches DL series memory model. write list mirrors each seeded block — pymodbus rejects sweeping write ranges that include undefined cells.",
"server_list": {
"srv": {
"comm": "tcp",
"host": "0.0.0.0",
"port": 5020,
"framer": "socket",
"device_id": 1
}
},
"device_list": {
"dev": {
"setup": {
"co size": 16384,
"di size": 8192,
"hr size": 16384,
"ir size": 1024,
"shared blocks": true,
"type exception": false,
"defaults": {
"value": {"bits": 0, "uint16": 0, "uint32": 0, "float32": 0.0, "string": " "},
"action": {"bits": null, "uint16": null, "uint32": null, "float32": null, "string": null}
}
},
"invalid": [],
"write": [
[0, 0],
[200, 209],
[1024, 1024],
[1040, 1042],
[1056, 1057],
[1072, 1072],
[1280, 1282],
[1343, 1343],
[1407, 1407],
[2048, 2050],
[3072, 3074],
[4000, 4007],
[8448, 8448]
],
"uint16": [
{"_quirk": "V0 marker. HR[0]=0xCAFE proves register 0 is valid on DL205/DL260 (rejects-register-0 was a DL05/DL06 relative-mode artefact). 0xCAFE = 51966.",
"addr": 0, "value": 51966},
{"_quirk": "Scratch HR range 200..209 — mirrors the standard.json scratch range so the smoke test (DL205Profile.SmokeHoldingRegister=200) round-trips identically against either profile.",
"addr": 200, "value": 0},
{"addr": 201, "value": 0},
{"addr": 202, "value": 0},
{"addr": 203, "value": 0},
{"addr": 204, "value": 0},
{"addr": 205, "value": 0},
{"addr": 206, "value": 0},
{"addr": 207, "value": 0},
{"addr": 208, "value": 0},
{"addr": 209, "value": 0},
{"_quirk": "V2000 marker. V2000 octal = decimal 1024 = PDU 0x0400. Marker 0x2000 = 8192.",
"addr": 1024, "value": 8192},
{"_quirk": "V40400 marker. V40400 octal = decimal 8448 = PDU 0x2100 (NOT register 0). Marker 0x4040 = 16448.",
"addr": 8448, "value": 16448},
{"_quirk": "String 'Hello' first char in LOW byte. HR[0x410] = 'H'(0x48) lo + 'e'(0x65) hi = 0x6548 = 25928.",
"addr": 1040, "value": 25928},
{"_quirk": "String 'Hello' second char-pair: 'l'(0x6C) lo + 'l'(0x6C) hi = 0x6C6C = 27756.",
"addr": 1041, "value": 27756},
{"_quirk": "String 'Hello' third char-pair: 'o'(0x6F) lo + null(0x00) hi = 0x006F = 111.",
"addr": 1042, "value": 111},
{"_quirk": "Float32 1.5f in CDAB word order. IEEE 754 1.5 = 0x3FC00000. CDAB = low word first: HR[0x420]=0x0000, HR[0x421]=0x3FC0=16320.",
"addr": 1056, "value": 0},
{"_quirk": "Float32 1.5f CDAB high word.",
"addr": 1057, "value": 16320},
{"_quirk": "BCD register. Decimal 1234 stored as BCD nibbles 0x1234 = 4660. NOT binary 1234 (= 0x04D2).",
"addr": 1072, "value": 4660},
{"_quirk": "FC03 cap test marker — first cell of a 128-register span the FC03 cap test reads. Other cells in the span aren't seeded explicitly, so reads of HR[1283..1342] / 1344..1406 return the default 0; the seeded markers at 1280, 1281, 1282, 1343, 1407 prove the span boundaries.",
"addr": 1280, "value": 0},
{"addr": 1281, "value": 1},
{"addr": 1282, "value": 2},
{"addr": 1343, "value": 63},
{"addr": 1407, "value": 127}
],
"bits": [
{"_quirk": "Y0 marker. DL260 maps Y0 to coil 2048 (0-based). Coil 2048 = ON proves the mapping.",
"addr": 2048, "value": 1},
{"addr": 2049, "value": 0},
{"addr": 2050, "value": 1},
{"_quirk": "C0 marker. DL260 maps C0 to coil 3072 (0-based). Coil 3072 = ON proves the mapping.",
"addr": 3072, "value": 1},
{"addr": 3073, "value": 0},
{"addr": 3074, "value": 1},
{"_quirk": "Scratch C-relays for write-roundtrip tests against the writable C range.",
"addr": 4000, "value": 0},
{"addr": 4001, "value": 0},
{"addr": 4002, "value": 0},
{"addr": 4003, "value": 0},
{"addr": 4004, "value": 0},
{"addr": 4005, "value": 0},
{"addr": 4006, "value": 0},
{"addr": 4007, "value": 0}
],
"uint32": [],
"float32": [],
"string": [],
"repeat": []
}
}
}

View File

@@ -0,0 +1,60 @@
<#
.SYNOPSIS
Launches the pymodbus simulator with one of the integration-test profiles
(Standard or DL205). Foreground process — Ctrl+C to stop.
.PARAMETER Profile
Which simulator profile to run: 'standard' or 'dl205'. Both bind TCP 5020 by
default so they can't run simultaneously on the same box.
.PARAMETER HttpPort
Port for pymodbus's optional web UI / REST API. Default 8080. Pass 0 to
disable (passes --no_http).
.EXAMPLE
.\serve.ps1 -Profile standard
Starts the standard server on TCP 5020 with web UI on 8080.
.EXAMPLE
.\serve.ps1 -Profile dl205 -HttpPort 0
Starts the DL205 server on TCP 5020, no web UI.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [ValidateSet('standard', 'dl205')] [string]$Profile,
[int]$HttpPort = 8080
)
$ErrorActionPreference = 'Stop'
$here = $PSScriptRoot
# Confirm pymodbus.simulator is on PATH — clearer message than the
# 'CommandNotFoundException' dotnet style.
$cmd = Get-Command pymodbus.simulator -ErrorAction SilentlyContinue
if (-not $cmd) {
Write-Error "pymodbus.simulator not found. Install with: pip install 'pymodbus[simulator]==3.13.0'"
exit 1
}
$jsonFile = Join-Path $here "$Profile.json"
if (-not (Test-Path $jsonFile)) {
Write-Error "Profile config not found: $jsonFile"
exit 1
}
$args = @(
'--modbus_server', 'srv',
'--modbus_device', 'dev',
'--json_file', $jsonFile
)
if ($HttpPort -gt 0) {
$args += @('--http_port', $HttpPort)
Write-Host "Web UI will be at http://localhost:$HttpPort"
} else {
$args += '--no_http'
}
Write-Host "Starting pymodbus simulator: profile=$Profile TCP=localhost:5020"
Write-Host "Ctrl+C to stop."
& pymodbus.simulator @args

View File

@@ -0,0 +1,97 @@
{
"_comment": "Standard.json — generic Modbus TCP server for the integration suite. See ../README.md. NOTE: pymodbus rejects unknown keys at device-list / setup level; explanatory comments live in the README + git history. Layout: HR[0..31]=address-as-value, HR[100]=auto-increment, HR[200..209]=scratch, coils 1024..1055=alternating, coils 1100..1109=scratch. Coils live at 1024+ because pymodbus stores all 4 standard tables in ONE underlying cell array — bits and uint16 at the same address conflict (each cell can only be typed once).",
"server_list": {
"srv": {
"comm": "tcp",
"host": "0.0.0.0",
"port": 5020,
"framer": "socket",
"device_id": 1
}
},
"device_list": {
"dev": {
"setup": {
"co size": 2048,
"di size": 2048,
"hr size": 2048,
"ir size": 2048,
"shared blocks": true,
"type exception": false,
"defaults": {
"value": {"bits": 0, "uint16": 0, "uint32": 0, "float32": 0.0, "string": " "},
"action": {"bits": null, "uint16": null, "uint32": null, "float32": null, "string": null}
}
},
"invalid": [],
"write": [
[0, 31],
[100, 100],
[200, 209],
[1024, 1055],
[1100, 1109]
],
"uint16": [
{"addr": 0, "value": 0}, {"addr": 1, "value": 1},
{"addr": 2, "value": 2}, {"addr": 3, "value": 3},
{"addr": 4, "value": 4}, {"addr": 5, "value": 5},
{"addr": 6, "value": 6}, {"addr": 7, "value": 7},
{"addr": 8, "value": 8}, {"addr": 9, "value": 9},
{"addr": 10, "value": 10}, {"addr": 11, "value": 11},
{"addr": 12, "value": 12}, {"addr": 13, "value": 13},
{"addr": 14, "value": 14}, {"addr": 15, "value": 15},
{"addr": 16, "value": 16}, {"addr": 17, "value": 17},
{"addr": 18, "value": 18}, {"addr": 19, "value": 19},
{"addr": 20, "value": 20}, {"addr": 21, "value": 21},
{"addr": 22, "value": 22}, {"addr": 23, "value": 23},
{"addr": 24, "value": 24}, {"addr": 25, "value": 25},
{"addr": 26, "value": 26}, {"addr": 27, "value": 27},
{"addr": 28, "value": 28}, {"addr": 29, "value": 29},
{"addr": 30, "value": 30}, {"addr": 31, "value": 31},
{"addr": 100, "value": 0,
"action": "increment",
"parameters": {"minval": 0, "maxval": 65535}},
{"addr": 200, "value": 0}, {"addr": 201, "value": 0},
{"addr": 202, "value": 0}, {"addr": 203, "value": 0},
{"addr": 204, "value": 0}, {"addr": 205, "value": 0},
{"addr": 206, "value": 0}, {"addr": 207, "value": 0},
{"addr": 208, "value": 0}, {"addr": 209, "value": 0}
],
"bits": [
{"addr": 1024, "value": 1}, {"addr": 1025, "value": 0},
{"addr": 1026, "value": 1}, {"addr": 1027, "value": 0},
{"addr": 1028, "value": 1}, {"addr": 1029, "value": 0},
{"addr": 1030, "value": 1}, {"addr": 1031, "value": 0},
{"addr": 1032, "value": 1}, {"addr": 1033, "value": 0},
{"addr": 1034, "value": 1}, {"addr": 1035, "value": 0},
{"addr": 1036, "value": 1}, {"addr": 1037, "value": 0},
{"addr": 1038, "value": 1}, {"addr": 1039, "value": 0},
{"addr": 1040, "value": 1}, {"addr": 1041, "value": 0},
{"addr": 1042, "value": 1}, {"addr": 1043, "value": 0},
{"addr": 1044, "value": 1}, {"addr": 1045, "value": 0},
{"addr": 1046, "value": 1}, {"addr": 1047, "value": 0},
{"addr": 1048, "value": 1}, {"addr": 1049, "value": 0},
{"addr": 1050, "value": 1}, {"addr": 1051, "value": 0},
{"addr": 1052, "value": 1}, {"addr": 1053, "value": 0},
{"addr": 1054, "value": 1}, {"addr": 1055, "value": 0},
{"addr": 1100, "value": 0}, {"addr": 1101, "value": 0},
{"addr": 1102, "value": 0}, {"addr": 1103, "value": 0},
{"addr": 1104, "value": 0}, {"addr": 1105, "value": 0},
{"addr": 1106, "value": 0}, {"addr": 1107, "value": 0},
{"addr": 1108, "value": 0}, {"addr": 1109, "value": 0}
],
"uint32": [],
"float32": [],
"string": [],
"repeat": []
}
}
}

View File

@@ -24,7 +24,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Update="ModbusPal\**\*" CopyToOutputDirectory="PreserveNewest"/> <None Update="Pymodbus\**\*" CopyToOutputDirectory="PreserveNewest"/>
<None Update="DL205\**\*" CopyToOutputDirectory="PreserveNewest"/> <None Update="DL205\**\*" CopyToOutputDirectory="PreserveNewest"/>
</ItemGroup> </ItemGroup>