Files
lmxopcua/docs/drivers/Modbus.md
T

152 lines
11 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`** and no
`IHistoryProvider` — the Modbus protocol expresses neither, so those capabilities
are out of scope by design.
| 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.
- **`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 JSON skeleton
is reproduced in [docs/v2/driver-specs.md §2](../v2/driver-specs.md).
## 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).
**Deferrals** — array *writes* (FC16 multi-register write for an array value) are a named
follow-up; 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.