From 974d835d08e5eddeeb9992b5b043b0e6ee05f250 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 3 Jun 2026 16:13:22 -0400 Subject: [PATCH] =?UTF-8?q?docs(audit):=20G2=20completeness=20=E2=80=94=20?= =?UTF-8?q?Modbus/AbCip/AbLegacy=20driver=20overview=20pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/drivers/AbCip.md | 104 ++++++++++++++++++++++++++++++++++ docs/drivers/AbLegacy.md | 100 +++++++++++++++++++++++++++++++++ docs/drivers/Modbus.md | 118 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 322 insertions(+) create mode 100644 docs/drivers/AbCip.md create mode 100644 docs/drivers/AbLegacy.md create mode 100644 docs/drivers/Modbus.md diff --git a/docs/drivers/AbCip.md b/docs/drivers/AbCip.md new file mode 100644 index 00000000..b2b3e0e2 --- /dev/null +++ b/docs/drivers/AbCip.md @@ -0,0 +1,104 @@ +# AB CIP Driver + +In-process native-protocol driver that exposes Allen-Bradley CIP / EtherNet-IP +controllers as OPC UA nodes. It runs inside the OtOpcUa server's .NET 10 AnyCPU +process and talks to the PLC through the libplctag.NET wrapper — no gateway, no +sidecar. One driver instance can serve many devices; per-device routing is keyed +on the canonical `ab://gateway[:port]/cip-path` host-address string. + +Supported families: **ControlLogix**, **CompactLogix**, **Micro800**, and +**GuardLogix**. CIP has no native push model, so subscriptions are a polling +overlay on top of `IReadable`. + +For the driver spec (capability surface, config shape, type mapping), see +[docs/v2/driver-specs.md §3](../v2/driver-specs.md). For the manual test client, +see [Driver.AbCip.Cli.md](../Driver.AbCip.Cli.md). For the integration fixture +coverage map, see [AbServer-Test-Fixture.md](AbServer-Test-Fixture.md). + +## Project Layout + +| Project | Role | +|---------|------| +| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/` | The driver — `AbCipDriver`, the libplctag runtime/enumerator/template-reader wrappers, the UDT read planner + template decoders, the host-address parser, and the ALMD alarm projection. | +| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/` | `AbCipDriverOptions`, `AbCipDeviceOptions`, `AbCipTagDefinition` / `AbCipStructureMember`, and the `AbCipDataType` / `AbCipPlcFamily` enums bound from the driver's `DriverConfig` JSON. | + +Per family the `AbCipPlcFamilyProfile` (`PlcFamilies/AbCipPlcFamilyProfile.cs`) +supplies the libplctag `plc` attribute, default CIP path, ConnectionSize, and +request-packing / connected-messaging quirks — ControlLogix is the baseline and +each other family is a delta (Micro800 is unconnected-only with no backplane +routing; GuardLogix shares the ControlLogix wire protocol with a tag-level safety +partition). + +## Capability Surface + +`AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable` +(`Driver.AbCip/AbCipDriver.cs`). It adds **`IAlarmSource`** over the Modbus / +AB Legacy surface. + +| Capability | Implementation entry point | Notes | +|------------|---------------------------|-------| +| `ITagDiscovery` | `DiscoverAsync` | Emits pre-declared tags under per-device folders; UDT tags with declared `Members` fan out into a sub-folder + one variable per member. With `EnableControllerBrowse` the `@tags` symbol table is walked into a `Discovered/` folder (system/module/routine tags filtered out). | +| `IReadable` | `ReadAsync` → `ReadGroupAsync` / `ReadSingleAsync` | Per-tag reads; opt-in whole-UDT grouping (`EnableDeclarationOnlyUdtGrouping`) collapses N member reads into one. | +| `IWritable` | `WriteAsync` | BOOL-within-DINT writes do a per-parent read-modify-write under a lock; `SafetyTag` and non-writable tags return `BadNotWritable`. | +| `ISubscribable` | `SubscribeAsync` driven by the shared `PollGroupEngine` | CIP has no push model — subscriptions become polling groups. | +| `IHostConnectivityProbe` | `ProbeLoopAsync` + `GetHostStatuses` | One probe loop per device reading `Probe.ProbeTagPath`; no path configured ⇒ a warning is logged and the device stays `Unknown`. | +| `IPerCallHostResolver` | `ResolveHost` | Routes each call to the tag's `DeviceHostAddress`, the breaker key for the resilience pipeline so one dead PLC trips only its own breaker. | +| `IAlarmSource` | `AbCipAlarmProjection` (ALMD) | Opt-in via `EnableAlarmProjection`; off by default the subscribe path is a no-op so capability negotiation still works. | + +## Addressing Model + +Per-device host addresses are the canonical `ab://gateway[:port]/cip-path` form +parsed by `AbCipHostAddress.TryParse` (`AbCipHostAddress.cs`). The parsed +`CipPath` is handed to libplctag verbatim, so no wire-layer translation is +needed: + +| Form | Meaning | +|------|---------| +| `ab://10.0.0.5/1,0` | Single-chassis ControlLogix, CPU in slot 0 | +| `ab://10.0.0.5/1,2,2,192.168.50.20,1,0` | Bridged ControlLogix (routed path) | +| `ab://10.0.0.5/` | Micro800 / no-backplane device (empty path) | +| `ab://10.0.0.5:44818/1,0` | Explicit EIP port (default 44818) | + +Tags carry a Logix symbolic `TagPath` (controller or program scope). UDT-typed +tags are declared as `AbCipDataType.Structure` with a `Members` list; discovery +fans each member out as `{tag.Name}.{member.Name}`, and the read planner can +collapse a batch of members into one whole-UDT read when +`EnableDeclarationOnlyUdtGrouping` is set. The whole-UDT fast path is opt-in +because Studio 5000 may reorder members vs declaration order; decoding at +declaration-order offsets against a reordered layout yields silently-plausible +wrong numbers. + +## Configuration + +`AbCipDriverOptions` (`Driver.AbCip.Contracts/AbCipDriverOptions.cs`) binds from +the driver's `DriverConfig` JSON. Key fields: + +- **`Devices`** — one `AbCipDeviceOptions` per PLC (`HostAddress`, `PlcFamily`, optional `DeviceName`, per-device `AllowPacking` / `ConnectionSize` overrides). +- **`Tags`** — pre-declared `AbCipTagDefinition` list; `Members` for UDT fan-out, `SafetyTag` for GuardLogix safety-partition tags. +- **`Probe`** — connectivity-probe `Enabled` / `Interval` / `Timeout` / `ProbeTagPath`. +- **Discovery** — `EnableControllerBrowse` (`@tags` walk) and `EnableDeclarationOnlyUdtGrouping` (whole-UDT read fast path). +- **Alarms** — `EnableAlarmProjection` + `AlarmPollInterval` for the ALMD projection. + +Full per-field descriptions live in `AbCipDriverOptions.cs`. The JSON skeleton is +reproduced in [docs/v2/driver-specs.md §3](../v2/driver-specs.md). + +## Alarm Projection + +`IAlarmSource` is served by `AbCipAlarmProjection`, which polls each subscribed +ALMD UDT's `InFaulted` + `Severity` members at `AlarmPollInterval` and fires +`OnAlarmEvent` on raise/clear transitions. It is **ALMD-only** in this pass (ALMA +analog alarms are a follow-up) and **disabled by default** — shops running FT +Alarm & Events should keep it off and take alarms through the native route, since +the projection semantics don't exactly mirror Rockwell FT A&E. + +## Testing + +- **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/` cover the driver, host-address parser, UDT planner, and alarm projection via fake tag runtimes. +- **Integration tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/` run against the `ab_server` Docker fixture. See [AbServer-Test-Fixture.md](AbServer-Test-Fixture.md) for the coverage map and the `AB_SERVER_ENDPOINT` wiring. +- **Manual client** — [Driver.AbCip.Cli.md](../Driver.AbCip.Cli.md). + +## Operational Notes + +- **Native heap is invisible to the GC.** `GetMemoryFootprint()` reports CLR allocations only; libplctag's native `Tag` heap does not show up there. Watch whole-process RSS, and use `ReinitializeAsync` (tears down + re-creates every device's libplctag handles) as the remediation for native-heap growth. +- **Handle eviction on failure** — a non-zero libplctag status or a transport exception evicts the cached tag runtime so the next read/write re-creates a fresh handle, mirroring the probe loop's recreate-on-failure behaviour. +- **Declaration-only UDT grouping is a footgun unless verified** — only enable `EnableDeclarationOnlyUdtGrouping` when every UDT's member declaration order has been hand-verified against the controller's compiled layout. diff --git a/docs/drivers/AbLegacy.md b/docs/drivers/AbLegacy.md new file mode 100644 index 00000000..c0c23082 --- /dev/null +++ b/docs/drivers/AbLegacy.md @@ -0,0 +1,100 @@ +# AB Legacy Driver + +In-process native-protocol driver that exposes legacy Allen-Bradley PLCs — +**SLC 500**, **MicroLogix**, **PLC-5**, and Logix-via-PCCC — as OPC UA nodes. It +runs inside the OtOpcUa server's .NET 10 AnyCPU process and speaks PCCC over +EtherNet/IP through the same libplctag.NET wrapper as the AB CIP driver, but +addresses data by **file** (data-table) rather than by symbolic tag. One driver +instance can serve many devices; per-device routing is keyed on the canonical +`ab://gateway[:port]/cip-path` host-address string. PCCC has no native push +model, so subscriptions are a polling overlay on top of `IReadable`. + +For the driver spec (capability surface, config shape, payload limits), see +[docs/v2/driver-specs.md §4](../v2/driver-specs.md). For the manual test client, +see [Driver.AbLegacy.Cli.md](../Driver.AbLegacy.Cli.md). For the integration +fixture coverage map, see [AbLegacy-Test-Fixture.md](AbLegacy-Test-Fixture.md). + +## Project Layout + +| Project | Role | +|---------|------| +| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/` | The driver — `AbLegacyDriver`, the libplctag runtime wrapper, the PCCC file-address parser (`AbLegacyAddress`), the host-address parser, and the status mapper. | +| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/` | `AbLegacyDriverOptions`, `AbLegacyDeviceOptions`, `AbLegacyTagDefinition`, and the `AbLegacyDataType` / `AbLegacyPlcFamily` / `AbLegacyPlcFamilyProfile` records bound from the driver's `DriverConfig` JSON. | + +Per family the `AbLegacyPlcFamilyProfile` supplies the libplctag `plc` attribute, +default CIP path, max-payload bytes, and the `SupportsStringFile` / +`SupportsLongFile` capability flags. MicroLogix uses direct EIP (empty default +path); MicroLogix and PLC-5 don't ship L-files; PLC-5 predates them entirely. +Tag types are validated against the device's profile at init time — declaring a +`Long` or `String` tag on a family that can't support it fails fast with a clear +message. + +## Capability Surface + +`AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable` +(`Driver.AbLegacy/AbLegacyDriver.cs`). There is **no `IAlarmSource`** — unlike the +AB CIP driver, PCCC has no ALMD instruction to project, so alarms are out of +scope. + +| Capability | Implementation entry point | Notes | +|------------|---------------------------|-------| +| `ITagDiscovery` | `DiscoverAsync` | Emits pre-declared tags under per-device folders. Tags are single-element today (`IsArray` hard-wired false); multi-element file ranges are a tracked follow-up. | +| `IReadable` | `ReadAsync` | Per-tag reads serialized per cached runtime under a lock (a libplctag `Tag` handle is not concurrency-safe across the server read path + poll loop). | +| `IWritable` | `WriteAsync` | Bit-within-word writes (N-file `N7:0/3`, B-file bits) do a per-parent-word read-modify-write under a lock. Non-writable tags return `BadNotWritable`. | +| `ISubscribable` | `SubscribeAsync` driven by the shared `PollGroupEngine` | No push model — subscriptions become polling groups. | +| `IHostConnectivityProbe` | `ProbeLoopAsync` + `GetHostStatuses` | One probe loop per device reading `Probe.ProbeAddress`; transitions log Warning (down) / Information (recover). | +| `IPerCallHostResolver` | `ResolveHost` | Routes each call to the tag's `DeviceHostAddress`; unknown references fall back to the first device, never throwing (per the interface contract). | + +## Addressing Model + +Per-device host addresses are the canonical `ab://gateway[:port]/cip-path` form +parsed by `AbLegacyHostAddress.TryParse`. When the parsed CIP path is empty the +family profile's default path is used (e.g. SLC 500 gets `1,0`; MicroLogix stays +empty for direct EIP). + +Tags carry a PCCC **file address** parsed by `AbLegacyAddress` (`AbLegacyAddress.cs`) +— file letter + file number + word number, with an optional bit index (`/N`) or +structured sub-element (`.ACC`, `.PRE`, …). The string is passed straight through +to libplctag's `name=` attribute; the parser validates shape and surfaces the +pieces for driver-side routing (e.g. deciding a bit needs read-modify-write): + +| Form | Meaning | +|------|---------| +| `N7:0` | Integer file 7, word 0 (signed 16-bit) | +| `F8:0` | Float file 8, word 0 (32-bit IEEE-754) | +| `B3:0/0` | Bit file 3, word 0, bit 0 | +| `L9:0` | Long-integer file (SLC 5/05+, 32-bit) | +| `ST9:0` | String file (82-byte fixed-length) | +| `T4:0.ACC` / `C5:0.PRE` | Timer / counter sub-element | +| `I:0/0` / `O:1/2` / `S:1` | Input / output / status system files (no file number) | + +`AbLegacyDataType` covers the corresponding PCCC types: `Bit`, `Int` (N), `Long` +(L), `Float` (F), `AnalogInt` (A), `String` (ST), and the `TimerElement` / +`CounterElement` / `ControlElement` sub-element families. The parser enforces +PCCC structural rules — bit-addressing only on 16/32-bit element files, +sub-elements only on T/C/R files, no file number on I/O/S — rejecting malformed +addresses before they reach libplctag. + +## Configuration + +`AbLegacyDriverOptions` (`Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs`) +binds from the driver's `DriverConfig` JSON: + +- **`Devices`** — one `AbLegacyDeviceOptions` per PLC (`HostAddress`, `PlcFamily`, optional `DeviceName`). +- **`Tags`** — pre-declared `AbLegacyTagDefinition` list (`Name`, `DeviceHostAddress`, `Address`, `DataType`, `Writable`, `WriteIdempotent`). +- **`Probe`** — connectivity-probe `Enabled` / `Interval` / `Timeout` / `ProbeAddress`. + +Full per-field descriptions live in the contracts assembly. The JSON skeleton is +reproduced in [docs/v2/driver-specs.md §4](../v2/driver-specs.md). + +## Testing + +- **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/` cover the driver, the PCCC address parser, and the host-address parser via fake tag runtimes. +- **Integration tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/` run against the AB Legacy Docker fixture. See [AbLegacy-Test-Fixture.md](AbLegacy-Test-Fixture.md) for the coverage map. +- **Manual client** — [Driver.AbLegacy.Cli.md](../Driver.AbLegacy.Cli.md). + +## Operational Notes + +- **Native heap is invisible to the GC.** As with AB CIP, `GetMemoryFootprint()` reports CLR allocations only; watch whole-process RSS and use `ReinitializeAsync` to recycle libplctag handles. +- **PCCC reconnect is more expensive than CIP** — legacy PLCs have no connection multiplexing, so the resilience pipeline should use longer backoff than for AB CIP (see [docs/v2/driver-specs.md §4](../v2/driver-specs.md)). +- **Single-element addressing today** — a PCCC file is inherently an array (an N7 file is up to 256 words), but the current tag surface addresses one element per tag; range-spanning tags must be enumerated element-by-element until multi-element addressing lands. diff --git a/docs/drivers/Modbus.md b/docs/drivers/Modbus.md new file mode 100644 index 00000000..b15744ee --- /dev/null +++ b/docs/drivers/Modbus.md @@ -0,0 +1,118 @@ +# 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 +`[.][:[]][:][:]`: + +| 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). + +## 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.