152 lines
11 KiB
Markdown
152 lines
11 KiB
Markdown
# 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.
|