# 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_<behavior>`).

## 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`."
- **Grammar scope** (Driver.Modbus.Addressing-007): the
  `ModbusStringByteOrder` knob (HighByteFirst / LowByteFirst) is **not**
  expressible through the `ModbusAddressParser` grammar string — the 3rd grammar
  field is the multi-register word/byte order (ABCD/CDAB/BADC/DCBA) and the 4th
  is the array count, so there is no token slot for the per-string byte order.
  Tags that need low-byte-first packing on DL205 must set
  `ModbusTagDefinition.StringByteOrder = LowByteFirst` via the structured tag
  form (the driver config DTO). The grammar default produces high-byte-first
  strings (matches Ignition / Kepware default behaviour).

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.
