Files
lmxopcua/docs/v2/modbus-addressing.md
T
Joseph Doherty 5533de6281 feat(arrays): Modbus String[]/BitInRegister[] array writes reachable; table follows the resolver
The Modbus lane (#741 U3) gave the driver String[] and BitInRegister[] holding-register array encoders, and
moved address resolution to ModbusTagAddressResolution (keys first, a bit region coerced to Bool, strict key
reads), but DriverArrayWriteSupport still listed both array shapes read-only (follow-up F1), so a client
write was BadNotWritable before it reached the driver.

Modbus row now mirrors the driver end to end:
- Keys shape only (region + address both present). A string-only addressString tag is resolved by the
  driver with its own family through a parser Core.Abstractions cannot reference, so it stays read-only
  (pinned: the conservative direction).
- Strict reads as the resolver: address an integer 0-65535; a present region / dataType / byteOrder /
  stringByteOrder string must name a member.
- Coils: any dataType (coerced to Bool), <= 1968 coils (FC15).
- HoldingRegisters (FC16, never Bool): numeric, String ((stringLength+1)/2 registers, stringLength >= 1),
  BitInRegister (1 register, bitIndex 0-15, span ending <= 65535), all within maxRegistersPerWrite.
  FC23 is opt-in and never changes the answer.
- Elements convert (ScadaBridge int[] -> Int16 stays writable); only a String / DateTime node over a
  numeric or bit element is left out, since such an element never converts.

Tests: the parity test's PendingF1 exemption and its guard are gone; the parity matrix is now region x
element type x node type against the real WriteAsync with a spec-conformant device (FC15 > 1968 refused),
plus shape edges at every cap boundary (numeric, String, BitInRegister, coil), strict key reads, FC23
on/off, string-only, the ScadaBridge int[] shape and U3 wire bytes. Core.Abstractions row tests and a
composer test (String[] and BitInRegister[] writable; over-cap, no-stringLength and string-only read-only).
Docs: table XML doc, Uns.md, modbus-addressing.md, drivers/Modbus.md, data-plane rules.

Refs #741

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 17:10:00 -04:00

15 KiB
Raw Blame History

Modbus tag-addressing reference

Foundational doc for the Modbus addressing grammar shipped across #136–#144. Covers the address-string parser (ModbusAddressParser) that the wire driver and the Admin UI both consume, the per-tag suffix modifiers, and the family- native branch.

Grammar

<region><offset>[.<bit>][:<type>[<len>]][:<order>][:<count>]

Each field is optional from left to right; the parser fills defaults.

Region + offset

Three accepted forms — pick whichever matches your tag spreadsheet's convention. All three resolve to the same (Region, ushort PduOffset) on the wire.

Form Example Means
Modicon 5-digit 40001 Holding register 1 (PDU 0)
Modicon 6-digit 400001 Holding register 1 (PDU 0); supports up to 465536 (PDU 65535)
Mnemonic HR1, IR1, C100, DI1 Same regions; 1-based register number

Modicon leading-digit → region:

Digit Region OPC UA wire FC
0 Coils FC01 / FC05 / FC15
1 DiscreteInputs FC02 (read-only)
3 InputRegisters FC04 (read-only)
4 HoldingRegisters FC03 / FC06 / FC16

Bit suffix .N

40001.5 = bit 5 (LSB-first) of HR[0]. Implies DataType=BitInRegister; mixing with an explicit type or array-count is rejected.

Type code :T

Codes verified 2026-04-25 against Wonderware DASMBTCP user guide and the Ignition Modbus addressing manual. The I / UI / I_64 / UI_64 / BCD_32 shapes match Wonderware's suffix convention and Ignition's underscore-N prefix variants where those vendors agree.

Code Type Registers Vendor reference
BOOL Boolean 1 (region must be Coils / DiscreteInputs) universal
S Int16 1 Wonderware DASMBTCP S = 16-bit signed
US UInt16 1 Ignition HRUS = Unsigned Short
I Int32 2 Wonderware DASMBTCP I = 32-bit signed; Ignition HRI
UI UInt32 2 Ignition HRUI
I_64 Int64 4 Ignition HRI_64
UI_64 UInt64 4 Ignition HRUI_64
F Float32 2 Wonderware F; Ignition HRF
D Float64 4 Ignition HRD
BCD 16-bit BCD 1 Ignition HRBCD
BCD_32 32-bit BCD 2 Ignition HRBCD_32
STR<len> ASCII string, len chars (2 chars / register) ceil(len/2) analogous to Ignition HRS<addr>:<len>

Default when omitted:

  • Coils / DiscreteInputs → BOOL
  • HoldingRegisters / InputRegisters → S (Int16) — matches Ignition's bare-HR default

Codes removed in #146 (silent wrong-data risk, never compatible with the two reference vendors): :DI, :L, :UDI, :UL, :LI, :ULI, :LBCD. Pre-#146 configs that use these get a clear "Unknown type code" diagnostic at parse time; rewrite to the post-#146 codes per the table above.

Byte order :O

Mnemonic Meaning Wire
ABCD Big-endian (Modbus spec default) [A,B,C,D]
CDAB Word swap (Siemens, several AB) [C,D,A,B]
BADC Byte swap (legacy little-endian-internal devices) [B,A,D,C]
DCBA Full reverse (some EtherNet/IP gateways) [D,C,B,A]

For 8-byte values (Int64 / Float64) the same labels apply pairwise.

Array count :N

40001:F:5 = Float32[5] (consumes HR[0..9]). Array + bit suffix is rejected. Strings are not arrays.

An array written through the grammar string alone (a string-only tag — addressString with no region/address keys) is served and written by the driver, but its OPC UA node stays read-only: whether a write fits is decided at compose time, where the driver's family is not known. Pin the keys (the editor's "Resolve with driver family") to make the array writable — see "Array writes" below.

Composition

The 3-field shorthand 40001:F:5 is parsed as (type=F, count=5) because 5 isn't a valid byte-order mnemonic. Use the explicit 4-field form 40001:F:CDAB:5 when you need a non-default order.

Family-native syntax (#144)

When the driver instance has Family != Generic, the parser tries the family's native syntax FIRST, then falls back to Modicon / mnemonic.

DL205 (AutomationDirect DirectLOGIC)

Form Example Mapping
Vnnnn (octal) V2000 HoldingRegisters[1024] (octal 2000 = decimal 1024)
Ynn (octal) Y17 Coils[2048 + 15] (Y-output base + offset)
Cnn (octal) C100 Coils[3072 + 64] (C-relay base + offset)
Xnn (octal) X17 DiscreteInputs[15]
SPnn (octal) SP10 DiscreteInputs[1024 + 8]

Cross-family ambiguity: C100 means Coils[99] under Generic (mnemonic) but Coils[3136] under DL205. Per-driver Family choice disambiguates.

MELSEC (Mitsubishi)

Form Example Mapping (sub-family Q_L_iQR / F_iQF)
Dnnn (decimal) D100 HoldingRegisters[100]
Mnnn (decimal) M50 Coils[50]
Xnn X20 DiscreteInputs[32 hex / 16 octal]
Ynn Y20 Coils[32 hex / 16 octal]

X / Y digit interpretation depends on MelsecSubFamily:

  • Q_L_iQR → hex (default)
  • F_iQF → octal

Bank-base offsets default to 0 in the grammar string. Sites with non-zero "Modbus Device Assignment" bases use the structured tag form.

Driver-instance options

Beyond per-tag addressing, ModbusDriverOptions exposes (#139–#143):

Connection (#139)

  • KeepAlive { Enabled, Time, Interval, RetryCount } — TCP-level probes. Defaults match the historical PR 53 wire output (Enabled=true, Time=30s, Interval=10s, RetryCount=3).
  • IdleDisconnectTimeout — proactively close + reconnect after this much socket idle time. Default null = disabled.
  • Reconnect { InitialDelay, MaxDelay, BackoffMultiplier } — geometric backoff for the post-drop reconnect loop. Default (0, 30s, 2.0) = immediate first retry, geometric thereafter.

Protocol (#140)

  • MaxCoilsPerRead (default 2000) — separate cap for FC01/FC02 coil reads.
  • UseFC15ForSingleCoilWrites — force FC15 (write multiple coils qty=1) for single-coil writes. Safety/audit PLCs may require this.
  • UseFC16ForSingleRegisterWrites — same for FC16 vs FC06.
  • UseFC23ForMultiRegisterWrites (default false) — opt-in: a 2–121-register holding write is sent as one FC23 (write + read-back). A unit answering exception 01 moves to FC16 until the next re-init. Without the key the wire stays FC16 (#741).
  • DisableFC23 — hard kill switch: never send FC23, even when opted in.

Subscribe (#141)

  • Per-tag Deadband — suppress sub-threshold publishes on numeric tags.
  • WriteOnChangeOnly (driver-level) — short-circuit identical-value writes. Cache invalidates on read-divergence.

Multi-unit (#142)

  • Per-tag UnitId — overrides the driver-level UnitId in the MBAP header. Required for one-Ethernet-gateway / N-RTU-slave deployments.
  • IPerCallHostResolver.ResolveHost returns host:port/unitN per tag so per-PLC circuit breakers fire per slave.
  • Per-tag CoalesceProhibited — escape hatch for #143's planner (read this tag in isolation regardless of MaxReadGap).

Block-read coalescing (#143)

  • MaxReadGap — gap budget the planner is willing to bridge between adjacent tags of one (unit, region). With MaxReadGap=10, three tags at HR 100/102/110 collapse into one FC03 of quantity 11. An explicit 0 = coalescing off (its historical meaning); the default 8 applies only when the key is absent (#741). Coils / DiscreteInputs (gap in bits), String scalars and same-word BitInRegister tags coalesce too.
  • DisableReadCoalescing — turn coalescing off regardless of the gap.

Coalescing auto-recovery (#148 / #150 / #151 / #152)

  • A coalesced read that fails with a Modbus exception (write-only or protected register mid-block) records the failed range as auto-prohibited. The planner stops re-coalescing across the range; the per-tag fallback path keeps healthy members working in the same scan.
  • Bisection (#150): every re-probe pass narrows multi-register prohibitions by trying the two halves separately. Over log2(span) ticks the prohibition pins at the actual offending register(s); intermediate halves that succeed get cleared.
  • Periodic re-probe (#151): opt in via AutoProhibitReprobeInterval (TimeSpan?). Default null = disabled (prohibitions persist for the driver lifetime; clear on ReinitializeAsync).
  • Per-tag escape hatch: CoalesceProhibited (bool, default false) on ModbusTagDefinition. The planner reads such tags in isolation regardless of MaxReadGap. Use for known-bad addresses you want to exclude from the auto-discovery loop.
  • Diagnostics (#152): ModbusDriver.GetAutoProhibitedRanges() returns a snapshot of every active prohibition as ModbusAutoProhibition records (UnitId / Region / StartAddress / EndAddress / LastProbedUtc / BisectionPending). Surface in the driver-diagnostics RPC channel when that wiring lands; for now consumable by in-process callers (Server health endpoints, log aggregation).

JSON DTO shape

The factory accepts both the structured form (legacy) and the new AddressString form per-tag. Mix freely — newer pasted rows use the grammar string; legacy rows keep the structured fields.

{
  "host": "10.1.2.3",
  "port": 502,
  "unitId": 1,
  "family": "DL205",
  "keepAlive": { "enabled": true, "timeMs": 30000, "intervalMs": 10000, "retryCount": 3 },
  "idleDisconnectMs": 120000,
  "reconnect": { "initialDelayMs": 0, "maxDelayMs": 30000, "backoffMultiplier": 2.0 },
  "maxCoilsPerRead": 2000,
  "writeOnChangeOnly": false,
  "maxReadGap": 8,
  "tags": [
    { "name": "Temp",        "addressString": "V2000:F:CDAB" },
    { "name": "Setpoint",    "addressString": "40001:I" },
    { "name": "Outputs",     "addressString": "Y0:5" },
    { "name": "AlarmCount",  "region": "HoldingRegisters", "address": 200, "dataType": "Int16", "deadband": 5.0 }
  ]
}

Vendor compatibility caveat

The exact spelling of type codes (e.g. I vs INT, BCD vs L_BCD) and the byte-order mnemonics were synthesised from training-era vendor docs (Wonderware DASMBTCP, Kepware KEPServerEX, Ignition, Matrikon, OAS). Before locking the grammar for a production deployment, verify against the current Kepware "Modbus Ethernet Driver Help" PDF and Ignition's "Modbus Addressing" user-manual page — if a critical tool's mnemonics have shifted, add aliases in ModbusAddressParser.TryParseType rather than asking users to rewrite spreadsheets.

Address strings, families and region/type rules (#741)

  • One authority: ModbusTagAddressResolution (Driver.Modbus.Contracts). The driver's tag reader, the deploy gate (Inspect), the AdminUI editor model (load / save / Validate / the hint text) and the CSV import/export all ask it which part of a blob is the address; none restates the rule. Its agreement is pinned by one table-driven test that runs every persisted shape through every consumer (ModbusAddressAuthorityAgreementTests).
  • Whenever a tag carries both region and address, those keys are its address — a classic tag, an editor-pinned tag, and a pre-#741 row that carried an addressString beside its keys (the old driver never read the string) all load from the keys, exactly as before, and a family change never remaps them. Only a string-only blob (an addressString with NO region/address — CSV / API authoring) is resolved through ModbusAddressParser with the driver's Family / MelsecSubFamily; a family that cannot parse it refuses the tag (BadNodeIdUnknown). It is never served as HR[0].
  • "addressResolved": true is editor display / CSV state only: it never makes keys authoritative, and a marker on a blob WITHOUT keys is ignored (the blob is string-only) and dropped on the next editor save.
  • The AdminUI never writes default keys it did not resolve: the editor model stamps region/address/type only when the loaded blob had the keys, or when a field is set (hand edit, CSV address cell, Resolve). A string-only, non-string-addressString or address-less blob keeps its keys unwritten, and its CSV export leaves the address cells blank.
  • The editor's "Resolve with driver family" persists the resolved region/address/type fields plus "addressResolved": true (the pinned state). Editing a resolved field by hand in the editor drops the string and the marker. A typed CSV address cell keeps them only while the string still describes the cells under some family (so an unchanged export → import of a pinned row is lossless); a contradicting cell drops them, and the import review grid reports that column as an override.
  • Coils / DiscreteInputs carry only Bool. A bit-region tag persisted with a register dataType (the pre-#741 editor saved Int16) is coerced to Bool, never refused. A register region with Bool is refused (use BitInRegister).
  • Exception 01/02/03 are device refusals: per-tag Bad, one Warning per tag, no driver Degraded; reads and writes share one comms-fault health slot.

Array writes (#610, #741)

Whether an array node is client-writable is decided by DriverArrayWriteSupport (Core.Abstractions) at compose time, and it says yes only for what ModbusDriver.WriteAsync encodes end to end (ModbusArrayWriteSupportParityTests pins the two against each other):

  • Keys only. The tag must carry region + address (read as strictly as the driver reads them). A string-only tag stays read-only — the composer cannot resolve the grammar under the driver's family.
  • Coils: one FC15 of at most 1968 coils (the spec ceiling; the driver does not chunk). Any dataType — a bit region is coerced to Bool.
  • HoldingRegisters: one FC16 whose register count fits maxRegistersPerWrite (default 123; the smallest value on the driver or the tag's device wins). Registers per element: 1 / 2 / 4 for the numeric types, (stringLength+1)/2 for String (each element nul-padded to that width), 1 for BitInRegister (the span is read with one FC03, the addressed bit of each register is set or cleared, and the span is written back with one FC16 — the other 15 bits are preserved; bitIndex 0–15, the span must end at or before register 65535). FC23 (useFC23ForMultiRegisterWrites) is opt-in, capped at 121 registers and falls back to FC16 above that, so it never changes which arrays are writable.
  • Never writable: DiscreteInputs / InputRegisters, Bool in a register region, a String with no stringLength, a bitIndex above 15, a missing or zero arrayLength, and a String / DateTime node over a numeric or bit element (such an element never converts). Elements otherwise convert, so a ScadaBridge int[] to an Int16 node is writable.