8.5 KiB
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. For the manual test client, see Driver.Modbus.Cli.md. For the integration fixture coverage map, see 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 viaModbusTagDefinition.StringByteOrder(the grammar can't express this — seeModbusStringByteOrder).MELSEC— Mitsubishi. D-registers → HoldingRegisters,X→ DiscreteInputs,Y/M→ Coils; theMelsecSubFamilyselector 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-tagUnitIdoverrides drive multi-slave gateway topology. Tags— the pre-declaredModbusTagDefinitionlist; this is the address space.Probe— connectivity-probe interval / timeout / probe register (default register 0).- Read/write caps —
MaxRegistersPerRead(125),MaxRegistersPerWrite(123),MaxCoilsPerRead(2000), plusMaxReadGapandAutoProhibitReprobeIntervalfor coalescing. - Function-code overrides —
UseFC15ForSingleCoilWrites,UseFC16ForSingleRegisterWritesfor PLCs that only accept multi-write codes. - Resilience —
AutoReconnect,KeepAlive,IdleDisconnectTimeout,Reconnectbackoff, andWriteOnChangeOnlyredundant-write suppression.
Full per-field descriptions live in ModbusDriverOptions.cs. The JSON skeleton
is reproduced in docs/v2/driver-specs.md §2.
Testing
- Unit tests —
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/(driver behaviour via a fake transport) andtests/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 for the coverage map and theMODBUS_SIM_ENDPOINTwiring. - Manual client — Driver.Modbus.Cli.md.
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).
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
GetAutoProhibitedRangesand logged on first occurrence / on clear — use them to find protected register holes in a device's map.