0200619322
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
265 lines
11 KiB
Markdown
265 lines
11 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.
|
||
|
||
### 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)
|
||
|
||
- **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.
|