The Modbus lane (#741 U3) gave the driver String[] and BitInRegister[] holding-register array encoders, and moved address resolution to ModbusTagAddressResolution (keys first, a bit region coerced to Bool, strict key reads), but DriverArrayWriteSupport still listed both array shapes read-only (follow-up F1), so a client write was BadNotWritable before it reached the driver. Modbus row now mirrors the driver end to end: - Keys shape only (region + address both present). A string-only addressString tag is resolved by the driver with its own family through a parser Core.Abstractions cannot reference, so it stays read-only (pinned: the conservative direction). - Strict reads as the resolver: address an integer 0-65535; a present region / dataType / byteOrder / stringByteOrder string must name a member. - Coils: any dataType (coerced to Bool), <= 1968 coils (FC15). - HoldingRegisters (FC16, never Bool): numeric, String ((stringLength+1)/2 registers, stringLength >= 1), BitInRegister (1 register, bitIndex 0-15, span ending <= 65535), all within maxRegistersPerWrite. FC23 is opt-in and never changes the answer. - Elements convert (ScadaBridge int[] -> Int16 stays writable); only a String / DateTime node over a numeric or bit element is left out, since such an element never converts. Tests: the parity test's PendingF1 exemption and its guard are gone; the parity matrix is now region x element type x node type against the real WriteAsync with a spec-conformant device (FC15 > 1968 refused), plus shape edges at every cap boundary (numeric, String, BitInRegister, coil), strict key reads, FC23 on/off, string-only, the ScadaBridge int[] shape and U3 wire bytes. Core.Abstractions row tests and a composer test (String[] and BitInRegister[] writable; over-cap, no-stringLength and string-only read-only). Docs: table XML doc, Uns.md, modbus-addressing.md, drivers/Modbus.md, data-plane rules. Refs #741 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
13 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 — 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 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. Transport—Tcp(default; MBAP header + transaction id) orRtuOverTcp(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-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 v2-era JSON skeleton
in docs/v2/driver-specs.md §2 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) 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.
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, #741) — a whole-array write goes out as one FC15 (coils) or one FC16
(holding registers: numeric, String at (stringLength+1)/2 registers per element, and
BitInRegister as one FC03 + one FC16 read-modify-write of the span); 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 it is addressed by region + address keys and
arrayLength × registers-per-element fits the cap (and a coil array ≤ 1968), else its node stays
read-only. A string-only (addressString) array stays read-only, because the composer cannot resolve
it under the driver's family — see Uns.md §Array writes and
modbus-addressing.md §Array writes.
Deferrals — multi-dimensional arrays (arrayLength is always 1-D); per-element historization.
See Uns.md §Array tags 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).
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. - Int64 / UInt64 OPC UA node DataType — tags declared with
DataType: Int64orDataType: UInt64advertise 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,
ModbusDriverstampsReadHealth/WriteHealth(Volatile) per tag as the loop runs. This is intentional: the comms-loss watchdog keys onLastSuccessfulRead, which any success advances andDegraded/Faultedwrites preserve, so a late healthy tag cannot mask a dead device, and a per-pass accumulator would add risk to theDegradedlatch semantics (CLAUDE.md "Comms-loss detection") for no observable gain. Revisit only if a test shows a defect.