Files
lmxopcua/docs/drivers/Modbus.md
T

159 lines
12 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 Driver
In-process native-protocol driver that exposes Modbus-TCP devices as OPC UA
variable nodes. It runs inside the OtOpcUa server's .NET 10 AnyCPU process and
speaks Modbus-TCP directly over a socket — no gateway, no sidecar, no bitness
constraint. Modbus has no discovery protocol and no native push model, so the
address space is built entirely from pre-declared tags and subscriptions are a
polling overlay on top of `IReadable`.
For the driver spec (capability surface, config shape, byte-order matrix), see
[docs/v2/driver-specs.md §2](../v2/driver-specs.md). For the manual test client,
see [Driver.Modbus.Cli.md](../Driver.Modbus.Cli.md). For the integration fixture
coverage map, see [Modbus-Test-Fixture.md](Modbus-Test-Fixture.md).
## Project Layout
| Project | Role |
|---------|------|
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/` | The driver — `ModbusDriver` plus the `ModbusTcpTransport` socket layer, the connectivity probe, and the auto-prohibition planner. |
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing/` | Shared address grammar — `ModbusAddressParser` and the `ModbusRegion` / `ModbusDataType` / `ModbusByteOrder` / `ModbusFamily` enums. Lives in its own assembly so the Admin UI and the parser can speak about addresses without a transport dependency. |
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/` | `ModbusDriverOptions` + `ModbusTagDefinition` config records bound from the driver's `DriverConfig` JSON. |
## Capability Surface
`ModbusDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable`
(`Driver.Modbus/ModbusDriver.cs`). There is **no `IAlarmSource`** — the Modbus protocol has no
alarm object, so that capability is out of scope by design. (No driver carries a
history capability; history is server-side.)
| Capability | Implementation entry point | Notes |
|------------|---------------------------|-------|
| `ITagDiscovery` | `DiscoverAsync` | Emits one `Modbus/{tag}` variable per pre-declared tag; Modbus has no browse protocol, so the driver returns exactly the configured `Tags`. |
| `IReadable` | `ReadAsync` → `ReadOneAsync` / `ReadCoalescedAsync` | FC01/FC02 for coils, FC03/FC04 for registers; auto-chunks reads past the per-device cap. |
| `IWritable` | `WriteAsync` → `WriteOneAsync` | FC05/FC15 for coils, FC06/FC16 for registers; `BitInRegister` writes do a per-register read-modify-write under a lock. `DiscreteInputs` / `InputRegisters` are read-only and return `BadNotWritable`. |
| `ISubscribable` | `SubscribeAsync` driven by the shared `PollGroupEngine` | No native push — subscriptions become per-tag polling groups with an optional per-tag `Deadband` filter. |
| `IHostConnectivityProbe` | `ProbeLoopAsync` + `GetHostStatuses` | Periodic cheap FC03 at `Probe.ProbeAddress`; `HostName` is the `Host:Port` string surfaced to the Admin UI. |
| `IPerCallHostResolver` | `ResolveHost` | Routes each call to a per-slave breaker key (`Host:Port/unit{UnitId}`) so a dead RTU slave behind a multi-unit gateway opens its own breaker. |
## Addressing Model
Every exposed register is a pre-declared `ModbusTagDefinition` (Region, Address,
DataType, ByteOrder, …). Tag spreadsheets are typically authored as address
strings parsed by `ModbusAddressParser` at config-bind time; the grammar is
`<region><offset>[.<bit>][:<type>[<len>]][:<order>][:<count>]`:
| Form | Example | Meaning |
|------|---------|---------|
| Modicon digits | `40001` / `400001` | Holding register 0 (5- or 6-digit form), default Int16 |
| Mnemonic prefix | `HR1` / `IR1` / `C100` / `DI5` | Region prefix + 1-based register number |
| Bit suffix | `40001.5` | Bit 5 of holding register 0 (`BitInRegister`) |
| Explicit type | `40001:F` / `40001:STR20` | Float32 / 20-char ASCII string |
| Word order | `40001:F:CDAB` | Float32 with word-swap byte order |
| Array | `40001:F:5` | Float32[5] (consumes HR[0..9]) |
The four regions (`Coils`, `DiscreteInputs`, `InputRegisters`,
`HoldingRegisters`) map directly to function-code selection. The type codes are
aligned with Wonderware DASMBTCP and the Ignition Modbus driver so pasted tag
sheets translate without manual rewriting.
**Byte/word order** is the most common production misconfiguration. The four
`ModbusByteOrder` mnemonics — `ABCD` (BigEndian, spec default), `CDAB`
(WordSwap), `BADC` (ByteSwap), `DCBA` (FullReverse) — describe how bytes A/B/C/D
appear across consecutive registers when decoding a multi-register value.
## Device Profiles
`ModbusDriverOptions.Family` selects a parser family-native branch
(`ModbusFamily`):
- **`Generic`** (default) — only Modicon (`4xxxx`) and mnemonic (`HR1`, `C100`) forms are accepted.
- **`DL205`** — AutomationDirect DirectLOGIC. V-memory (octal) → HoldingRegisters, `Y`/`C` → Coils, `X`/`SP` → DiscreteInputs. Strings can be packed low-byte-first via `ModbusTagDefinition.StringByteOrder` (the grammar can't express this — see `ModbusStringByteOrder`).
- **`MELSEC`** — Mitsubishi. D-registers → HoldingRegisters, `X` → DiscreteInputs, `Y`/`M` → Coils; the `MelsecSubFamily` selector switches Q/L/iQR (hex) vs FX (octal) X/Y interpretation.
Per-family register caps are honoured through `MaxRegistersPerRead` /
`MaxRegistersPerWrite` / `MaxCoilsPerRead` (e.g. DL205/DL260 cap reads at 128,
Mitsubishi Q at 64); the driver auto-chunks larger reads into consecutive
requests.
## Coalesced Reads + Auto-Prohibition
When `MaxReadGap > 0` the read planner (`ReadCoalescedAsync`) groups tags in the
same `(UnitId, Region)`, sorts by address, and merges near-adjacent register
spans (gap ≤ `MaxReadGap`, total span ≤ the read cap) into a single FC03/FC04
PDU, then slices the response back into per-tag values. If a coalesced read hits
a Modbus exception (illegal/protected register), the offending range is recorded
as **auto-prohibited** so the planner stops re-coalescing across it; the
surviving members fall back to per-tag reads in the same scan. Setting
`AutoProhibitReprobeInterval` starts a background loop that periodically retries
prohibited ranges and uses bisection to narrow a multi-register prohibition down
to the actual offending register(s). Per-tag escape hatch:
`ModbusTagDefinition.CoalesceProhibited`.
## Configuration
`ModbusDriverOptions` (`Driver.Modbus.Contracts/ModbusDriverOptions.cs`) binds
from the driver's `DriverConfig` JSON. Key fields:
- **Endpoint** — `Host`, `Port` (default 502), `UnitId`, `Timeout`. Per-tag `UnitId` overrides drive multi-slave gateway topology.
- **`Transport`** — `Tcp` (default; MBAP header + transaction id) or `RtuOverTcp` (RTU `[addr][PDU][CRC-16]` framing tunnelled over the socket to a serial-to-Ethernet gateway); string-valued, absent = `Tcp`. The Test Connect probe follows the authored mode.
- **`Tags`** — the pre-declared `ModbusTagDefinition` list; this *is* the address space.
- **`Probe`** — connectivity-probe interval / timeout / probe register (default register 0).
- **Read/write caps** — `MaxRegistersPerRead` (125), `MaxRegistersPerWrite` (123), `MaxCoilsPerRead` (2000), plus `MaxReadGap` and `AutoProhibitReprobeInterval` for coalescing.
- **Function-code overrides** — `UseFC15ForSingleCoilWrites`, `UseFC16ForSingleRegisterWrites` for PLCs that only accept multi-write codes.
- **Resilience** — `AutoReconnect`, `KeepAlive`, `IdleDisconnectTimeout`, `Reconnect` backoff, and `WriteOnChangeOnly` redundant-write suppression.
Full per-field descriptions live in `ModbusDriverOptions.cs`. The v2-era JSON skeleton
in [docs/v2/driver-specs.md §2](../v2/driver-specs.md) predates the `Transport` key and the v3 raw-tag model.
## Testing
- **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/` (driver behaviour via a fake transport) and `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests/` (the address grammar).
- **Integration tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/` run against the Docker Modbus simulator fixture. See [Modbus-Test-Fixture.md](Modbus-Test-Fixture.md) for the coverage map and the `MODBUS_SIM_ENDPOINT` wiring.
- **Manual client** — [Driver.Modbus.Cli.md](../Driver.Modbus.Cli.md).
## Driver-Type String
The canonical stored/dispatched `DriverType` value is **`"Modbus"`** — this is what the runtime factory key, docker-dev seed, AdminUI editor-map, tag-config validator, and probe all use. The AdminUI displays the friendly label **"Modbus TCP"** and the new-driver route slug is `modbustcp`, but neither the slug nor the label is stored in the database. Any pre-existing `DriverInstance` rows that carry the legacy value `"ModbusTcp"` must be updated to `"Modbus"` to receive typed-editor dispatch and factory instantiation.
## 1-D array support
A Modbus tag becomes a **1-D OPC UA array node** when its `TagConfig` JSON carries
`"isArray": true` and `"arrayLength": N` (N ≥ 1). The canonical rule:
`isArray: true` + `arrayLength >= 1` → array; `isArray: false` (any length) → scalar.
**Read mechanism** — the driver reads a contiguous block of `N × (registers per element)`
holding/input registers in a single FC03/FC04 PDU, then splits the response into N
decoded values. Three element-type modes are supported:
| Mode | How N elements are read |
|---|---|
| Numeric (Bool, Int16, UInt16, Int32, UInt32, Float32, Float64, Int64, UInt64) | N × element-width registers per PDU; coalescing rules apply as for scalar reads |
| String (`DataType: STR<len>`) | N × ceil(len/2) registers per PDU; each element decoded as a fixed-length ASCII string |
| BitInRegister | N bits packed in one register (or straddling registers); bit index advances per element |
**Unit test coverage** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/` covers
numeric, String, and BitInRegister array reads.
**Live-verify** — Mac-verifiable against the simulator at `10.100.0.35:5020` (no fixture
bring-up required; the sim is always-on on the shared Docker host).
**Array writes** (#610) — a whole-array write goes out as one FC15 (coils) or one FC16 (numeric
holding registers); the driver refuses rather than splits an FC16 longer than `maxRegistersPerWrite`
(default 123). The server mirrors that: an array tag is client-writable only when
`arrayLength × registers-per-element` fits the cap (and a coil array ≤ 1968), else its node stays
read-only — see [Uns.md §Array writes](../Uns.md#array-writes-610), including the upgrade note.
**Deferrals** — multi-dimensional arrays (`arrayLength` is always 1-D); per-element historization.
See [Uns.md §Array tags](../Uns.md#array-tags-1-d) for the cross-driver coverage matrix
and the UI authoring flow.
## Operational Notes
- **Wrong-endian readings are silently plausible.** A byte-order misconfiguration produces a wrong number, not a Bad quality code — surface byte-order mismatches as data-validation alerts, not status codes (see [docs/v2/driver-specs.md §2](../v2/driver-specs.md)).
- **`WriteOnChangeOnly` + write-only tags** — the suppression cache is only invalidated by a read that returns a divergent value. A tag that is never subscribed/polled never refreshes its cache entry, so a re-asserted value can be suppressed indefinitely. Subscribe every tag that needs deterministic re-writes, or leave the option off.
- **Auto-prohibited ranges** are visible via `GetAutoProhibitedRanges` and logged on first occurrence / on clear — use them to find protected register holes in a device's map.
- **Int64 / UInt64 OPC UA node DataType** — tags declared with `DataType: Int64` or `DataType: UInt64` advertise the correct OPC UA scalar type (`Int64` / `UInt64`) on their node. Values outside the 32-bit range are preserved end-to-end; the wire codec (4-register read/write) was already correct before this fix.
- **Health is per-tag, last-writer-wins — a deliberate asymmetry (#602).** Unlike the S7 read/write passes, which accumulate one worst-state-wins outcome per batch, `ModbusDriver` stamps `ReadHealth`/`WriteHealth` (Volatile) per tag as the loop runs. This is intentional: the comms-loss watchdog keys on `LastSuccessfulRead`, which any success advances and `Degraded`/`Faulted` writes preserve, so a late healthy tag cannot mask a dead device, and a per-pass accumulator would add risk to the `Degraded` latch semantics (CLAUDE.md "Comms-loss detection") for no observable gain. Revisit only if a test shows a defect.