Review r0 of Integration-W3: - I1: the effective FC16 cap is min(configured maxRegistersPerWrite, 123). The driver trusts a larger configured cap and sends an over-ceiling FC16 (past 127 registers its byte-count byte wraps), which every conformant device refuses, so such an array is never advertised writable. - I2: a BitInRegister[] write reads its span first in one unchunked FC03, so it is also bounded by min(configured maxRegistersPerRead, 125) (absent/0 = 125). - m2: every span (registers and coils) must end at or before address 65535. - m1: the String-node wording says it converts only when the text parses, which the server cannot promise (DateTime never converts). - m4: the parity device now enforces FC01/02 2000, FC03/04 125 (or a smaller device read limit), FC15 1968, FC16 123, FC23 121/125, the FC15/FC16 byte-count field (exception 03) and the address-space end (exception 02). Tests: parity rows for each ceiling/cap/span boundary against the real write path, and Core.Abstractions row tests. The configured-cap test that asserted a cap of 200 lifts the ceiling now asserts the opposite. Refs #741 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
15 KiB
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-HRdefault
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(defaultfalse) — 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.ResolveHostreturnshost:port/unitNper tag so per-PLC circuit breakers fire per slave.- Per-tag
CoalesceProhibited— escape hatch for #143's planner (read this tag in isolation regardless ofMaxReadGap).
Block-read coalescing (#143)
MaxReadGap— gap budget the planner is willing to bridge between adjacent tags of one (unit, region). WithMaxReadGap=10, three tags at HR 100/102/110 collapse into one FC03 of quantity 11. An explicit0= coalescing off (its historical meaning); the default8applies only when the key is absent (#741). Coils / DiscreteInputs (gap in bits),Stringscalars and same-wordBitInRegistertags 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 onReinitializeAsync). - Per-tag escape hatch:
CoalesceProhibited(bool, default false) onModbusTagDefinition. The planner reads such tags in isolation regardless ofMaxReadGap. 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 asModbusAutoProhibitionrecords (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
regionandaddress, those keys are its address — a classic tag, an editor-pinned tag, and a pre-#741 row that carried anaddressStringbeside 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 (anaddressStringwith NOregion/address— CSV / API authoring) is resolved throughModbusAddressParserwith the driver'sFamily/MelsecSubFamily; a family that cannot parse it refuses the tag (BadNodeIdUnknown). It is never served as HR[0]. "addressResolved": trueis 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-addressStringor 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 savedInt16) is coerced to Bool, never refused. A register region withBoolis refused (useBitInRegister). - 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), and never more than FC16's protocol ceiling of 123 — a larger configured cap does not lift it, because a conformant device refuses the longer FC16. Registers per element: 1 / 2 / 4 for the numeric types,(stringLength+1)/2forString(each element nul-padded to that width), 1 forBitInRegister(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;bitIndex0–15). Because that FC03 is one unchunked read of the whole span, a BitInRegister array must also fitmaxRegistersPerRead(default and protocol ceiling 125). - Every span — registers or coils — must end at or before address 65535
(a conformant device answers exception 02 past it). 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
Stringwith nostringLength, abitIndexabove 15, a missing or zeroarrayLength, and aString/DateTimenode over a numeric or bit element (a DateTime never converts; a String converts only when its text happens to parse, which the server cannot promise). Elements otherwise convert, so a ScadaBridgeint[]to anInt16node is writable.