diff --git a/docs/v2/dl205.md b/docs/v2/dl205.md new file mode 100644 index 0000000..b5ff16d --- /dev/null +++ b/docs/v2/dl205.md @@ -0,0 +1,295 @@ +# AutomationDirect DirectLOGIC DL205 / DL260 — Modbus quirks + +AutomationDirect's DirectLOGIC DL205 family (D2-250-1, D2-260, D2-262, D2-262M) and +its larger DL260 sibling speak Modbus TCP (via the H2-ECOM100 / H2-EBC100 Ethernet +coprocessors, and the DL260's built-in Ethernet port) and Modbus RTU (via the CPU +serial ports in "Modbus" mode). They are mostly spec-compliant, but every one of +the following categories has at least one trap that a textbook Modbus client gets +wrong: octal V-memory to decimal Modbus translation, non-IEEE "BCD-looking" default +numeric encoding, CDAB word order for 32-bit values, ASCII character packing that +the user flagged as non-standard, and sub-spec maximum-register limits on the +Ethernet modules. This document catalogues each quirk, cites primary sources, and +names the ModbusPal integration test we'd write for it (convention from +`docs/v2/modbus-test-plan.md`: `DL205_`). + +## Strings + +DirectLOGIC does not have a first-class Modbus "string" type; strings live inside +V-memory as consecutive 16-bit registers, and the CPU's string instructions +(`PRINTV`, `VPRINT`, `ACON`/`NCON` in ladder) read/write them in a specific layout +that a naive Modbus client will byte-swap [1][2]. + +- **Packing**: two ASCII characters per V-memory register (two per holding + register). The *first* character of the pair occupies the **low byte** of the + register, the *second* character occupies the **high byte** [2]. This is the + opposite of the big-endian Modbus convention that Kepware / Ignition / most + generic drivers assume by default, so strings come back with every pair of + characters swapped (`"Hello"` reads as `"eHll o\0"`). +- **Termination**: null-terminated (`0x00` in the character byte). There is no + length prefix. Writes must pad the final register's unused byte with `0x00`. +- **Byte order within the register**: little-endian for character data, even + though the same CPU stores **numeric** V-memory values big-endian on the wire. + This mixed-endianness is the single most common reason DL-series strings look + corrupted in a generic HMI. Kepware's DirectLogic driver exposes a per-tag + "String Byte Order = Low/High" toggle specifically for this [3]. +- **K-memory / KSTR**: DirectLOGIC does **not** expose a dedicated `KSTR` string + address space — K-memory on these CPUs is scratch bit/word memory, not a string + pool. Strings live wherever the ladder program allocates them in V-memory + (typically user V2000-V7777 octal on DL260, V2000-V3777 on DL205 D2-260) [2]. +- **Maximum length**: bounded only by the V-memory region assigned. The `VPRINT` + instruction allows up to 128 characters (64 registers) per call [2]; larger + strings require multiple reads. +- **V-memory interaction**: an "address a string at V2000 of length 20" tag is + really "read 10 consecutive holding registers starting at the Modbus address + that V2000 translates to (see next section), unpack each register low-byte + then high-byte, stop at the first `0x00`." + +Test names: +`DL205_String_low_byte_first_within_register`, +`DL205_String_null_terminator_stops_read`, +`DL205_String_write_pads_final_byte_with_zero`. + +## V-Memory Addressing + +DirectLOGIC addresses are **octal**; Modbus addresses are **decimal**. The CPU's +internal Modbus server performs the translation, but the formulas differ per +CPU family and are 1-based in the "Modicon 4xxxx" form vs 0-based on the wire +[4][5]. + +Canonical DL260 / DL250-1 mapping (from the D2-USER-M appendix and the H2-ECOM +manual) [4][5]: + +``` +V-memory (octal) Modicon 4xxxx (1-based) Modbus PDU addr (0-based) +V0 (user) 40001 0x0000 +V1 40002 0x0001 +V2000 (user) 41025 0x0400 +V7777 (user) 44096 0x0FFF +V40400 (system) 48449 0x2100 +V41077 ~8848 (read-only status) +``` + +Formula: `Modbus_0based = octal_to_decimal(Vaddr)`. So `V2000` octal = `1024` +decimal = Modbus PDU address `0x0400`. The "4xxxx" Modicon view just adds 1 and +prefixes the register bank digit. + +- **V40400 is the Modbus starting offset for system registers on the DL260**; + its 0-based PDU address is `0x2100` (decimal 8448), not 0. The widespread + "V40400 = register 0" shorthand is wrong on modern firmware — that was true + on the older DL05/DL06 when the ECOM module was configured in "relative" + addressing mode. On the H2-ECOM100 factory default ("absolute" mode), V40400 + maps to 0x2100 [5]. +- **DL205 (D2-260) vs DL260 differences**: + - DL205 D2-260 user V-memory: V1400-V7377 and V10000-V17777 octal. + - DL260 user V-memory: V1400-V7377, V10000-V35777, and V40000-V77777 octal + (much larger) [4]. + - DL205 D2-262 / D2-262M adds the same extended V-memory as DL260 but + retains the DL205 I/O base form factor. + - Neither DL205 sub-model changes the *formula* — only the valid range. +- **Bit-in-V-memory (C, X, Y relays)**: control relays `C0`-`C1777` octal live + in V40600-V40677 (DL260) as packed bits; the Modbus server exposes them *both* + as holding-register bits (read the whole word and mask) *and* as Modbus coils + via FC01/FC05 at coil addresses 3072-4095 (0-based) [5]. `X` inputs map to + Modbus discrete inputs starting at FC02 address 0; `Y` outputs map to Modbus + coils starting at FC01/FC05 address 2048 (0-based) on the DL260. +- **Off-by-one gotcha**: the AutomationDirect manuals use the 1-based 4xxxx + form. Kepware, libmodbus, pymodbus, and the .NET stack all take the 0-based + PDU form. When the manual says "V2000 = 41025" you send `0x0400`, not + `0x0401`. + +Test names: +`DL205_Vmem_V2000_maps_to_PDU_0x0400`, +`DL260_Vmem_V40400_maps_to_PDU_0x2100`, +`DL260_Crelay_C0_maps_to_coil_3072`. + +## Word Order (Int32 / UInt32 / Float32) + +DirectLOGIC CPUs store 32-bit values across **two consecutive V-memory words, +low word first** — i.e., `CDAB` when viewed as a Modbus register pair [1][3]. +Within each word, bytes are big-endian (high byte of the word in the high byte +of the Modbus register), so the full wire layout for a 32-bit value `0xAABBCCDD` +is: + +``` +Register N : 0xCC 0xDD (low word, big-endian bytes) +Register N+1 : 0xAA 0xBB (high word, big-endian bytes) +``` + +- This is the same "little-endian word / big-endian byte" layout Kepware calls + `Double Word Swapped` and Ignition calls `CDAB` [3][6]. +- **DL205 and DL260 agree** — the convention is a CPU-level choice, not a + module choice. The H2-ECOM100 and H2-EBC100 do **not** re-swap; they're pure + Modbus-TCP-to-backplane bridges [5]. The DL260 built-in Ethernet port + behaves identically. +- **Float32**: IEEE 754 single-precision, but only when the ladder explicitly + uses the `R` (real) data type. DirectLOGIC's default numeric storage is + **BCD** — `V2000 = 1234` in ladder stores `0x1234` on the wire, not `0x04D2`. + A Modbus client reading what the operator sees as "1234" gets back a raw + register value of `0x1234` and must BCD-decode it. Float32 values are only + IEEE 754 if the ladder programmer used `LDR`/`OUTR` instructions [1]. +- **Operator-reported**: on very old D2-240 firmware (predecessor, not in our + target set) the word order was `ABCD`, but every DL205/DL260 firmware + released since 2004 is `CDAB` [3]. _Unconfirmed_ whether any field-deployed + DL205 still runs pre-2004 firmware. + +Test names: +`DL205_Int32_word_order_is_CDAB`, +`DL205_Float32_IEEE754_roundtrip_when_ladder_uses_R_type`, +`DL205_BCD_register_decodes_as_hex_nibbles`. + +## Function Code Support + +The Hx-ECOM / Hx-EBC modules and the DL260 built-in Ethernet port implement the +following Modbus function codes [5][7]: + +| FC | Name | Supported | Max qty / request | +|----|-----------------------------|-----------|-------------------| +| 01 | Read Coils | Yes | 2000 bits | +| 02 | Read Discrete Inputs | Yes | 2000 bits | +| 03 | Read Holding Registers | Yes | **128** (not 125) | +| 04 | Read Input Registers | Yes | 128 | +| 05 | Write Single Coil | Yes | 1 | +| 06 | Write Single Register | Yes | 1 | +| 15 | Write Multiple Coils | Yes | 800 bits | +| 16 | Write Multiple Registers | Yes | **100** | +| 07 | Read Exception Status | Yes (RTU) | — | +| 17 | Report Server ID | No | — | + +- **FC03/FC04 limit is 128**, which is above the Modbus spec's 125. Requesting + 129+ returns exception code `03` (Illegal Data Value) [5]. +- **FC16 limit is 100**, below the spec's 123. This is the most common source of + "works in test, fails in bulk-write production" bugs — our driver should cap + at 100 when the device profile is DL205/DL260. +- **No custom function codes** are exposed on the Modbus port. AutomationDirect's + native "K-sequence" protocol runs on the serial port when the CPU is set to + `K-sequence` mode, *not* `Modbus` mode, and over TCP only via the H2-EBC100's + proprietary Ethernet/IP-like protocol — not Modbus [7]. + +Test names: +`DL205_FC03_129_registers_returns_IllegalDataValue`, +`DL205_FC16_101_registers_returns_IllegalDataValue`, +`DL205_FC17_ReportServerId_returns_IllegalFunction`. + +## Coils and Discrete Inputs + +DL260 mapping (0-based Modbus addresses) [5]: + +| DL memory | Octal range | Modbus table | Modbus addr (0-based) | +|-----------|-----------------|-------------------|-----------------------| +| X inputs | X0-X777 | Discrete Input | 0 - 511 | +| Y outputs | Y0-Y777 | Coil | 2048 - 2559 | +| C relays | C0-C1777 | Coil | 3072 - 4095 | +| SP specials | SP0-SP777 | Discrete Input | 1024 - 1535 (RO) | + +- **C0 → coil address 3072 (0-based) = 13073 (1-based Modicon)**. Y0 → coil + 2048 = 12049. These offsets are wired into the CPU and cannot be remapped. +- **Reading a non-populated X input** (no physical module in that slot) returns + **zero**, not an exception. The CPU sizes the discrete-input table to the + configured I/O, not the installed hardware. Confirmed in the DL260 user + manual's I/O configuration chapter [4]. +- **Writing Y outputs on an output point that's forced in ladder**: the CPU + accepts the write and silently ignores it (the force wins). No exception is + returned. _Operator-reported_, matches Kepware driver release notes [3]. + +Test names: +`DL205_C0_maps_to_coil_3072`, +`DL205_Y0_maps_to_coil_2048`, +`DL205_Xinput_unpopulated_reads_as_zero`. + +## Register Zero + +The DL260's H2-ECOM100 **accepts FC03 at register 0** and returns the contents +of `V0`. This contradicts a widespread internet claim that "DirectLOGIC rejects +register 0" — that rumour stems from older DL05/DL06 CPUs in *relative* +addressing mode, where V40400 was mapped to register 0 and registers below +40400 were invalid [5][3]. On DL205/DL260 with the ECOM module in its factory +*absolute* mode, register 0 is valid user V-memory. + +- Our driver's `ModbusProbeOptions.ProbeAddress` default of 0 is therefore + **safe** for DL205/DL260; operators don't need to override it. +- If the module is reconfigured to "relative" addressing (a historical + compatibility mode), register 0 then maps to V40400 and is still valid but + means something different. The probe will still succeed. + +Test name: `DL205_FC03_register_0_returns_V0_contents`. + +## Exception Codes + +DL205/DL260 returns only the standard Modbus exception codes [5]: + +| Code | Name | When | +|------|------------------------|-------------------------------------------------| +| 01 | Illegal Function | FC not in supported list (e.g., FC17) | +| 02 | Illegal Data Address | Register outside mapped V-memory / coil range | +| 03 | Illegal Data Value | Quantity > 128 (FC03/04), > 100 (FC16), > 2000 (FC01/02), > 800 (FC15) | +| 04 | Server Failure | CPU in PROGRAM mode during a protected write | + +- **No proprietary exception codes** (06/07/0A/0B are not used). +- **Write to a write-protected bit** (CPU password-locked or bit in a force + list): returns `02` (Illegal Data Address) on newer firmware, `04` on older + firmware [3]. _Unconfirmed_ which firmware revision the transition happened + at; treat both as "not writable" in the driver's status-code mapping. +- **Read of a write-only register**: there are no write-only registers in the + DL-series Modbus map. Every writable register is also readable. + +Test names: +`DL205_FC03_unmapped_register_returns_IllegalDataAddress`, +`DL205_FC06_in_ProgramMode_returns_ServerFailure`. + +## Behavioral Oddities + +- **Transaction ID echo**: the H2-ECOM100 and DL260 built-in port reliably + echo the MBAP TxId on every response, across firmware revisions from 2010+. + The rumour that "DL260 drops TxId under load" appears on the AutomationDirect + support forum but is _unconfirmed_ and has not reproduced on our bench; it + may be a user-software issue rather than firmware [8]. Our driver's + single-flight + TxId-match guard handles it either way. +- **Concurrency**: the ECOM serializes requests internally. Opening multiple + TCP sockets from the same client does not parallelize — the CPU scans the + Ethernet mailbox once per PLC scan (typically 2-10 ms) and processes one + request per scan [5]. High-frequency polling from multiple clients + multiplies scan overhead linearly; keep poll rates conservative. +- **Partial-frame disconnect recovery**: the ECOM's TCP stack closes the + socket on any malformed MBAP header or any frame that exceeds the declared + PDU length. It does not resynchronize mid-stream. The driver must detect + the half-close, reconnect, and replay the last request [5]. +- **Keepalive**: the ECOM does **not** send TCP keepalives. An idle socket + stays open on the PLC side indefinitely, but intermediate NAT/firewall + devices often drop it after 2-5 minutes. Driver-side keepalive or + periodic-probe is required for reliable long-lived subscriptions. +- **Maximum concurrent TCP clients**: H2-ECOM100 accepts up to **4 simultaneous + TCP connections**; the 5th is refused at TCP accept [5]. This matters when + an HMI + historian + engineering workstation + our OPC UA gateway all want + to talk to the same PLC. + +Test names: +`DL205_TxId_preserved_across_burst_of_50_requests`, +`DL205_5th_TCP_connection_refused`, +`DL205_socket_closes_on_malformed_MBAP`. + +## References + +1. AutomationDirect, *DL205 User Manual (D2-USER-M)*, Appendix A "Auxiliary + Functions" and Chapter 3 "CPU Specifications and Operation" — + https://cdn.automationdirect.com/static/manuals/d2userm/d2userm.html +2. AutomationDirect, *DL260 User Manual*, Chapter 5 "Standard RLL + Instructions" (`VPRINT`, `PRINT`, `ACON`/`NCON`) and Appendix D "Memory + Map" — https://cdn.automationdirect.com/static/manuals/d2userm/d2userm.html +3. Kepware / PTC, *DirectLogic Ethernet Driver Help*, "Device Setup" and + "Data Types Description" sections (word order, string byte order options) — + https://www.kepware.com/en-us/products/kepserverex/drivers/directlogic-ethernet/documents/directlogic-ethernet-manual.pdf +4. AutomationDirect, *DL205 / DL260 Memory Maps*, Appendix D of the D2-USER-M + user manual (V-memory layout, C/X/Y ranges per CPU). +5. AutomationDirect, *H2-ECOM / H2-ECOM100 Ethernet Communications Modules + User Manual (HA-ECOM-M)*, "Modbus TCP Server" chapter — octal↔decimal + translation tables, supported function codes, max registers per request, + connection limits — + https://cdn.automationdirect.com/static/manuals/hxecomm/hxecomm.html +6. Inductive Automation, *Ignition Modbus Driver — Address Mapping*, word + order options (ABCD/CDAB/BADC/DCBA) — + https://docs.inductiveautomation.com/docs/8.1/ignition-modules/opc-ua/drivers/modbus-v2 +7. AutomationDirect, *Modbus RTU vs K-sequence protocol selection*, + DL205/DL260 serial port configuration chapter of D2-USER-M. +8. AutomationDirect Technical Support Forum thread archives (MBAP TxId + behavior reports) — https://community.automationdirect.com/ (search: + "ECOM100 transaction id"). _Unconfirmed_ operator reports only. diff --git a/docs/v2/modbus-test-plan.md b/docs/v2/modbus-test-plan.md index ada85f4..8009b43 100644 --- a/docs/v2/modbus-test-plan.md +++ b/docs/v2/modbus-test-plan.md @@ -32,36 +32,34 @@ test project): ## Per-device quirk catalog -### AutomationDirect DL205 +### AutomationDirect DL205 / DL260 -First known target device. Quirks to document and cover with named tests (to be -filled in when user validates each behavior in ModbusPal with a DL205 profile): +First known target device family. **Full quirk catalog with primary-source citations +and per-quirk integration-test names lives at [`dl205.md`](dl205.md)** — that doc is +the reference; this section is the testing roadmap. -- **Word order for 32-bit values**: _pending_ — confirm whether DL205 uses ABCD - (Modbus TCP standard) or CDAB (Siemens-style word-swap) for Int32/UInt32/Float32. - Test name: `DL205_Float32_word_order_is_CDAB` (or `ABCD`, whichever proves out). -- **Register-zero access**: _pending_ — some DL205 configurations reject FC03 at - register 0 with exception code 02 (illegal data address). If confirmed, the - integration test suite verifies `ModbusProbeOptions.ProbeAddress` default of 0 - triggers the rejection and operators must override; test name: - `DL205_FC03_at_register_0_returns_IllegalDataAddress`. -- **Coil addressing base**: _pending_ — DL205 documentation sometimes uses 1-based - coil addresses; verify the driver's zero-based addressing matches the physical - PLC without an off-by-one adjustment. -- **Maximum registers per FC03**: _pending_ — Modbus spec caps at 125; some DL205 - models enforce a lower limit (e.g., 64). Test name: - `DL205_FC03_beyond_max_registers_returns_IllegalDataValue`. -- **Response framing under sustained load**: _pending_ — the driver's - single-flight semaphore assumes the server pairs requests/responses by - transaction id; at least one DL205 firmware revision is reported to drop the - TxId under load. If reproduced in ModbusPal we add a retry + log-and-continue - path to `ModbusTcpTransport`. -- **Exception code on coil write to a protected bit**: _pending_ — some DL205 - setups protect internal coils; the driver should surface the PLC's exception - PDU as `BadNotWritable` rather than `BadInternalError`. +Confirmed quirks (priority order — top items are highest-impact for our driver +and ship first as PR 41+): -_User action item_: as each quirk is validated in ModbusPal, replace the _pending_ -marker with the confirmed behavior and file a named test in the integration suite. +| Quirk | Driver impact | Integration-test name | +|---|---|---| +| **String packing**: 2 chars/register, **first char in low byte** (opposite of generic Modbus) | `ModbusDataType.String` decoder must be configurable per-device family — current code assumes high-byte-first | `DL205_String_low_byte_first_within_register` | +| **Word order CDAB** for Int32/UInt32/Float32 | Already configurable via `ModbusByteOrder.WordSwap`; default per device profile | `DL205_Int32_word_order_is_CDAB` | +| **BCD-as-default** numeric storage (only IEEE 754 when ladder uses `R` type) | New decoder mode — register reads as `0x1234` for ladder value `1234`, not as decimal `4660` | `DL205_BCD_register_decodes_as_hex_nibbles` | +| **FC16 capped at 100 registers** (below the spec's 123) | Bulk-write batching must cap per-device-family | `DL205_FC16_101_registers_returns_IllegalDataValue` | +| **FC03/04 capped at 128** (above the spec's 125) | Less impactful — clients that respect the spec's 125 stay safe | `DL205_FC03_129_registers_returns_IllegalDataValue` | +| **V-memory octal-to-decimal addressing** (V2000 octal → 0x0400 decimal) | New address-format helper in profile config so operators can write `V2000` instead of computing `1024` themselves | `DL205_Vmem_V2000_maps_to_PDU_0x0400` | +| **C-relay → coil 3072 / Y-output → coil 2048** offsets | Hard-coded constants in DL205 device profile | `DL205_C0_maps_to_coil_3072`, `DL205_Y0_maps_to_coil_2048` | +| **Register 0 is valid** (rejects-register-0 rumour was DL05/DL06 relative-mode artefact) | None — current default is safe | `DL205_FC03_register_0_returns_V0_contents` | +| **Max 4 simultaneous TCP clients** on H2-ECOM100 | Connect-time: handle TCP-accept failure with a clearer error message | `DL205_5th_TCP_connection_refused` | +| **No TCP keepalive** | Driver-side periodic-probe (already wired via `IHostConnectivityProbe`) | _Covered by existing `ModbusProbeTests`_ | +| **No mid-stream resync on malformed MBAP** | Already covered — single-flight + reconnect-on-error | _Covered by existing `ModbusDriverTests`_ | +| **Write-protect exception code: `02` newer / `04` older** | Translate either to `BadNotWritable` | `DL205_FC06_in_ProgramMode_returns_ServerFailure` | + +_Operator-reported / unconfirmed_ — covered defensively in the driver but no +integration tests until reproduced on hardware: +- TxId drop under load (forum rumour; not reproduced). +- Pre-2004 firmware ABCD word order (every shipped DL205/DL260 since 2004 is CDAB). ### Future devices