Files
lmxopcua/docs/v2/modbus-addressing.md
Joseph Doherty b8df230eb8 Task #152 — Modbus coalescing: surface auto-prohibitions through diagnostics
Auto-prohibited ranges (#148) were previously visible only through an
internal AutoProhibitedRangeCount accessor used by tests. Production
operators had no way to see what the planner had learned without pulling
logs or inspecting driver state.

Changes:

- New public record `ModbusAutoProhibition(UnitId, Region, StartAddress,
  EndAddress, LastProbedUtc, BisectionPending)` — operator-facing snapshot
  shape. Lives in the addressing assembly's logical namespace alongside
  the other public types.
- `ModbusDriver.GetAutoProhibitedRanges()` returns
  `IReadOnlyList<ModbusAutoProhibition>` — a copy of the live prohibition
  map. Lock-protected snapshot so consumers don't race with the re-probe
  loop.
- RecordAutoProhibition tracks first-fire vs re-fire via the dictionary
  insert path, leaving a hook to add structured logging once an ILogger
  is plumbed through (currently elided to keep the constructor minimal
  for testability — a future change can wire ILogger and emit a single
  warning per first-fire).

Tests (1 new, additive to the 6 in ModbusCoalescingAutoRecoveryTests):
- GetAutoProhibitedRanges_Surfaces_Operator_Visible_Snapshot — confirms
  the snapshot shape: empty before any failure, populated with correct
  UnitId/Region/Start/End/BisectionPending after a failed coalesced read,
  LastProbedUtc within the recent past.

Docs:
- docs/v2/modbus-addressing.md — new "Coalescing auto-recovery" subsection
  consolidates the #148/#150/#151/#152 surface in one place. Documents
  the diagnostic accessor + flags the in-process consumption pattern
  (Server health endpoints today; Admin UI when an RPC channel exists).

239 + 1 = 240 unit tests green.

Caveat: the Admin UI surfacing (table render, "clear all prohibitions"
button) is intentionally NOT shipped here. Admin can't reach a live
ModbusDriver instance without a driver-diagnostics RPC channel that
doesn't exist yet — that's a larger architectural piece. For now the
data is queryable in-process by the Server's health endpoints; once an
RPC channel lands, Admin can wire the existing GetAutoProhibitedRanges
into a Blazor table without further driver changes.
2026-04-25 01:19:10 -04:00

9.5 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.
  • DisableFC23 — kill switch for FC23 (currently unused; reserved).

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 (default 0 = off) — gap budget the planner is willing to bridge between adjacent register tags. With MaxReadGap=10, three tags at HR 100/102/110 collapse into one FC03 of quantity 11.

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.