cf62007678
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
323 lines
15 KiB
Markdown
323 lines
15 KiB
Markdown
# 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](https://cdn.logic-control.com/media/DASMBTCP.pdf) and the
|
||
[Ignition Modbus addressing
|
||
manual](https://www.docs.inductiveautomation.com/docs/8.1/ignition-modules/opc-ua/opc-ua-drivers/modbus/modbus-addressing).
|
||
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.
|
||
|
||
```json
|
||
{
|
||
"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), 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)/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). Because that FC03 is one
|
||
unchunked read of the whole span, a BitInRegister array must also fit
|
||
`maxRegistersPerRead` (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 `String` with no `stringLength`, a `bitIndex` above 15, a missing
|
||
or zero `arrayLength`, and a `String` / `DateTime` node 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
|
||
ScadaBridge `int[]` to an `Int16` node is writable.
|