Phase 3 PR 43 — Swap ModbusPal to pymodbus for the integration-test simulator #42

Merged
dohertj2 merged 1 commits from phase-3-pr43-pymodbus-swap into v2 2026-04-18 20:52:48 -04:00
11 changed files with 459 additions and 498 deletions

View File

@@ -13,22 +13,30 @@ confirmed DL205 quirk lands in a follow-up PR as a named test in that project.
## Harness
**Chosen simulator: ModbusPal** (Java, scriptable). Rationale:
- Scriptable enough to mimic device-specific behaviors (non-standard register
layouts, custom exception codes, intentional response delays).
- 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.
**Chosen simulator: pymodbus 3.13.0** (`pip install 'pymodbus[simulator]==3.13.0'`).
Replaced ModbusPal in PR 43 — see `tests/.../Pymodbus/README.md` for the
trade-off rationale. Headline reasons:
**Setup pattern** (not yet codified in a script — will land alongside the integration
test project):
1. Install ModbusPal, load the per-device `.xmpp` profile from
`tests/Driver.Modbus.IntegrationTests/ModbusPal/` (TBD directory).
2. Start the simulator listening on `localhost:502` (or override via
`MODBUS_SIM_ENDPOINT` env var).
3. `dotnet test` the integration project — tests auto-skip when the endpoint is
unreachable, so forgetting to start the simulator doesn't wedge CI.
- **Headless** pure-Python CLI; no Java GUI, runs cleanly on a CI runner.
- **Maintained** — current stable 3.13.0; ModbusPal 1.6b is abandoned.
- **All four standard tables** (HR, IR, coils, DI) configurable; ModbusPal
1.6b only exposed HR + coils.
- **Built-in actions** (`increment`, `random`, `timestamp`, `uptime`) +
optional custom-Python actions for declarative dynamic behaviors.
- **Per-register raw uint16 seeding** — encoding the DL205 string-byte-order
/ 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
@@ -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
deliberately not used here — its value is speed + determinism, which doesn't
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
"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
- **PR 30 — Integration test project + DL205 profile scaffold** — **DONE**.
Shipped `tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests` with
`ModbusSimulatorFixture` (TCP-probe, skips with a clear `SkipReason` when the
endpoint is unreachable), `DL205/DL205Profile.cs` (tag map stub — one
writable holding register at address 100), and `DL205/DL205SmokeTests.cs`
(write-then-read round-trip). `ModbusPal/` directory holds the README
pointing at the to-be-committed `DL205.xmpp` profile.
- **PR 31+**: one PR per confirmed DL205 quirk, landing the named test + any
driver-side adjustment (e.g., retry on dropped TxId) needed to pass it. Drop
the `DL205.xmpp` profile into `ModbusPal/` alongside the first quirk PR.
endpoint is unreachable), `DL205/DL205Profile.cs` (tag map stub), and
`DL205/DL205SmokeTests.cs` (write-then-read round-trip).
- **PR 41 — DL205 quirk catalog doc** — **DONE**. `docs/v2/dl205.md`
documents every DL205/DL260 Modbus divergence with primary-source citations.
- **PR 42 — ModbusPal `.xmpp` profiles** — **SUPERSEDED by PR 43**. Replaced
with pymodbus JSON because ModbusPal 1.6b is abandoned, GUI-only, and only
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

@@ -1,15 +1,15 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
/// <summary>
/// Tag map for the AutomationDirect DL205 device class. Mirrors what the ModbusPal
/// <c>.xmpp</c> profile in <c>ModbusPal/DL205.xmpp</c> exposes (or the real PLC, when
/// Tag map for the AutomationDirect DL205 device class. Mirrors what the pymodbus
/// <c>dl205.json</c> profile in <c>Pymodbus/dl205.json</c> exposes (or the real PLC, when
/// <see cref="ModbusSimulatorFixture"/> is pointed at one).
/// </summary>
/// <remarks>
/// 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
/// 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.
/// </remarks>
public static class DL205Profile
@@ -18,8 +18,8 @@ public static class DL205Profile
/// register-zero quirk (pending confirmation) — see modbus-test-plan.md.</summary>
public const ushort SmokeHoldingRegister = 100;
/// <summary>Expected value the ModbusPal profile seeds into register 100. When running
/// against a real DL205 (or a ModbusPal profile where this register is writable), the smoke
/// <summary>Expected value the pymodbus profile seeds into register 100. When running
/// against a real DL205 (or a pymodbus profile where this register is writable), the smoke
/// test seeds this value first, then reads it back.</summary>
public const short SmokeHoldingValue = 1234;

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;
/// <summary>
/// Reachability probe for a Modbus TCP simulator (ModbusPal or a real PLC). Parses
/// <c>MODBUS_SIM_ENDPOINT</c> (default <c>localhost:502</c>) and TCP-connects once at
/// Reachability probe for a Modbus TCP simulator (pymodbus-driven, see
/// <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
/// <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
@@ -25,7 +26,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
/// </remarks>
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";
public string Host { get; }
@@ -46,13 +51,15 @@ public sealed class ModbusSimulatorFixture : IAsyncDisposable
if (!task.Wait(TimeSpan.FromSeconds(2)) || !client.Connected)
{
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)
{
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,98 @@
{
"_comment": "DL205.json — AutomationDirect DirectLOGIC DL205/DL260 quirk simulator. Models each behavior in docs/v2/dl205.md as concrete register values so DL205_<behavior> integration tests can assert against this profile WITHOUT a live PLC. Loaded by `pymodbus.simulator`. See ../README.md. Per-quirk address layout matches the table in dl205.md exactly. `shared blocks: true` matches DL series behavior — coils/HR overlay the same word address space (a Y-output is both a discrete bit AND part of a system V-memory register).",
"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, 16383]
],
"_comment_uint16": "Holding-register seeds. Every quirky value is a raw uint16 with the byte math worked out in dl205.md so the simulator serves it verbatim — pymodbus does NOT decode strings, BCD, or float-CDAB on its own; that's the driver's job.",
"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": "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. Real DL205/DL260 FC03 caps at 128 registers (above spec's 125). HR[1280..1407] is 128 contiguous registers; rest of block defaults to 0.",
"addr": 1280, "value": 0},
{"addr": 1281, "value": 1},
{"addr": 1282, "value": 2},
{"addr": 1343, "value": 63, "_marker": "FC03Block_mid"},
{"addr": 1407, "value": 127, "_marker": "FC03Block_last"}
],
"_comment_bits": "Coils — Y outputs at 2048+, C relays at 3072+, scratch C at 4000-4007 for write tests. DL260 X inputs would be at discrete-input addresses 0..511 but pymodbus's shared-blocks mode + same-table-as-coils means those would conflict with HR seeds; FC02 tests against this profile use a separate discrete-input block instead — that's why `di size` is large but the X-input markers live in `bits` only when `shared blocks=false`. Document trade-off in README.",
"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,81 @@
{
"_comment": "Standard.json — generic Modbus TCP server for the integration suite. Loaded by `pymodbus.simulator`. See ../README.md for the launch command. Holding registers 0..31 are seeded with their address as value (HR[5]=5) for easy mental-map diagnostics. HR[100] auto-increments via pymodbus's built-in `increment` action so subscribe-and-receive integration tests have a register that ticks without a write. HR[200..209] is a scratch range left at 0 for write-roundtrip tests. Coils 0..31 alternate on/off (even=on); coils 100..109 scratch.",
"server_list": {
"srv": {
"comm": "tcp",
"host": "0.0.0.0",
"port": 5020,
"framer": "socket",
"device_id": 1
}
},
"device_list": {
"dev": {
"setup": {
"co size": 1024,
"di size": 1024,
"hr size": 1024,
"ir size": 1024,
"shared blocks": false,
"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, 1023]
],
"bits": [
{"addr": 0, "value": 1}, {"addr": 1, "value": 0},
{"addr": 2, "value": 1}, {"addr": 3, "value": 0},
{"addr": 4, "value": 1}, {"addr": 5, "value": 0},
{"addr": 6, "value": 1}, {"addr": 7, "value": 0},
{"addr": 8, "value": 1}, {"addr": 9, "value": 0},
{"addr": 10, "value": 1}, {"addr": 11, "value": 0},
{"addr": 12, "value": 1}, {"addr": 13, "value": 0},
{"addr": 14, "value": 1}, {"addr": 15, "value": 0},
{"addr": 16, "value": 1}, {"addr": 17, "value": 0},
{"addr": 18, "value": 1}, {"addr": 19, "value": 0},
{"addr": 20, "value": 1}, {"addr": 21, "value": 0},
{"addr": 22, "value": 1}, {"addr": 23, "value": 0},
{"addr": 24, "value": 1}, {"addr": 25, "value": 0},
{"addr": 26, "value": 1}, {"addr": 27, "value": 0},
{"addr": 28, "value": 1}, {"addr": 29, "value": 0},
{"addr": 30, "value": 1}, {"addr": 31, "value": 0}
],
"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}}
],
"uint32": [],
"float32": [],
"string": [],
"repeat": []
}
}
}

View File

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