Files
lmxopcua/docs/v2/modbus-addressing.md
Joseph Doherty dfd027ebca Task #146 — Modbus addressing: align type codes with Wonderware DASMBTCP + Ignition
Web verification (2026-04-25) against current vendor docs surfaced concrete
grammar conflicts in the v1 suffix grammar shipped in #137. Hard cutover
before the Admin UI rolls out widely so users don't paste `:I` from a
Wonderware spreadsheet and silently get wrong-typed reads.

Sources:
- Wonderware DASMBTCP user guide
  https://cdn.logic-control.com/media/DASMBTCP.pdf
- Ignition Modbus addressing (8.1)
  https://www.docs.inductiveautomation.com/docs/8.1/ignition-modules/opc-ua/opc-ua-drivers/modbus/modbus-addressing

Type-code changes:

| Code   | Pre-#146 | Post-#146  | Vendor reference            |
|--------|----------|------------|------------------------------|
| `:S`   | (n/a)    | Int16      | Wonderware DASMBTCP `S`      |
| `:US`  | (n/a)    | UInt16     | Ignition `HRUS`              |
| `:I`   | Int16    | **Int32**  | Wonderware `I` + Ignition `HRI` |
| `:UI`  | UInt16   | **UInt32** | Ignition `HRUI`              |
| `:I_64`  | (n/a)  | Int64      | Ignition `HRI_64`            |
| `:UI_64` | (n/a)  | UInt64     | Ignition `HRUI_64`           |
| `:BCD_32`| (n/a)  | BCD32      | Ignition `HRBCD_32`          |

Codes REMOVED (no clear vendor precedent + conflict with the new mapping):
`:DI`, `:L`, `:UDI`, `:UL`, `:LI`, `:ULI`, `:LBCD`. Pre-#146 configs that
use them get an "Unknown type code" diagnostic at parse time so users get
a fast surface-level error rather than silent wrong-typed reads.

Codes UNCHANGED (already vendor-aligned): `:BOOL`, `:F`, `:D`, `:BCD`,
`:STR<n>`. Modicon 5/6-digit + mnemonic regions (HR/IR/C/DI) + bit suffix
`.N` are also unchanged.

Defaults:
- Coils / DiscreteInputs → `BOOL` (unchanged)
- HoldingRegisters / InputRegisters with no explicit type → Int16 (matches
  Ignition's bare `HR` default)

Byte-order mnemonics (`:ABCD` / `:CDAB` / `:BADC` / `:DCBA`) are kept but
documented as OtOpcUa-specific — they aren't in any major vendor's per-tag
address string. Ignition uses a `-R` suffix per prefix; Wonderware
configures word-order at the topic level.

Tests:
- 12 Type_Codes_Parse rows updated to assert the new mappings.
- New Removed_Aliases_Are_Rejected (×7) confirms each pre-#146 alias now
  fails fast with "Unknown type code".
- Worked_Example_Int16_Array uses the new `:S` code.
- New Worked_Example_Int32_Array_Via_I_Code documents the `:I = Int32`
  vendor-alignment intent so a future "fix" doesn't accidentally regress.
- Unknown_Type_Code_Rejected_With_Catalog updated to match the new error
  message ("Valid: BOOL, S, US, I, ...").

Docs:
- docs/v2/modbus-addressing.md — table replaced with the post-#146 codes,
  each row cites its Wonderware / Ignition reference. New "Codes removed
  in #146" subsection documents the cutover.
- docs/Driver.Modbus.Cli.md — example grammar list updated; explicit
  type-code reminder appended.

114 addressing tests + 231 driver tests still green. Solution build clean.
2026-04-25 00:51:50 -04:00

209 lines
8.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
- `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.
## 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.