Task #138 — Modbus addressing grammar docs + e2e

Closes the docs/e2e end of the Modbus addressing line shipped across
#136-#145.

Docs:

- docs/v2/modbus-addressing.md (new) — full grammar reference.
  Region+offset (Modicon 5-digit / 6-digit / mnemonic), bit suffix,
  type codes (BOOL / I / UI / DI / UDI / LI / ULI / F / D / BCD / LBCD /
  STR<n>), all four byte-order mnemonics (ABCD / CDAB / BADC / DCBA),
  array-count semantics, family-native syntax (DL205 V/Y/C/X/SP and
  MELSEC D/M/X/Y with hex-vs-octal sub-family selection), driver-instance
  options (KeepAlive / Reconnect / IdleDisconnect, MaxCoilsPerRead and
  FC15/16 forcing, Deadband + WriteOnChangeOnly, MaxReadGap +
  CoalesceProhibited, multi-unit IPerCallHostResolver). Includes a worked
  JSON DTO example mixing AddressString + structured tag forms.

- docs/Driver.Modbus.Cli.md — appended a "v2 addressing grammar" section
  pointing users at the full reference, with quick-reference examples.

- Vendor-compatibility caveat documented: type codes and byte-order
  mnemonics were synthesised from training-era vendor docs (Wonderware
  DASMBTCP, Kepware KEPServerEX, Ignition, Matrikon, OAS) and should be
  verified against current vendor manuals before locking for production.

E2E tests (4 new AddressingGrammarTests in IntegrationTests):
- Modicon 5-digit and 6-digit forms map to identical wire offsets.
- Float32 + WordSwap (CDAB) round-trips end-to-end through the
  pymodbus simulator.
- Int16[5] array round-trips as a typed short[] surface.
- Block-read coalescing produces a wire-acceptable PDU when MaxReadGap=5
  bridges three nearby tags.

All tests skip gracefully when the pymodbus simulator at localhost:5020
is unreachable (matches the existing ModbusSimulatorFixture pattern).

Final test count across the Modbus addressing surface:
- 107 ModbusAddressing.Tests (parser + family + Modicon)
- 231 Driver.Modbus.Tests (driver, byte order, array, multi-unit, coalescing,
  protocol, subscribe, connection options)
- 110 Admin.Tests (incl. ModbusOptionsViewModel defaults pinning)
- 4 new AddressingGrammar integration tests (skip when sim down)
This commit is contained in:
Joseph Doherty
2026-04-25 00:32:27 -04:00
parent 858f300a61
commit 5ea57d2d70
3 changed files with 331 additions and 0 deletions

View File

@@ -0,0 +1,195 @@
# 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`
| Code | Type | Registers |
|---|---|---|
| `BOOL` | Boolean | 1 (region must be Coils / DiscreteInputs) |
| `I` | Int16 | 1 |
| `UI` | UInt16 | 1 |
| `DI`, `L` | Int32 | 2 |
| `UDI`, `UL` | UInt32 | 2 |
| `LI` | Int64 | 4 |
| `ULI` | UInt64 | 4 |
| `F` | Float32 | 2 |
| `D` | Float64 | 4 |
| `BCD` | 16-bit BCD | 1 |
| `LBCD` | 32-bit BCD | 2 |
| `STR<len>` | ASCII string, `len` chars (2 chars / register) | `ceil(len/2)` |
Default when omitted:
- Coils / DiscreteInputs → `BOOL`
- HoldingRegisters / InputRegisters → `I` (Int16)
### 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.