Files
lmxopcua/docs/v2/modbus-addressing.md
T
Joseph Doherty 0200619322 fix(modbus): review r2 - region+address keys always win; string-only means no keys
N2: pre-#741 rows carry region/address beside an addressString with no marker
(the old driver never read the string, the old model preserved it as an
unknown key). The r1 rule resolved those from the string - a silent remap or
BadNodeIdUnknown on upgrade. The driver now loads from region+address whenever
both are present (classic, editor-pinned, pre-lane); only a blob with NO keys
is string-only and resolved through the parser. This is safe because the model
no longer stamps keys onto a string-only tag. The model distinguishes
string-only / legacy-string-beside-keys / pinned, and the editor hint says so.
Test matrix over every persisted shape (keys only, pre-lane keys+string,
pre-lane stamped HR/0+string, string only, marker+keys, marker no keys) under
every family; the pre-lane row also through a model save and a CSV
export->import.
n5: a typed CSV address cell is an authored address - it drops addressString
and the marker (new CsvTypedColumn.ClearsKeys) instead of being discarded.
n6: Validate skips the region/dataType/byteOrder enum checks on a string-only
tag (the driver ignores them). n7: docs/v2/modbus-addressing.md restates the
key-presence rule.

Refs #741
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:23:37 -04:00

11 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.

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)

  • 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; the AdminUI never stamps default keys onto it, so it cannot collapse onto HR[0], and a family that cannot parse it refuses the tag (BadNodeIdUnknown).
  • 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 or a typed CSV address cell — drops the string and the marker.
  • 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.