diff --git a/docs/plans/2026-07-24-driver-expansion-tracking.md b/docs/plans/2026-07-24-driver-expansion-tracking.md
index 9b88fb66..81db1a96 100644
--- a/docs/plans/2026-07-24-driver-expansion-tracking.md
+++ b/docs/plans/2026-07-24-driver-expansion-tracking.md
@@ -30,7 +30,7 @@ it reaches 📝.
| Wave | Deliverable | Design | Impl. plan | Status | Effort | Fixture / hardware |
|---|---|---|---|---|---|---|
| **0** | Universal Discover-backed browser | [design](2026-07-15-universal-discovery-browser-design.md) | [plan](2026-07-15-universal-discovery-browser-implementation.md) · [tasks](2026-07-15-universal-discovery-browser-implementation.md.tasks.json) | 🟡 **Live gate open** | S–M | none (retrofits shipped drivers) |
-| **1** | Modbus RTU (extend Modbus) | [design](2026-07-15-modbus-rtu-driver-design.md) | [plan](2026-07-24-modbus-rtu-driver.md) · [tasks](2026-07-24-modbus-rtu-driver.md.tasks.json) | 📝 **Plan ready** (11 tasks) | **S (lowest)** | `pymodbus` `rtu_over_tcp` — CI-simulatable, no new infra |
+| **1** | Modbus RTU (extend Modbus) | [design](2026-07-15-modbus-rtu-driver-design.md) | [plan](2026-07-24-modbus-rtu-driver.md) · [tasks](2026-07-24-modbus-rtu-driver.md.tasks.json) | 🟢 **Built — live gate PASSED** (11 tasks, branch `feat/modbus-rtu-driver`, pending merge) | **S (lowest)** | `pymodbus` `rtu_over_tcp` — CI-simulatable, no new infra |
| **1** | SQL poll | [design](2026-07-15-sql-poll-driver-design.md) | [plan](2026-07-24-sql-poll-driver.md) · [tasks](2026-07-24-sql-poll-driver.md.tasks.json) | 📝 **Plan ready** (22 tasks) | S–M | **existing central SQL Server** `10.100.0.35,14330` + SQLite unit |
| **2** | MTConnect Agent | [design](2026-07-15-mtconnect-driver-design.md) | [plan](2026-07-24-mtconnect-driver.md) · [tasks](2026-07-24-mtconnect-driver.md.tasks.json) | 📝 **Plan ready** (23 tasks) | S–M | Dockerized `mtconnect/cppagent` — CI-simulatable |
| **2** | MQTT / Sparkplug B | [design](2026-07-15-mqtt-sparkplug-driver-design.md) | [plan](2026-07-24-mqtt-sparkplug-driver.md) · [tasks](2026-07-24-mqtt-sparkplug-driver.md.tasks.json) | 📝 **Plan ready** (27 tasks, P1+P2) | M–L | Mosquitto/EMQX + C# Sparkplug sim — CI-simulatable |
@@ -92,16 +92,30 @@ marginal cost.
Both are **fully CI-simulatable with zero new hardware**; Modbus RTU needs no new infra at all, and
SQL poll reuses the always-on central SQL Server. This is the recommended next build.
-### Modbus RTU — 📝 Plan ready
+### Modbus RTU — 🟢 Built, live gate PASSED (branch `feat/modbus-rtu-driver`, pending merge)
- **Design:** [`2026-07-15-modbus-rtu-driver-design.md`](2026-07-15-modbus-rtu-driver-design.md)
-- **Implementation plan:** [`2026-07-24-modbus-rtu-driver.md`](2026-07-24-modbus-rtu-driver.md) · [`.tasks.json`](2026-07-24-modbus-rtu-driver.md.tasks.json) — **11 tasks** (1 trivial / 5 small / 2 standard / 3 high-risk).
+- **Implementation plan:** [`2026-07-24-modbus-rtu-driver.md`](2026-07-24-modbus-rtu-driver.md) · [`.tasks.json`](2026-07-24-modbus-rtu-driver.md.tasks.json) — **11 tasks** (1 trivial / 5 small / 2 standard / 3 high-risk), all complete via subagent-driven-development.
- **Scope:** extend the existing Modbus driver with a `Transport` selector (RTU CRC-16 + FC-aware
length, no TxId) + a `rtu_over_tcp` `pymodbus` docker profile + the AdminUI selector.
**Direct-serial transport is descoped** (user, 2026-07-15) — RTU-over-TCP via a serial→Ethernet
gateway is the only shipped mode, so there is no serial hardware gate.
-- **Fixture:** add an `rtu_over_tcp` profile to the existing Modbus integration fixture. First P1
- step is confirming the pymodbus simulator exposes the RTU framer on a TCP server.
-- **Effort:** **S — the lowest on the roadmap.** Recommended first.
+- **Fixture:** added the `rtu_over_tcp` profile (`framer=rtu`, host `:5021`, co-runs with `standard`)
+ to the Modbus integration fixture. **Unknown confirmed:** pymodbus 3.13's simulator DOES honour
+ `framer=rtu` on a TCP server (wire-probed a pure RTU FC03 response, no MBAP) — so the simple profile
+ path was taken; **no stdlib fallback server was needed.**
+- **Verification (all green):** unit suite **344/344**; full solution build 0 errors; the 2 RTU
+ integration tests pass **live** against the real `framer=rtu` fixture (read HR5→5, write+readback
+ HR200←4242). Per-task review chain (spec + code reviewers) + a final whole-branch review, all
+ approved-to-merge.
+- **Live `/run` gate — PASSED (2026-07-24, docker-dev rebuilt from this branch):**
+ - AdminUI `/raw` Modbus **driver** config modal renders the new **Transport** selector (with the
+ operator hint) — set to `RtuOverTcp`, saved.
+ - **Device Test Connect → `OK · 23 MS`** — the probe built a `ModbusRtuOverTcpTransport` via the
+ factory and spoke **FC03 over real RTU framing** to `10.100.0.35:5021`.
+ - Authored raw tags HR5 (r/o) + HR200 (r/w) → **deployment `06ec1632` Sealed** (all 6 nodes acked).
+ - Client.CLI on `opc.tcp://localhost:4840` (`multi-role`/`password`): **read** `ns=2;s=rtu-gate/Device1/HR5`
+ → **5, Good**; **write** `ns=2;s=rtu-gate/Device1/HR200` = 4242 → readback **4242, Good**.
+- **Effort:** **S — the lowest on the roadmap.** Delivered first, as recommended.
### SQL poll — 📝 Plan ready
- **Design:** [`2026-07-15-sql-poll-driver-design.md`](2026-07-15-sql-poll-driver-design.md)
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusDriverOptions.cs
index 642f1902..39a4104e 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusDriverOptions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusDriverOptions.cs
@@ -130,6 +130,14 @@ public sealed class ModbusDriverOptions
///
public MelsecFamily MelsecSubFamily { get; init; } = MelsecFamily.Q_L_iQR;
+ ///
+ /// Wire transport selector. (default) is the
+ /// historical Modbus TCP/MBAP framing.
+ /// frames Modbus RTU PDUs (CRC16, no MBAP header) over the same TCP socket — for
+ /// serial-to-Ethernet gateways that bridge RS-485 without re-encapsulating to MBAP.
+ ///
+ public ModbusTransportMode Transport { get; init; } = ModbusTransportMode.Tcp;
+
///
/// When true, the driver suppresses redundant writes: if the most recent
/// successful write to a tag carried value V and a new write of V arrives, the second
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusTransportMode.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusTransportMode.cs
new file mode 100644
index 00000000..6d7cfe0d
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusTransportMode.cs
@@ -0,0 +1,17 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
+
+///
+/// Wire transport for a Modbus driver instance. = Modbus/TCP (MBAP + TxId);
+/// = RTU framing (address + CRC-16, no MBAP) tunnelled over a socket to
+/// a serial→Ethernet gateway. Default — existing configs omit the field.
+/// A direct-serial Rtu member is intentionally NOT reserved here (descoped 2026-07-15);
+/// do not number-squat it.
+///
+public enum ModbusTransportMode
+{
+ /// Modbus/TCP — 7-byte MBAP header + transaction id (the historical default).
+ Tcp,
+
+ /// RTU framing ([addr][PDU][CRC-16]) tunnelled over a socket to a serial→Ethernet gateway.
+ RtuOverTcp,
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusCrc.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusCrc.cs
new file mode 100644
index 00000000..0e81ed1a
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusCrc.cs
@@ -0,0 +1,48 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
+
+///
+/// CRC-16/MODBUS helper: reflected polynomial 0xA001, initial value 0xFFFF.
+/// On the wire the CRC is appended low byte first, high byte second —
+/// does that; just returns the 16-bit value. Byte-stream-agnostic:
+/// no socket or framing knowledge lives here.
+///
+public static class ModbusCrc
+{
+ /// Computes the CRC-16/MODBUS checksum over .
+ /// The bytes to checksum (e.g. the RTU frame minus the trailing CRC).
+ /// The 16-bit CRC value.
+ public static ushort Compute(ReadOnlySpan data)
+ {
+ ushort crc = 0xFFFF;
+ foreach (var b in data)
+ {
+ crc ^= b;
+ for (var i = 0; i < 8; i++)
+ {
+ var carry = (crc & 0x0001) != 0;
+ crc >>= 1;
+ if (carry)
+ {
+ crc ^= 0xA001;
+ }
+ }
+ }
+
+ return crc;
+ }
+
+ ///
+ /// Returns with its CRC-16/MODBUS appended, low byte first.
+ ///
+ /// The frame body to checksum.
+ /// A new array: followed by [crc & 0xFF, crc >> 8].
+ public static byte[] Append(ReadOnlySpan body)
+ {
+ var crc = Compute(body);
+ var result = new byte[body.Length + 2];
+ body.CopyTo(result);
+ result[^2] = (byte)(crc & 0xFF);
+ result[^1] = (byte)(crc >> 8);
+ return result;
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
index 69646220..12854f7c 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
@@ -94,7 +94,8 @@ public sealed class ModbusDriver
/// Initializes a new Modbus TCP driver with the specified options and transport factory.
/// Driver configuration options.
/// Unique identifier for this driver instance.
- /// Factory to create the Modbus transport; defaults to ModbusTcpTransport.
+ /// Factory to create the Modbus transport; defaults to
+ /// , which honours .
/// Logger instance; defaults to null logger if not provided.
public ModbusDriver(ModbusDriverOptions options, string driverInstanceId,
Func? transportFactory = null,
@@ -107,11 +108,7 @@ public sealed class ModbusDriver
_resolver = new EquipmentTagRefResolver(
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
_transportFactory = transportFactory
- ?? (o => new ModbusTcpTransport(
- o.Host, o.Port, o.Timeout, o.AutoReconnect,
- keepAlive: o.KeepAlive,
- idleDisconnect: o.IdleDisconnectTimeout,
- reconnect: o.Reconnect));
+ ?? (o => ModbusTransportFactory.Create(o));
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs
index 5291f0cb..93fdb4a7 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs
@@ -74,6 +74,8 @@ public static class ModbusDriverFactoryExtensions
: ParseEnum(dto.Family, "", driverInstanceId, "Family"),
MelsecSubFamily = dto.MelsecSubFamily is null ? MelsecFamily.Q_L_iQR
: ParseEnum(dto.MelsecSubFamily, "", driverInstanceId, "MelsecSubFamily"),
+ Transport = dto.Transport is null ? ModbusTransportMode.Tcp
+ : ParseEnum(dto.Transport, "", driverInstanceId, "Transport"),
AutoProhibitReprobeInterval = dto.AutoProhibitReprobeMs is { } reprobeMs ? TimeSpan.FromMilliseconds(reprobeMs) : null,
AutoReconnect = dto.AutoReconnect ?? true,
// v3: the deploy artifact (Wave C) populates rawTags with the cluster's authored raw Modbus
@@ -159,6 +161,8 @@ public static class ModbusDriverFactoryExtensions
public string? Family { get; init; }
/// Gets or sets the Melsec subfamily.
public string? MelsecSubFamily { get; init; }
+ /// Gets or sets the wire transport mode (Tcp / RtuOverTcp). Null defaults to Tcp.
+ public string? Transport { get; init; }
/// Gets or sets the automatic prohibition reprobei interval in milliseconds.
public int? AutoProhibitReprobeMs { get; init; }
/// Gets or sets a value indicating whether automatic reconnection is enabled.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs
index afd6683c..77739782 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs
@@ -29,15 +29,19 @@ public sealed class ModbusDriverProbe : IDriverProbe
///
public string DriverType => "Modbus";
- /// The parsed probe target — host/port/unitId + the effective connection timeout, all read
- /// from the SAME factory DTO shape (ModbusDriverConfigDto) the driver factory parses, so
- /// timeoutMs binds identically to the factory (R2-11, 05/CONV-2; the OpcUaClient parity rule).
- internal readonly record struct ProbeTarget(string Host, int Port, byte UnitId, TimeSpan Timeout);
+ /// The parsed probe target — host/port/unitId + the effective connection timeout + the wire
+ /// transport mode, all read from the SAME factory DTO shape (ModbusDriverConfigDto) the driver
+ /// factory parses, so timeoutMs binds identically to the factory (R2-11, 05/CONV-2; the
+ /// OpcUaClient parity rule) and an RtuOverTcp-authored config probes over RTU framing —
+ /// matching how the driver will actually run.
+ internal readonly record struct ProbeTarget(string Host, int Port, byte UnitId, TimeSpan Timeout, ModbusTransportMode Transport);
/// Parse the driver config JSON into a using the factory DTO shape.
/// Returns a null target + an error message when the JSON is invalid or has no host/port. The effective
/// timeout mirrors the factory (TimeSpan.FromMilliseconds(dto.TimeoutMs ?? …)); when the config
- /// omits timeoutMs the caller's is used.
+ /// omits timeoutMs the caller's is used. Transport
+ /// mirrors the factory's null-defaults-to-Tcp convention ();
+ /// an unrecognized value is reported as a probe-target error rather than silently falling back.
/// The driver config JSON (factory DTO shape).
/// The timeout used when the config omits timeoutMs.
/// The parsed target, or a null target with an error string.
@@ -52,8 +56,15 @@ public sealed class ModbusDriverProbe : IDriverProbe
if (string.IsNullOrWhiteSpace(dto.Host) || port <= 0)
return (null, "Config has no host/port to probe.");
+ var transportMode = ModbusTransportMode.Tcp;
+ if (dto.Transport is not null && !Enum.TryParse(dto.Transport, ignoreCase: true, out transportMode))
+ {
+ return (null, $"Config has unknown Transport '{dto.Transport}'. " +
+ $"Expected one of {string.Join(", ", Enum.GetNames())}");
+ }
+
var timeout = dto.TimeoutMs is { } ms && ms > 0 ? TimeSpan.FromMilliseconds(ms) : fallbackTimeout;
- return (new ProbeTarget(dto.Host!, port, dto.UnitId ?? 1, timeout), null);
+ return (new ProbeTarget(dto.Host!, port, dto.UnitId ?? 1, timeout, transportMode), null);
}
///
@@ -72,9 +83,17 @@ public sealed class ModbusDriverProbe : IDriverProbe
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(timeout);
- // Phase 1 — TCP connect (using ModbusTcpTransport which handles IPv4 preference).
+ // Phase 1 — TCP connect, over whichever transport the config's Transport mode selects
+ // (ModbusTransportFactory is the single mapping point — same switch the live driver uses).
// autoReconnect=false: this is a one-shot probe, no retry loops.
- var transport = new ModbusTcpTransport(host, port, timeout, autoReconnect: false);
+ var transport = ModbusTransportFactory.Create(new ModbusDriverOptions
+ {
+ Host = host,
+ Port = port,
+ Timeout = timeout,
+ Transport = t.Transport,
+ AutoReconnect = false,
+ });
await using (transport.ConfigureAwait(false))
{
try
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusRtuFraming.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusRtuFraming.cs
new file mode 100644
index 00000000..b7f20d05
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusRtuFraming.cs
@@ -0,0 +1,187 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
+
+///
+/// Modbus RTU framing: builds a request ADU ([unitId] + PDU + CRC) and deframes a
+/// response back to its bare PDU. Byte-stream-agnostic — it operates on a
+/// and knows nothing about sockets or serial ports, so a future serial transport can reuse it.
+///
+///
+///
+/// The single genuinely-new correctness risk of the RTU path: an RTU frame carries no
+/// length field (unlike TCP's MBAP header), so the response size must be derived from
+/// the function code. After reading addr(1) + fc(1), the remaining byte count
+/// is one of three shapes:
+///
+///
+/// ShapeTrailing bytes after addr,fc
+///
+/// Exception (fc & 0x80)
+/// excCode(1) + CRC(2)
+///
+///
+/// Read (FC 01/02/03/04)
+/// byteCount(1) then byteCount data bytes + CRC(2)
+///
+///
+/// Write echo (FC 05/06/15/16)
+/// fixed 4 bytes + CRC(2)
+///
+///
+///
+/// The trailing CRC is validated over [addr, ...pdu] (everything except the two CRC
+/// bytes) before the frame is interpreted — so a corrupt exception frame surfaces as a
+/// desync, never as a bogus . A CRC mismatch or a short read
+/// (truncation / stream closed mid-frame) throws ;
+/// a CRC-valid exception PDU throws carrying the original
+/// function code (fc & 0x7F) and exception code.
+///
+///
+/// Once the CRC passes, the response's address byte is validated against the addressed unit.
+/// On an RS-485 multi-drop bus a CRC-valid reply from the wrong slave would otherwise be
+/// silently accepted as the addressed unit's data; such a frame is a unit-mismatch
+/// . This ordering is deliberate — a corrupt frame
+/// stays a CRC/desync failure, and only a coherent-but-mis-addressed frame becomes a
+/// unit-mismatch desync.
+///
+///
+public static class ModbusRtuFraming
+{
+ ///
+ /// Builds an RTU request ADU: the unit id, the PDU, and the trailing CRC-16/MODBUS
+ /// (appended low byte first).
+ ///
+ /// The RTU slave/unit address.
+ /// The bare PDU (function code + data).
+ /// A new array: [unitId, ...pdu, crcLo, crcHi].
+ public static byte[] BuildAdu(byte unitId, ReadOnlySpan pdu)
+ {
+ var frame = new byte[1 + pdu.Length];
+ frame[0] = unitId;
+ pdu.CopyTo(frame.AsSpan(1));
+ return ModbusCrc.Append(frame);
+ }
+
+ ///
+ /// Reads a single RTU response frame from and returns its bare PDU
+ /// ([fc, ...data]), with the leading unit-id and trailing CRC stripped.
+ ///
+ /// The byte stream to read the response from.
+ ///
+ /// The unit id the request was addressed to. The response's address byte is validated against
+ /// it after the CRC passes; a mismatch is a unit-mismatch desync.
+ ///
+ /// A token to cancel the read.
+ /// The bare response PDU (function code followed by its data).
+ ///
+ /// The frame was truncated (stream closed mid-frame), its trailing CRC did not validate, or its
+ /// address byte did not match .
+ ///
+ ///
+ /// The frame was a CRC-valid Modbus exception PDU (high bit set on the function code).
+ ///
+ public static async Task ReadResponsePduAsync(
+ Stream stream, byte expectedUnit, CancellationToken ct)
+ {
+ // Header: addr(1) + fc(1). The function code selects how many more bytes to read.
+ var header = new byte[2];
+ await ReadExactlyAsync(stream, header, ct).ConfigureAwait(false);
+ var fc = header[1];
+
+ int trailing; // bytes after addr+fc, up to and including the 2 CRC bytes.
+ if ((fc & 0x80) != 0)
+ {
+ // Exception frame: excCode(1) + CRC(2).
+ trailing = 1 + 2;
+ }
+ else if (fc is 0x01 or 0x02 or 0x03 or 0x04)
+ {
+ // Read response: byteCount(1) then byteCount data bytes + CRC(2).
+ var bc = new byte[1];
+ await ReadExactlyAsync(stream, bc, ct).ConfigureAwait(false);
+ var byteCount = bc[0];
+ var rest = new byte[byteCount + 2];
+ await ReadExactlyAsync(stream, rest, ct).ConfigureAwait(false);
+ return ValidateAndStrip(expectedUnit, header, bc, rest);
+ }
+ else
+ {
+ // Fixed 4-byte echo: correct for the write FCs this driver emits (05/06/0F/10). A future
+ // variable-length response FC outside 01-04 (e.g. FC23) would be mis-sized here and
+ // surface as a desync — revisit the FC-shape table if the driver starts emitting one.
+ trailing = 4 + 2;
+ }
+
+ var tail = new byte[trailing];
+ await ReadExactlyAsync(stream, tail, ct).ConfigureAwait(false);
+ return ValidateAndStrip(expectedUnit, header, ReadOnlySpan.Empty, tail);
+ }
+
+ ///
+ /// Assembles the full frame from its pieces, validates the trailing CRC over everything
+ /// except the CRC bytes, then validates the response address against
+ /// and strips addr + CRC to return the bare PDU.
+ /// A CRC-valid exception PDU throws .
+ ///
+ private static byte[] ValidateAndStrip(
+ byte expectedUnit, ReadOnlySpan header, ReadOnlySpan middle, ReadOnlySpan tail)
+ {
+ // Reassemble the whole frame: header(addr,fc) + middle(optional byteCount) + tail.
+ var frame = new byte[header.Length + middle.Length + tail.Length];
+ header.CopyTo(frame);
+ middle.CopyTo(frame.AsSpan(header.Length));
+ tail.CopyTo(frame.AsSpan(header.Length + middle.Length));
+
+ // CRC covers everything except the trailing 2 CRC bytes.
+ var body = frame.AsSpan(0, frame.Length - 2);
+ var expected = ModbusCrc.Compute(body);
+ var actual = (ushort)(frame[^2] | (frame[^1] << 8));
+ if (actual != expected)
+ throw new ModbusTransportDesyncException(
+ ModbusTransportDesyncException.DesyncReason.CrcMismatch,
+ $"Modbus RTU CRC mismatch: computed {expected:X4} got {actual:X4}");
+
+ // Unit-address validation runs only AFTER the CRC passes: a corrupt frame is a CRC/desync
+ // failure, and only a coherent-but-mis-addressed frame (a CRC-valid reply from the wrong
+ // RS-485 slave) is a unit-mismatch desync. Never silently accept another unit's data.
+ var addr = frame[0];
+ if (addr != expectedUnit)
+ throw new ModbusTransportDesyncException(
+ ModbusTransportDesyncException.DesyncReason.UnitMismatch,
+ $"Modbus RTU unit mismatch: expected {expectedUnit} got {addr}");
+
+ // Bare PDU = frame minus leading addr and trailing CRC.
+ var pdu = body[1..].ToArray();
+
+ // Exception PDU: high bit set on the function code. The CRC already validated, so this is a
+ // coherent protocol-level error — surface the ORIGINAL function code (fc & 0x7F).
+ if ((pdu[0] & 0x80) != 0)
+ {
+ var fc = (byte)(pdu[0] & 0x7F);
+ var exCode = pdu[1];
+ throw new ModbusException(fc, exCode, $"Modbus exception fc={fc} code={exCode}");
+ }
+
+ return pdu;
+ }
+
+ ///
+ /// Fills completely from , throwing a
+ /// truncation desync if the stream ends first. Mirrors
+ /// ModbusTcpTransport.ReadExactlyAsync, but normalises the end-of-stream to a
+ /// so a length-less RTU frame that arrives
+ /// short is classified as a desync rather than a bare .
+ ///
+ private static async Task ReadExactlyAsync(Stream stream, byte[] buf, CancellationToken ct)
+ {
+ var read = 0;
+ while (read < buf.Length)
+ {
+ var n = await stream.ReadAsync(buf.AsMemory(read), ct).ConfigureAwait(false);
+ if (n == 0)
+ throw new ModbusTransportDesyncException(
+ ModbusTransportDesyncException.DesyncReason.TruncatedFrame,
+ $"Modbus RTU frame truncated: expected {buf.Length} bytes, got {read}");
+ read += n;
+ }
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusRtuOverTcpTransport.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusRtuOverTcpTransport.cs
new file mode 100644
index 00000000..7b010e4f
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusRtuOverTcpTransport.cs
@@ -0,0 +1,190 @@
+using System.Net.Sockets;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
+
+///
+/// Concrete Modbus RTU-over-TCP transport. Composes for the
+/// connect / reconnect / keep-alive / idle machinery and for the
+/// wire layout — the same lifecycle uses, but with CRC framing
+/// instead of MBAP.
+///
+///
+///
+/// Delta from : an RTU ADU is [unitId][PDU][CRC] with
+/// no MBAP header and no transaction id. Because there is no TxId to correlate
+/// interleaved responses, at most one transaction may be on the bus at a time — the
+/// single-flight is therefore mandatory, not merely a
+/// diagnostics convenience.
+///
+///
+/// Survives mid-transaction socket drops the same way the TCP transport does: a socket-level
+/// failure ( / /
+/// , and the
+/// that derives from ) triggers a single reconnect-then-retry; a
+/// second failure bubbles up so the driver's health surface reflects the real state.
+///
+///
+/// Every transaction runs under a per-op deadline (a linked
+/// , design §7 / R2-01) so a
+/// frozen gateway can never wedge a poll: the deadline firing is normalised to a
+/// and tears the socket down so the next attempt
+/// reconnects. A caller cancellation is distinguished from that timeout and propagates
+/// as a plain with no teardown.
+///
+///
+/// The constructor is connection-free; all socket I/O happens in /
+/// . The seam injects a pre-connected
+/// (an in-memory duplex fake) so the send/receive orchestration,
+/// single-flight, and deadline can be exercised with no socket — in that path
+/// is and the idle / reconnect machinery is
+/// skipped (a raw injected stream cannot reconnect).
+///
+///
+public sealed class ModbusRtuOverTcpTransport : IModbusTransport
+{
+ private readonly ModbusSocketLifecycle? _life;
+ private readonly Stream? _testStream;
+ private readonly TimeSpan _timeout;
+ private readonly SemaphoreSlim _gate = new(1, 1);
+ private bool _disposed;
+
+ /// Initializes a new instance of the class.
+ /// The host address or hostname of the Modbus gateway.
+ /// The TCP port of the Modbus gateway.
+ /// The timeout for socket operations and the per-op response deadline.
+ /// Whether to automatically reconnect on socket failures.
+ /// Optional keep-alive configuration for the socket.
+ /// Optional duration after which an idle socket is disconnected.
+ /// Optional reconnect backoff configuration.
+ public ModbusRtuOverTcpTransport(
+ string host, int port, TimeSpan timeout, bool autoReconnect = true,
+ ModbusKeepAliveOptions? keepAlive = null,
+ TimeSpan? idleDisconnect = null,
+ ModbusReconnectOptions? reconnect = null)
+ {
+ _timeout = timeout;
+ _life = new ModbusSocketLifecycle(host, port, timeout, autoReconnect, keepAlive, idleDisconnect, reconnect);
+ }
+
+ private ModbusRtuOverTcpTransport(Stream testStream, TimeSpan timeout)
+ {
+ _timeout = timeout;
+ _testStream = testStream;
+ }
+
+ ///
+ /// Test seam: builds a transport over a pre-connected, in-memory duplex ,
+ /// bypassing and the idle / reconnect machinery so the send/receive
+ /// orchestration, single-flight, and per-op deadline can be exercised with no real socket.
+ ///
+ /// A pre-connected duplex stream standing in for the socket.
+ /// The per-op response deadline.
+ /// A transport driving directly.
+ internal static ModbusRtuOverTcpTransport ForTest(Stream stream, TimeSpan timeout) => new(stream, timeout);
+
+ ///
+ public Task ConnectAsync(CancellationToken ct) =>
+ // The ForTest seam is handed a pre-connected stream — nothing to dial.
+ _life is null ? Task.CompletedTask : _life.ConnectAsync(ct);
+
+ ///
+ public async Task SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
+ {
+ if (_disposed) throw new ObjectDisposedException(nameof(ModbusRtuOverTcpTransport));
+ if (CurrentStream is null) throw new InvalidOperationException("Transport not connected");
+
+ // Single-flight: RTU carries no transaction id, so at most one transaction may be on the bus
+ // at a time — serialise every send/receive under the gate.
+ await _gate.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ // Proactive idle-disconnect (real socket only): if the socket has been quiet longer than
+ // the configured threshold, tear it down + reconnect before this PDU lands. Defends
+ // against silent NAT / firewall reaping.
+ if (_life is not null && _life.ShouldReconnectForIdle())
+ {
+ await _life.TearDownAsync().ConfigureAwait(false);
+ await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
+ }
+
+ try
+ {
+ var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
+ _life?.MarkSuccess();
+ return result;
+ }
+ catch (Exception ex) when (_life is not null && _life.AutoReconnect && ModbusSocketLifecycle.IsSocketLevelFailure(ex))
+ {
+ // Mid-transaction drop: tear down the dead socket, reconnect (with backoff if
+ // configured), resend. Single retry — a second failure propagates so health/status
+ // reflect reality. Never reached in the ForTest seam (a raw injected stream has no
+ // lifecycle to reconnect).
+ await _life.TearDownAsync().ConfigureAwait(false);
+ await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
+ var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
+ _life.MarkSuccess();
+ return result;
+ }
+ }
+ finally
+ {
+ _gate.Release();
+ }
+ }
+
+ /// The live stream: the lifecycle's , or the injected test stream.
+ private Stream? CurrentStream => _life?.Stream ?? _testStream;
+
+ ///
+ /// Executes exactly one RTU transaction on the current stream: build the ADU, write + flush,
+ /// read back one deframed response PDU. See the class remarks for the desync / timeout /
+ /// caller-cancel handling.
+ ///
+ private async Task SendOnceAsync(byte unitId, byte[] pdu, CancellationToken ct)
+ {
+ var stream = CurrentStream;
+ if (stream is null) throw new InvalidOperationException("Transport not connected");
+
+ // RTU ADU: [unitId] + PDU + CRC — no MBAP header, no TxId.
+ var adu = ModbusRtuFraming.BuildAdu(unitId, pdu);
+
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ cts.CancelAfter(_timeout);
+ try
+ {
+ await stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false);
+ await stream.FlushAsync(cts.Token).ConfigureAwait(false);
+
+ // Framing owns the length-less RTU deframe + CRC + unit-address validation. A CRC-valid
+ // exception PDU throws ModbusException (socket still coherent — propagates, no teardown);
+ // truncation / CRC / unit-mismatch throw ModbusTransportDesyncException.
+ return await ModbusRtuFraming.ReadResponsePduAsync(stream, unitId, cts.Token).ConfigureAwait(false);
+ }
+ catch (ModbusTransportDesyncException)
+ {
+ // Framing violation: the stream is desynchronized — never reuse it.
+ if (_life is not null) await _life.TearDownAsync().ConfigureAwait(false);
+ throw;
+ }
+ catch (OperationCanceledException) when (!ct.IsCancellationRequested)
+ {
+ // Per-op timeout fired (NOT the caller). The response is unknown / may still land later
+ // and corrupt the next read — tear the socket down and normalise as a desync so the
+ // reconnect retry / status mapping engages.
+ if (_life is not null) await _life.TearDownAsync().ConfigureAwait(false);
+ throw new ModbusTransportDesyncException(
+ ModbusTransportDesyncException.DesyncReason.Timeout,
+ $"Modbus per-op response timeout after {_timeout.TotalMilliseconds:0}ms");
+ }
+ }
+
+ /// Asynchronously disposes the transport and underlying socket resources.
+ /// A task that represents the asynchronous operation.
+ public async ValueTask DisposeAsync()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ if (_life is not null) await _life.DisposeAsync().ConfigureAwait(false);
+ _gate.Dispose();
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusSocketLifecycle.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusSocketLifecycle.cs
new file mode 100644
index 00000000..024d28f9
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusSocketLifecycle.cs
@@ -0,0 +1,221 @@
+using System.Net.Sockets;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
+
+///
+/// Owns the raw TCP socket lifecycle shared by every Modbus-over-TCP transport (MBAP and
+/// RTU-over-TCP alike): the IPv4-preference connect, OS-level keep-alive, geometric-backoff
+/// reconnect, idle-disconnect tracking, and teardown. It exposes the current
+/// so the composing transport can drive its own framing on top —
+/// the lifecycle knows nothing about MBAP / RTU wire layout.
+///
+///
+///
+/// Extracted verbatim from so the connect / reconnect /
+/// keep-alive / idle behaviour is written once and composed by both transports. The
+/// constructor is connection-free — all socket I/O happens in /
+/// .
+///
+///
+/// Why keep-alive matters for DL205/DL260: the AutomationDirect H2-ECOM100 does NOT send
+/// TCP keepalives per docs/v2/dl205.md §behavioral-oddities, so any NAT/firewall
+/// between the gateway and PLC can silently close an idle socket after 2-5 minutes.
+/// Enabling OS-level SO_KEEPALIVE lets the driver's own side detect a stuck socket
+/// in reasonable time even when the application is mostly idle.
+///
+///
+public sealed class ModbusSocketLifecycle : IAsyncDisposable
+{
+ private readonly string _host;
+ private readonly int _port;
+ private readonly TimeSpan _timeout;
+ private readonly bool _autoReconnect;
+ private readonly ModbusKeepAliveOptions _keepAlive;
+ private readonly TimeSpan? _idleDisconnect;
+ private readonly ModbusReconnectOptions _reconnect;
+ private TcpClient? _client;
+ private NetworkStream? _stream;
+ private DateTime _lastSuccessUtc = DateTime.UtcNow;
+
+ /// Initializes a new instance of the class.
+ /// The host address or hostname of the Modbus server.
+ /// The TCP port of the Modbus server.
+ /// The timeout for socket operations.
+ /// Whether to automatically reconnect on socket failures.
+ /// Optional keep-alive configuration for the socket.
+ /// Optional duration after which an idle socket is disconnected.
+ /// Optional reconnect backoff configuration.
+ public ModbusSocketLifecycle(
+ string host, int port, TimeSpan timeout, bool autoReconnect = true,
+ ModbusKeepAliveOptions? keepAlive = null,
+ TimeSpan? idleDisconnect = null,
+ ModbusReconnectOptions? reconnect = null)
+ {
+ _host = host;
+ _port = port;
+ _timeout = timeout;
+ _autoReconnect = autoReconnect;
+ _keepAlive = keepAlive ?? new ModbusKeepAliveOptions();
+ _idleDisconnect = idleDisconnect;
+ _reconnect = reconnect ?? new ModbusReconnectOptions();
+ }
+
+ /// Gets the current live , or when not connected.
+ public NetworkStream? Stream => _stream;
+
+ /// Gets a value indicating whether auto-reconnect on socket failures is enabled.
+ public bool AutoReconnect => _autoReconnect;
+
+ /// Establishes a connection to the Modbus server, preferring IPv4.
+ /// A cancellation token to observe for cancellation.
+ /// A task representing the asynchronous connection operation.
+ public async Task ConnectAsync(CancellationToken ct)
+ {
+ // Resolve the host explicitly + prefer IPv4. .NET's TcpClient default-constructor is
+ // dual-stack (IPv6 first, fallback to IPv4) — but most Modbus TCP devices (PLCs and
+ // simulators like pymodbus) bind 0.0.0.0 only, so the IPv6 attempt times out and we
+ // burn the entire ConnectAsync budget before even trying IPv4. Resolving first +
+ // dialing the IPv4 address directly sidesteps that.
+ var addresses = await System.Net.Dns.GetHostAddressesAsync(_host, ct).ConfigureAwait(false);
+ var ipv4 = System.Linq.Enumerable.FirstOrDefault(addresses,
+ a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork);
+ var target = ipv4 ?? (addresses.Length > 0 ? addresses[0] : System.Net.IPAddress.Loopback);
+
+ _client = new TcpClient(target.AddressFamily);
+ EnableKeepAlive(_client, _keepAlive);
+
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ cts.CancelAfter(_timeout);
+ await _client.ConnectAsync(target, _port, cts.Token).ConfigureAwait(false);
+ _stream = _client.GetStream();
+ _lastSuccessUtc = DateTime.UtcNow;
+ }
+
+ ///
+ /// Connect attempt with the configured geometric backoff. The first attempt fires after
+ /// (default zero — immediate); each
+ /// subsequent attempt sleeps for the previous delay times BackoffMultiplier,
+ /// capped at MaxDelay. Caller's cancellation token aborts the loop.
+ ///
+ /// A cancellation token to observe for cancellation.
+ /// A task representing the asynchronous reconnect operation.
+ public async Task ConnectWithBackoffAsync(CancellationToken ct)
+ {
+ var delay = _reconnect.InitialDelay;
+ var attempt = 0;
+ while (true)
+ {
+ if (delay > TimeSpan.Zero)
+ await Task.Delay(delay, ct).ConfigureAwait(false);
+ try
+ {
+ await ConnectAsync(ct).ConfigureAwait(false);
+ return;
+ }
+ catch (Exception ex) when (IsSocketLevelFailure(ex) && _autoReconnect)
+ {
+ attempt++;
+ // Geometric growth, capped. Use Math.Min on ticks so we don't overflow with
+ // pathological multipliers / long deployments.
+ var nextTicks = (long)(Math.Max(delay.Ticks, TimeSpan.FromMilliseconds(100).Ticks) * _reconnect.BackoffMultiplier);
+ delay = TimeSpan.FromTicks(Math.Min(nextTicks, _reconnect.MaxDelay.Ticks));
+ if (attempt >= 10)
+ {
+ // Bail after 10 attempts to surface persistent failure to the caller. With
+ // the default backoff (1s base, 2.0x mult, 30s cap) this is roughly 4 minutes
+ // of attempts; with InitialDelay=0 it's immediate up to the same cap.
+ throw;
+ }
+ }
+ }
+ }
+
+ ///
+ /// Reports whether the socket has been idle longer than the configured idle-disconnect
+ /// threshold and should be proactively torn down + reconnected before the next transaction.
+ /// Always when no idle-disconnect timeout is configured.
+ ///
+ /// when the idle threshold has been exceeded.
+ public bool ShouldReconnectForIdle() =>
+ _idleDisconnect.HasValue && DateTime.UtcNow - _lastSuccessUtc > _idleDisconnect.Value;
+
+ /// Records that a transaction just succeeded, resetting the idle-disconnect clock.
+ public void MarkSuccess() => _lastSuccessUtc = DateTime.UtcNow;
+
+ /// Tears down the current socket + stream, leaving the lifecycle ready to reconnect.
+ /// A task representing the asynchronous teardown operation.
+ public async Task TearDownAsync()
+ {
+ try { if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false); }
+ catch { /* best-effort */ }
+ _stream = null;
+ try { _client?.Dispose(); } catch { }
+ _client = null;
+ }
+
+ ///
+ /// Enable SO_KEEPALIVE with aggressive probe timing. DL205/DL260 doesn't send keepalives
+ /// itself; having the OS probe the socket every ~30s lets the driver notice a dead PLC
+ /// or broken NAT path long before the default 2-hour Windows idle timeout fires.
+ /// Non-fatal if the underlying OS rejects the option (some older Linux / container
+ /// sandboxes don't expose the fine-grained timing levers — the driver still works,
+ /// application-level probe still detects problems).
+ ///
+ private static void EnableKeepAlive(TcpClient client, ModbusKeepAliveOptions opts)
+ {
+ if (!opts.Enabled) return;
+ try
+ {
+ client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);
+ // A TimeSpan < 1s previously truncated to 0 via the int cast,
+ // which Windows / Linux interpret as "use the default" — silently defeating the
+ // configured keep-alive timing. Round up to at least 1 second so a sub-second
+ // configuration still produces a real keep-alive cadence. Negative values are
+ // also clamped to 1 to avoid surfacing as OS errors.
+ client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime,
+ ClampToWholeSeconds(opts.Time));
+ client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval,
+ ClampToWholeSeconds(opts.Interval));
+ client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, opts.RetryCount);
+ }
+ catch { /* best-effort; older OSes may not expose the granular knobs */ }
+ }
+
+ ///
+ /// Cast a to a whole number of seconds with a
+ /// minimum of 1 — protects callers from the int-cast truncation that turned 500 ms
+ /// keep-alive timing into "use the default" on most OSes.
+ ///
+ /// The timespan to clamp to whole seconds.
+ /// The clamped duration expressed as a whole number of seconds, never less than 1.
+ internal static int ClampToWholeSeconds(TimeSpan ts)
+ {
+ var seconds = (int)Math.Ceiling(ts.TotalSeconds);
+ return seconds < 1 ? 1 : seconds;
+ }
+
+ ///
+ /// Distinguish socket-layer failures (eligible for reconnect-and-retry) from
+ /// protocol-layer failures (must propagate — retrying the same PDU won't help if the
+ /// PLC just returned exception 02 Illegal Data Address).
+ ///
+ /// The exception to classify.
+ /// when the exception is a socket-level failure.
+ internal static bool IsSocketLevelFailure(Exception ex) =>
+ ex is EndOfStreamException
+ || ex is IOException
+ || ex is SocketException
+ || ex is ObjectDisposedException;
+
+ /// Asynchronously disposes the underlying socket + stream resources.
+ /// A task that represents the asynchronous operation.
+ public async ValueTask DisposeAsync()
+ {
+ try
+ {
+ if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false);
+ }
+ catch { /* best-effort */ }
+ _client?.Dispose();
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTcpTransport.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTcpTransport.cs
index e7701296..f6f94539 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTcpTransport.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTcpTransport.cs
@@ -3,13 +3,19 @@ using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
///
-/// Concrete Modbus TCP transport. Wraps a single and serializes
-/// requests so at most one transaction is in-flight at a time — Modbus servers typically
-/// support concurrent transactions, but the single-flight model keeps the wire trace
+/// Concrete Modbus TCP transport. Wraps a single socket (owned by )
+/// and serializes requests so at most one transaction is in-flight at a time — Modbus servers
+/// typically support concurrent transactions, but the single-flight model keeps the wire trace
/// easy to diagnose and avoids interleaved-response correlation bugs.
///
///
///
+/// Owns the MBAP framing (: 7-byte header + transaction-id
+/// pairing) and composes for the connect / reconnect /
+/// keep-alive / idle-disconnect machinery — the same lifecycle the RTU-over-TCP transport
+/// reuses with different framing.
+///
+///
/// Survives mid-transaction socket drops: when a send/read fails with a socket-level
/// error (, , )
/// the transport disposes the dead socket, reconnects, and retries the PDU exactly
@@ -26,19 +32,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
///
public sealed class ModbusTcpTransport : IModbusTransport
{
- private readonly string _host;
- private readonly int _port;
+ private readonly ModbusSocketLifecycle _life;
private readonly TimeSpan _timeout;
- private readonly bool _autoReconnect;
- private readonly ModbusKeepAliveOptions _keepAlive;
- private readonly TimeSpan? _idleDisconnect;
- private readonly ModbusReconnectOptions _reconnect;
private readonly SemaphoreSlim _gate = new(1, 1);
- private TcpClient? _client;
- private NetworkStream? _stream;
private ushort _nextTx;
private bool _disposed;
- private DateTime _lastSuccessUtc = DateTime.UtcNow;
/// Initializes a new instance of the class.
/// The host address or hostname of the Modbus server.
@@ -54,84 +52,18 @@ public sealed class ModbusTcpTransport : IModbusTransport
TimeSpan? idleDisconnect = null,
ModbusReconnectOptions? reconnect = null)
{
- _host = host;
- _port = port;
_timeout = timeout;
- _autoReconnect = autoReconnect;
- _keepAlive = keepAlive ?? new ModbusKeepAliveOptions();
- _idleDisconnect = idleDisconnect;
- _reconnect = reconnect ?? new ModbusReconnectOptions();
+ _life = new ModbusSocketLifecycle(host, port, timeout, autoReconnect, keepAlive, idleDisconnect, reconnect);
}
///
- public async Task ConnectAsync(CancellationToken ct)
- {
- // Resolve the host explicitly + prefer IPv4. .NET's TcpClient default-constructor is
- // dual-stack (IPv6 first, fallback to IPv4) — but most Modbus TCP devices (PLCs and
- // simulators like pymodbus) bind 0.0.0.0 only, so the IPv6 attempt times out and we
- // burn the entire ConnectAsync budget before even trying IPv4. Resolving first +
- // dialing the IPv4 address directly sidesteps that.
- var addresses = await System.Net.Dns.GetHostAddressesAsync(_host, ct).ConfigureAwait(false);
- var ipv4 = System.Linq.Enumerable.FirstOrDefault(addresses,
- a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork);
- var target = ipv4 ?? (addresses.Length > 0 ? addresses[0] : System.Net.IPAddress.Loopback);
-
- _client = new TcpClient(target.AddressFamily);
- EnableKeepAlive(_client, _keepAlive);
-
- using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
- cts.CancelAfter(_timeout);
- await _client.ConnectAsync(target, _port, cts.Token).ConfigureAwait(false);
- _stream = _client.GetStream();
- _lastSuccessUtc = DateTime.UtcNow;
- }
-
- ///
- /// Enable SO_KEEPALIVE with aggressive probe timing. DL205/DL260 doesn't send keepalives
- /// itself; having the OS probe the socket every ~30s lets the driver notice a dead PLC
- /// or broken NAT path long before the default 2-hour Windows idle timeout fires.
- /// Non-fatal if the underlying OS rejects the option (some older Linux / container
- /// sandboxes don't expose the fine-grained timing levers — the driver still works,
- /// application-level probe still detects problems).
- ///
- private static void EnableKeepAlive(TcpClient client, ModbusKeepAliveOptions opts)
- {
- if (!opts.Enabled) return;
- try
- {
- client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);
- // A TimeSpan < 1s previously truncated to 0 via the int cast,
- // which Windows / Linux interpret as "use the default" — silently defeating the
- // configured keep-alive timing. Round up to at least 1 second so a sub-second
- // configuration still produces a real keep-alive cadence. Negative values are
- // also clamped to 1 to avoid surfacing as OS errors.
- client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime,
- ClampToWholeSeconds(opts.Time));
- client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval,
- ClampToWholeSeconds(opts.Interval));
- client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, opts.RetryCount);
- }
- catch { /* best-effort; older OSes may not expose the granular knobs */ }
- }
-
- ///
- /// Cast a to a whole number of seconds with a
- /// minimum of 1 — protects callers from the int-cast truncation that turned 500 ms
- /// keep-alive timing into "use the default" on most OSes.
- ///
- /// The timespan to clamp to whole seconds.
- /// The clamped duration expressed as a whole number of seconds, never less than 1.
- internal static int ClampToWholeSeconds(TimeSpan ts)
- {
- var seconds = (int)Math.Ceiling(ts.TotalSeconds);
- return seconds < 1 ? 1 : seconds;
- }
+ public Task ConnectAsync(CancellationToken ct) => _life.ConnectAsync(ct);
///
public async Task SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
if (_disposed) throw new ObjectDisposedException(nameof(ModbusTcpTransport));
- if (_stream is null) throw new InvalidOperationException("Transport not connected");
+ if (_life.Stream is null) throw new InvalidOperationException("Transport not connected");
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
@@ -140,27 +72,27 @@ public sealed class ModbusTcpTransport : IModbusTransport
// threshold, tear it down + reconnect before this PDU lands. Defends against silent
// NAT / firewall reaping where the socket looks alive locally but the upstream side
// dropped it minutes ago.
- if (_idleDisconnect.HasValue && DateTime.UtcNow - _lastSuccessUtc > _idleDisconnect.Value)
+ if (_life.ShouldReconnectForIdle())
{
- await TearDownAsync().ConfigureAwait(false);
- await ConnectWithBackoffAsync(ct).ConfigureAwait(false);
+ await _life.TearDownAsync().ConfigureAwait(false);
+ await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
}
try
{
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
- _lastSuccessUtc = DateTime.UtcNow;
+ _life.MarkSuccess();
return result;
}
- catch (Exception ex) when (_autoReconnect && IsSocketLevelFailure(ex))
+ catch (Exception ex) when (_life.AutoReconnect && ModbusSocketLifecycle.IsSocketLevelFailure(ex))
{
// Mid-transaction drop: tear down the dead socket, reconnect (with backoff if
// configured), resend. Single retry — if it fails again, let it propagate so
// health/status reflect reality.
- await TearDownAsync().ConfigureAwait(false);
- await ConnectWithBackoffAsync(ct).ConfigureAwait(false);
+ await _life.TearDownAsync().ConfigureAwait(false);
+ await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
- _lastSuccessUtc = DateTime.UtcNow;
+ _life.MarkSuccess();
return result;
}
}
@@ -170,43 +102,6 @@ public sealed class ModbusTcpTransport : IModbusTransport
}
}
- ///
- /// Connect attempt with the configured geometric backoff. The first attempt fires after
- /// (default zero — immediate); each
- /// subsequent attempt sleeps for the previous delay times BackoffMultiplier,
- /// capped at MaxDelay. Caller's cancellation token aborts the loop.
- ///
- private async Task ConnectWithBackoffAsync(CancellationToken ct)
- {
- var delay = _reconnect.InitialDelay;
- var attempt = 0;
- while (true)
- {
- if (delay > TimeSpan.Zero)
- await Task.Delay(delay, ct).ConfigureAwait(false);
- try
- {
- await ConnectAsync(ct).ConfigureAwait(false);
- return;
- }
- catch (Exception ex) when (IsSocketLevelFailure(ex) && _autoReconnect)
- {
- attempt++;
- // Geometric growth, capped. Use Math.Min on ticks so we don't overflow with
- // pathological multipliers / long deployments.
- var nextTicks = (long)(Math.Max(delay.Ticks, TimeSpan.FromMilliseconds(100).Ticks) * _reconnect.BackoffMultiplier);
- delay = TimeSpan.FromTicks(Math.Min(nextTicks, _reconnect.MaxDelay.Ticks));
- if (attempt >= 10)
- {
- // Bail after 10 attempts to surface persistent failure to the caller. With
- // the default backoff (1s base, 2.0x mult, 30s cap) this is roughly 4 minutes
- // of attempts; with InitialDelay=0 it's immediate up to the same cap.
- throw;
- }
- }
- }
- }
-
///
/// Executes exactly one Modbus transaction on the current socket.
///
@@ -230,7 +125,8 @@ public sealed class ModbusTcpTransport : IModbusTransport
///
private async Task SendOnceAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
- if (_stream is null) throw new InvalidOperationException("Transport not connected");
+ var stream = _life.Stream;
+ if (stream is null) throw new InvalidOperationException("Transport not connected");
var txId = ++_nextTx;
// MBAP: [TxId(2)][Proto=0(2)][Length(2)][UnitId(1)] + PDU
@@ -248,11 +144,11 @@ public sealed class ModbusTcpTransport : IModbusTransport
cts.CancelAfter(_timeout);
try
{
- await _stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false);
- await _stream.FlushAsync(cts.Token).ConfigureAwait(false);
+ await stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false);
+ await stream.FlushAsync(cts.Token).ConfigureAwait(false);
var header = new byte[7];
- await ReadExactlyAsync(_stream, header, cts.Token).ConfigureAwait(false);
+ await ReadExactlyAsync(stream, header, cts.Token).ConfigureAwait(false);
var respTxId = (ushort)((header[0] << 8) | header[1]);
if (respTxId != txId)
throw new ModbusTransportDesyncException(
@@ -264,7 +160,7 @@ public sealed class ModbusTcpTransport : IModbusTransport
ModbusTransportDesyncException.DesyncReason.TruncatedFrame,
$"Modbus response length too small: {respLen}");
var respPdu = new byte[respLen - 1];
- await ReadExactlyAsync(_stream, respPdu, cts.Token).ConfigureAwait(false);
+ await ReadExactlyAsync(stream, respPdu, cts.Token).ConfigureAwait(false);
// Exception PDU: function code has high bit set. This is a well-formed protocol-level
// error — the socket is still coherent, so it MUST propagate without teardown.
@@ -280,7 +176,7 @@ public sealed class ModbusTcpTransport : IModbusTransport
catch (ModbusTransportDesyncException)
{
// Framing violation: the socket is desynchronized — never reuse it.
- await TearDownAsync().ConfigureAwait(false);
+ await _life.TearDownAsync().ConfigureAwait(false);
throw;
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
@@ -288,33 +184,13 @@ public sealed class ModbusTcpTransport : IModbusTransport
// Per-op timeout fired (NOT the caller). The response is unknown / may still land later
// and corrupt the next read — tear the socket down and normalize as a desync so the
// reconnect retry / status mapping engages.
- await TearDownAsync().ConfigureAwait(false);
+ await _life.TearDownAsync().ConfigureAwait(false);
throw new ModbusTransportDesyncException(
ModbusTransportDesyncException.DesyncReason.Timeout,
$"Modbus per-op response timeout after {_timeout.TotalMilliseconds:0}ms");
}
}
- ///
- /// Distinguish socket-layer failures (eligible for reconnect-and-retry) from
- /// protocol-layer failures (must propagate — retrying the same PDU won't help if the
- /// PLC just returned exception 02 Illegal Data Address).
- ///
- private static bool IsSocketLevelFailure(Exception ex) =>
- ex is EndOfStreamException
- || ex is IOException
- || ex is SocketException
- || ex is ObjectDisposedException;
-
- private async Task TearDownAsync()
- {
- try { if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false); }
- catch { /* best-effort */ }
- _stream = null;
- try { _client?.Dispose(); } catch { }
- _client = null;
- }
-
private static async Task ReadExactlyAsync(Stream s, byte[] buf, CancellationToken ct)
{
var read = 0;
@@ -332,12 +208,7 @@ public sealed class ModbusTcpTransport : IModbusTransport
{
if (_disposed) return;
_disposed = true;
- try
- {
- if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false);
- }
- catch { /* best-effort */ }
- _client?.Dispose();
+ await _life.DisposeAsync().ConfigureAwait(false);
_gate.Dispose();
}
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTransportDesyncException.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTransportDesyncException.cs
index 6f75e270..8a4bd31b 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTransportDesyncException.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTransportDesyncException.cs
@@ -1,9 +1,10 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
///
-/// Raised when a Modbus TCP transaction leaves the single-flight socket in an unknown /
+/// Raised when a Modbus transaction leaves the single-flight socket in an unknown /
/// desynchronized state — a per-op response timeout, or a framing violation (transaction-id
-/// mismatch or truncated MBAP length). Unlike a Modbus exception PDU (a well-formed
+/// mismatch, truncated frame, RTU CRC mismatch, or RTU unit-address mismatch). Unlike a Modbus
+/// exception PDU (a well-formed
/// protocol-level error where the socket is still coherent and the request must simply
/// propagate), a desync means bytes may still be in flight or half-read, so the socket is
/// unusable and MUST be torn down before this is thrown.
@@ -27,8 +28,20 @@ public sealed class ModbusTransportDesyncException : IOException
/// The response MBAP transaction id did not match the request's.
TxIdMismatch,
- /// The response MBAP length field was below the mandatory minimum (truncated frame).
+ ///
+ /// The frame ended before it was complete — the MBAP length field was below the mandatory
+ /// minimum (TCP), or the stream closed mid-frame while reading a length-less RTU frame.
+ ///
TruncatedFrame,
+
+ /// The RTU frame's trailing CRC-16 did not validate over the received bytes.
+ CrcMismatch,
+
+ ///
+ /// A CRC-valid RTU frame carried a slave/unit address other than the one addressed — on an
+ /// RS-485 multi-drop bus, a reply from the wrong slave.
+ ///
+ UnitMismatch,
}
/// Gets the reason the socket was classified desynchronized.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTransportFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTransportFactory.cs
new file mode 100644
index 00000000..beaccb1e
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTransportFactory.cs
@@ -0,0 +1,43 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
+
+///
+/// Single mapping point from to the concrete
+/// its selects.
+/// Consumed by both 's default transport-factory closure and the
+/// AdminUI Test-Connect probe, so the switch lives in
+/// exactly one place.
+///
+public static class ModbusTransportFactory
+{
+ ///
+ /// Builds the transport .
+ /// selects, threading every shared connection setting (Host/Port/Timeout/AutoReconnect/
+ /// KeepAlive/IdleDisconnectTimeout/Reconnect) through to whichever concrete transport is built.
+ ///
+ /// Driver configuration options.
+ /// A when
+ /// is selected; a when is selected.
+ ///
+ /// . is not a recognized
+ /// member — fail loudly rather than silently falling back to TCP/MBAP.
+ ///
+ public static IModbusTransport Create(ModbusDriverOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ return options.Transport switch
+ {
+ ModbusTransportMode.RtuOverTcp => new ModbusRtuOverTcpTransport(
+ options.Host, options.Port, options.Timeout, options.AutoReconnect,
+ keepAlive: options.KeepAlive,
+ idleDisconnect: options.IdleDisconnectTimeout,
+ reconnect: options.Reconnect),
+ ModbusTransportMode.Tcp => new ModbusTcpTransport(
+ options.Host, options.Port, options.Timeout, options.AutoReconnect,
+ keepAlive: options.KeepAlive,
+ idleDisconnect: options.IdleDisconnectTimeout,
+ reconnect: options.Reconnect),
+ _ => throw new ArgumentOutOfRangeException(
+ nameof(options), options.Transport, "Unknown Modbus transport mode."),
+ };
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/ModbusDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/ModbusDriverForm.razor
index f0959d1a..0c34a768 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/ModbusDriverForm.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/ModbusDriverForm.razor
@@ -27,6 +27,16 @@
}
+
+
+
+ @foreach (var e in Enum.GetValues())
+ {
+
+ }
+
+
RtuOverTcp = talk raw RTU frames to a serial→Ethernet gateway; must match the gateway's mode.
+
@@ -290,6 +300,9 @@
public ModbusFamily Family { get; set; } = ModbusFamily.Generic;
public MelsecFamily MelsecSubFamily { get; set; } = MelsecFamily.Q_L_iQR;
+ // Wire transport (Tcp = Modbus/TCP MBAP; RtuOverTcp = RTU framing over a serial→Ethernet gateway)
+ public ModbusTransportMode Transport { get; set; } = ModbusTransportMode.Tcp;
+
// Transport flags
public bool AutoReconnect { get; set; } = true;
public int IdleDisconnectTimeoutSeconds { get; set; } = 0;
@@ -337,6 +350,7 @@
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
Family = o.Family,
MelsecSubFamily = o.MelsecSubFamily,
+ Transport = o.Transport,
AutoReconnect = o.AutoReconnect,
IdleDisconnectTimeoutSeconds = o.IdleDisconnectTimeout.HasValue ? (int)o.IdleDisconnectTimeout.Value.TotalSeconds : 0,
MaxRegistersPerRead = o.MaxRegistersPerRead,
@@ -387,6 +401,7 @@
MaxReadGap = (ushort)Math.Clamp(MaxReadGap, 0, 65535),
Family = Family,
MelsecSubFamily = MelsecSubFamily,
+ Transport = Transport,
WriteOnChangeOnly = WriteOnChangeOnly,
AutoReconnect = AutoReconnect,
KeepAlive = new ModbusKeepAliveOptions
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/Dockerfile b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/Dockerfile
index d2fcd0ab..d4614a9f 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/Dockerfile
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/Dockerfile
@@ -21,7 +21,10 @@ COPY profiles/ /fixtures/
# compose profile. See Docker/README.md §exception injection.
COPY exception_injector.py /fixtures/
-EXPOSE 5020
+# 5020 = the shared port for the standard/dl205/mitsubishi/s7_1500/exception_injection
+# profiles (only one binds it at a time); 5021 = the rtu_over_tcp profile, which co-runs
+# with standard. Image-metadata honesty only — compose's ports: mapping doesn't need EXPOSE.
+EXPOSE 5020 5021
# Default to the standard profile; docker-compose.yml overrides per service.
# --http_port intentionally omitted; pymodbus 3.13's web UI binds on a
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/README.md b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/README.md
index 306fbc52..c9ec0938 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/README.md
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/README.md
@@ -2,15 +2,16 @@
The Modbus driver's integration tests talk to a
[`pymodbus`](https://pymodbus.readthedocs.io/) simulator running as a
-pinned Docker container. One image, per-profile service in compose, same
-port binding (`5020`) regardless of which profile is live. Docker is the
-only supported launch path — a fresh clone needs Docker Desktop and
-nothing else.
+pinned Docker container. One image, per-profile service in compose. Most
+profiles bind `:5020`, so only one of *those* runs at a time; the
+`rtu_over_tcp` profile binds `:5021` and is designed to co-run alongside
+`standard`. Docker is the only supported launch path — a fresh clone needs
+Docker Desktop and nothing else.
| File | Purpose |
|---|---|
| [`Dockerfile`](Dockerfile) | `python:3.12-slim-bookworm` + `pymodbus[simulator]==3.13.0` + every profile JSON + `exception_injector.py` |
-| [`docker-compose.yml`](docker-compose.yml) | One service per profile (`standard` / `dl205` / `mitsubishi` / `s7_1500` / `exception_injection`); all bind `:5020` so only one runs at a time |
+| [`docker-compose.yml`](docker-compose.yml) | One service per profile (`standard` / `dl205` / `mitsubishi` / `s7_1500` / `exception_injection` bind `:5020`, so only one of those runs at a time; `rtu_over_tcp` binds `:5021` and can run alongside `standard`) |
| [`profiles/*.json`](profiles/) | Same seed-register definitions the native launcher uses — canonical source |
| [`exception_injector.py`](exception_injector.py) | Pure-stdlib Modbus/TCP server that emits arbitrary exception codes per rule — used by the `exception_injection` profile |
@@ -43,10 +44,11 @@ docker compose -f tests\...\Docker\docker-compose.yml --profile dl205 up -d
docker compose -f tests\...\Docker\docker-compose.yml --profile dl205 down
```
-Only one profile binds `:5020` at a time; switch by stopping the current
-service + starting another. The integration tests discriminate by a
-separate `MODBUS_SIM_PROFILE` env var so they skip correctly when the
-wrong profile is live.
+Only one of the `:5020` profiles binds at a time; switch by stopping the
+current service + starting another. (`rtu_over_tcp` is the exception — it
+binds `:5021` and co-runs with `standard`.) The integration tests
+discriminate by a separate `MODBUS_SIM_PROFILE` env var so they skip
+correctly when the wrong profile is live.
> **⚠️ Profile-cycling gotcha (bit us in the 2026-07 sweep — see
> `archreview/plans/INTEGRATION-SWEEP-STATUS.md`).** Each profile is a
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/docker-compose.yml b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/docker-compose.yml
index d083ddc9..9ce9bb17 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/docker-compose.yml
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/docker-compose.yml
@@ -78,6 +78,30 @@ services:
"--json_file", "/fixtures/s7_1500.json"
]
+ # RTU-over-TCP profile. Same pymodbus simulator, but the server is framed
+ # RTU (framer=rtu) instead of socket/MBAP — this is what a serial→Ethernet
+ # gateway presents. Binds :5021 (NOT the shared :5020) so it co-runs with
+ # the `standard` profile: two families, two ports, no conflict. Serves
+ # rtu_over_tcp.json (byte-for-byte standard.json + framer/port swap).
+ rtu_over_tcp:
+ profiles: ["rtu_over_tcp"]
+ image: otopcua-pymodbus:3.13.0
+ build:
+ context: .
+ dockerfile: Dockerfile
+ container_name: otopcua-pymodbus-rtu_over_tcp
+ labels:
+ project: lmxopcua
+ restart: "no"
+ ports:
+ - "5021:5021"
+ command: [
+ "pymodbus.simulator",
+ "--modbus_server", "srv",
+ "--modbus_device", "dev",
+ "--json_file", "/fixtures/rtu_over_tcp.json"
+ ]
+
# Exception-injection profile. Runs the standalone pure-stdlib Modbus/TCP
# server shipped as exception_injector.py instead of the pymodbus
# simulator — pymodbus naturally emits only exception codes 02 + 03, and
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/profiles/rtu_over_tcp.json b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/profiles/rtu_over_tcp.json
new file mode 100644
index 00000000..443e687d
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/profiles/rtu_over_tcp.json
@@ -0,0 +1,97 @@
+{
+ "_comment": "rtu_over_tcp.json — RTU-over-TCP Modbus server for the integration suite (RTU framing tunnelled over a socket, as a serial→Ethernet gateway presents it). Byte-for-byte standard.json EXCEPT the server block: framer=\"rtu\" (not \"socket\") + port 5021 (not 5020), so it co-runs with the standard profile on :5020. pymodbus 3.13's simulator honors framer=rtu on a TCP server — confirmed on the wire (controller spike, 2026-07-24): raw RTU FC03 read of HR[5] returned `01 03 02 00 05 78 47`, a pure RTU frame with no MBAP header. Layout is identical to standard.json: HR[0..31]=address-as-value, HR[100]=auto-increment, HR[200..209]=scratch, coils 1024..1055=alternating, coils 1100..1109=scratch. NOTE: pymodbus rejects unknown keys at device-list / setup level; explanatory comments live in the README + git history.",
+
+ "server_list": {
+ "srv": {
+ "comm": "tcp",
+ "host": "0.0.0.0",
+ "port": 5021,
+ "framer": "rtu",
+ "device_id": 1
+ }
+ },
+
+ "device_list": {
+ "dev": {
+ "setup": {
+ "co size": 2048,
+ "di size": 2048,
+ "hr size": 2048,
+ "ir size": 2048,
+ "shared blocks": true,
+ "type exception": false,
+ "defaults": {
+ "value": {"bits": 0, "uint16": 0, "uint32": 0, "float32": 0.0, "string": " "},
+ "action": {"bits": null, "uint16": null, "uint32": null, "float32": null, "string": null}
+ }
+ },
+ "invalid": [],
+ "write": [
+ [0, 31],
+ [100, 100],
+ [200, 209],
+ [1024, 1055],
+ [1100, 1109]
+ ],
+
+ "uint16": [
+ {"addr": 0, "value": 0}, {"addr": 1, "value": 1},
+ {"addr": 2, "value": 2}, {"addr": 3, "value": 3},
+ {"addr": 4, "value": 4}, {"addr": 5, "value": 5},
+ {"addr": 6, "value": 6}, {"addr": 7, "value": 7},
+ {"addr": 8, "value": 8}, {"addr": 9, "value": 9},
+ {"addr": 10, "value": 10}, {"addr": 11, "value": 11},
+ {"addr": 12, "value": 12}, {"addr": 13, "value": 13},
+ {"addr": 14, "value": 14}, {"addr": 15, "value": 15},
+ {"addr": 16, "value": 16}, {"addr": 17, "value": 17},
+ {"addr": 18, "value": 18}, {"addr": 19, "value": 19},
+ {"addr": 20, "value": 20}, {"addr": 21, "value": 21},
+ {"addr": 22, "value": 22}, {"addr": 23, "value": 23},
+ {"addr": 24, "value": 24}, {"addr": 25, "value": 25},
+ {"addr": 26, "value": 26}, {"addr": 27, "value": 27},
+ {"addr": 28, "value": 28}, {"addr": 29, "value": 29},
+ {"addr": 30, "value": 30}, {"addr": 31, "value": 31},
+
+ {"addr": 100, "value": 0,
+ "action": "increment",
+ "parameters": {"minval": 0, "maxval": 65535}},
+
+ {"addr": 200, "value": 0}, {"addr": 201, "value": 0},
+ {"addr": 202, "value": 0}, {"addr": 203, "value": 0},
+ {"addr": 204, "value": 0}, {"addr": 205, "value": 0},
+ {"addr": 206, "value": 0}, {"addr": 207, "value": 0},
+ {"addr": 208, "value": 0}, {"addr": 209, "value": 0}
+ ],
+
+ "bits": [
+ {"addr": 1024, "value": 1}, {"addr": 1025, "value": 0},
+ {"addr": 1026, "value": 1}, {"addr": 1027, "value": 0},
+ {"addr": 1028, "value": 1}, {"addr": 1029, "value": 0},
+ {"addr": 1030, "value": 1}, {"addr": 1031, "value": 0},
+ {"addr": 1032, "value": 1}, {"addr": 1033, "value": 0},
+ {"addr": 1034, "value": 1}, {"addr": 1035, "value": 0},
+ {"addr": 1036, "value": 1}, {"addr": 1037, "value": 0},
+ {"addr": 1038, "value": 1}, {"addr": 1039, "value": 0},
+ {"addr": 1040, "value": 1}, {"addr": 1041, "value": 0},
+ {"addr": 1042, "value": 1}, {"addr": 1043, "value": 0},
+ {"addr": 1044, "value": 1}, {"addr": 1045, "value": 0},
+ {"addr": 1046, "value": 1}, {"addr": 1047, "value": 0},
+ {"addr": 1048, "value": 1}, {"addr": 1049, "value": 0},
+ {"addr": 1050, "value": 1}, {"addr": 1051, "value": 0},
+ {"addr": 1052, "value": 1}, {"addr": 1053, "value": 0},
+ {"addr": 1054, "value": 1}, {"addr": 1055, "value": 0},
+
+ {"addr": 1100, "value": 0}, {"addr": 1101, "value": 0},
+ {"addr": 1102, "value": 0}, {"addr": 1103, "value": 0},
+ {"addr": 1104, "value": 0}, {"addr": 1105, "value": 0},
+ {"addr": 1106, "value": 0}, {"addr": 1107, "value": 0},
+ {"addr": 1108, "value": 0}, {"addr": 1109, "value": 0}
+ ],
+
+ "uint32": [],
+ "float32": [],
+ "string": [],
+ "repeat": []
+ }
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusRtuOverTcpFixture.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusRtuOverTcpFixture.cs
new file mode 100644
index 00000000..a00748d4
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusRtuOverTcpFixture.cs
@@ -0,0 +1,82 @@
+using System.Net.Sockets;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
+
+///
+/// Reachability probe for the RTU-over-TCP Modbus simulator (pymodbus in Docker with
+/// framer=rtu, see Docker/docker-compose.yml profile rtu_over_tcp) or a
+/// real serial→Ethernet Modbus gateway. Mirrors but reads
+/// MODBUS_RTU_SIM_ENDPOINT (default 10.100.0.35:5021 — the shared Docker host, a
+/// distinct port from the standard profile's :5020 so the two fixtures co-run). TCP-connects
+/// once at fixture construction; each test checks and calls
+/// Assert.Skip when the endpoint was unreachable, so a dev box without a running simulator
+/// still passes `dotnet test` cleanly.
+///
+///
+/// Same one-shot-probe discipline as : the probe socket is
+/// not held for the life of the fixture (tests open their own
+/// against the same endpoint), and the fixture is a collection fixture so the probe runs once per
+/// session rather than per test.
+///
+public sealed class ModbusRtuOverTcpFixture : IAsyncDisposable
+{
+ // :5021 (not the standard profile's :5020) so the rtu_over_tcp container can co-run with the
+ // standard container on the shared Docker host. 10.100.0.35 = the shared Docker host (see
+ // CLAUDE.md "Docker Workflow"). Override with MODBUS_RTU_SIM_ENDPOINT to point at a real
+ // serial→Ethernet Modbus gateway, or a locally-running container.
+ private const string DefaultEndpoint = "10.100.0.35:5021";
+ private const string EndpointEnvVar = "MODBUS_RTU_SIM_ENDPOINT";
+
+ /// Gets the host address of the RTU-over-TCP Modbus simulator.
+ public string Host { get; }
+
+ /// Gets the port of the RTU-over-TCP Modbus simulator.
+ public int Port { get; }
+
+ /// Gets the skip reason if the simulator is unreachable; otherwise null.
+ public string? SkipReason { get; }
+
+ /// Initializes a new instance of the class.
+ public ModbusRtuOverTcpFixture()
+ {
+ var raw = Environment.GetEnvironmentVariable(EndpointEnvVar) ?? DefaultEndpoint;
+ var parts = raw.Split(':', 2);
+ Host = parts[0];
+ Port = parts.Length == 2 && int.TryParse(parts[1], out var p) ? p : 502;
+
+ try
+ {
+ // Force IPv4 on the probe — pymodbus binds 0.0.0.0 (IPv4 only) while .NET default-resolves
+ // "localhost" → IPv6 ::1 first and only then tries IPv4, surfacing as a 2s timeout under
+ // .NET 10. Resolving + dialing explicit IPv4 sidesteps the dual-stack ordering. (Same
+ // rationale as ModbusSimulatorFixture.)
+ using var client = new TcpClient(System.Net.Sockets.AddressFamily.InterNetwork);
+ var task = client.ConnectAsync(
+ System.Net.Dns.GetHostAddresses(Host)
+ .FirstOrDefault(a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
+ ?? System.Net.IPAddress.Loopback,
+ Port);
+ if (!task.Wait(TimeSpan.FromSeconds(2)) || !client.Connected)
+ {
+ SkipReason = $"RTU-over-TCP Modbus simulator at {Host}:{Port} did not accept a TCP connection within 2s. " +
+ $"Start the pymodbus Docker container (docker compose -f Docker/docker-compose.yml --profile rtu_over_tcp up -d --build) " +
+ $"or override {EndpointEnvVar}, then re-run.";
+ }
+ }
+ catch (Exception ex)
+ {
+ SkipReason = $"RTU-over-TCP Modbus simulator at {Host}:{Port} unreachable: {ex.GetType().Name}: {ex.Message}. " +
+ $"Start the pymodbus Docker container (docker compose -f Docker/docker-compose.yml --profile rtu_over_tcp up -d --build) " +
+ $"or override {EndpointEnvVar}, then re-run.";
+ }
+ }
+
+ ///
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+}
+
+[Xunit.CollectionDefinition(Name)]
+public sealed class ModbusRtuOverTcpCollection : Xunit.ICollectionFixture
+{
+ public const string Name = "ModbusRtuOverTcp";
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusRtuOverTcpTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusRtuOverTcpTests.cs
new file mode 100644
index 00000000..08448777
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusRtuOverTcpTests.cs
@@ -0,0 +1,94 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
+
+///
+/// End-to-end round-trip against the rtu_over_tcp.json pymodbus profile (or a real
+/// serial→Ethernet Modbus gateway when MODBUS_RTU_SIM_ENDPOINT points at one), driving the
+/// full + real stack with
+/// . Proves the driver reads a seeded holding register
+/// and writes+reads-back a scratch register when the wire carries RTU framing
+/// ([addr][PDU][CRC-16], no MBAP header) rather than Modbus/TCP.
+///
+///
+/// pymodbus 3.13's simulator honors framer=rtu on a TCP server — confirmed on the wire
+/// (controller spike, 2026-07-24): a raw RTU FC03 read of HR[5] returned
+/// 01 03 02 00 05 78 47, a pure RTU frame with no MBAP header (data 0x0005 = 5). So the
+/// fixture is the plain pymodbus simulator with framer/port swapped — no stdlib fallback server.
+///
+[Collection(ModbusRtuOverTcpCollection.Name)]
+[Trait("Category", "Integration")]
+[Trait("Device", "RtuOverTcp")]
+public sealed class ModbusRtuOverTcpTests(ModbusRtuOverTcpFixture sim)
+{
+ /// Reads a seeded holding register (HR[5]=5) over RTU-over-TCP framing.
+ [Fact]
+ public async Task Reads_a_seeded_holding_register_over_rtu_framing()
+ {
+ if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
+ var opts = new ModbusDriverOptions
+ {
+ Host = sim.Host,
+ Port = sim.Port,
+ UnitId = 1,
+ Transport = ModbusTransportMode.RtuOverTcp,
+ Timeout = TimeSpan.FromSeconds(2),
+ Probe = new ModbusProbeOptions { Enabled = false },
+ RawTags = ModbusRawTags.Entries([
+ new ModbusTagDefinition(
+ Name: "HR5",
+ Region: ModbusRegion.HoldingRegisters,
+ Address: 5,
+ DataType: ModbusDataType.UInt16,
+ Writable: false),
+ ]),
+ };
+ await using var driver = new ModbusDriver(opts, driverInstanceId: "modbus-rtu-int");
+ await driver.InitializeAsync(driverConfigJson: "{}", TestContext.Current.CancellationToken);
+
+ var results = await driver.ReadAsync(["HR5"], TestContext.Current.CancellationToken);
+
+ results.Count.ShouldBe(1);
+ results[0].StatusCode.ShouldBe(0u); // Good
+ results[0].Value.ShouldBe((ushort)5); // HR[5] seeded = address-as-value
+ }
+
+ /// Writes then reads back a scratch holding register (HR[200]) over RTU-over-TCP framing.
+ [Fact]
+ public async Task Writes_and_reads_back_a_holding_register_over_rtu_framing()
+ {
+ if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
+ var opts = new ModbusDriverOptions
+ {
+ Host = sim.Host,
+ Port = sim.Port,
+ UnitId = 1,
+ Transport = ModbusTransportMode.RtuOverTcp,
+ Timeout = TimeSpan.FromSeconds(2),
+ Probe = new ModbusProbeOptions { Enabled = false },
+ RawTags = ModbusRawTags.Entries([
+ new ModbusTagDefinition(
+ Name: "HR200",
+ Region: ModbusRegion.HoldingRegisters,
+ Address: 200,
+ DataType: ModbusDataType.UInt16,
+ Writable: true),
+ ]),
+ };
+ await using var driver = new ModbusDriver(opts, driverInstanceId: "modbus-rtu-int-w");
+ await driver.InitializeAsync(driverConfigJson: "{}", TestContext.Current.CancellationToken);
+
+ var writes = await driver.WriteAsync(
+ [new WriteRequest("HR200", (ushort)4242)],
+ TestContext.Current.CancellationToken);
+ writes.Count.ShouldBe(1);
+ writes[0].StatusCode.ShouldBe(0u);
+
+ var back = await driver.ReadAsync(["HR200"], TestContext.Current.CancellationToken);
+ back.Count.ShouldBe(1);
+ back[0].StatusCode.ShouldBe(0u);
+ back[0].Value.ShouldBe((ushort)4242);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusCrcTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusCrcTests.cs
new file mode 100644
index 00000000..cd4c2a56
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusCrcTests.cs
@@ -0,0 +1,27 @@
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
+
+public sealed class ModbusCrcTests
+{
+ // Known CRC-16/MODBUS vectors (init 0xFFFF, poly 0xA001). CRC returned as ushort;
+ // on the wire it is appended low-byte-first.
+ [Theory]
+ // FC03 read HR: unit 1, addr 0, qty 1 -> CRC 0x0A84 (bytes 84 0A on the wire)
+ [InlineData(new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01 }, (ushort)0x0A84)]
+ // "123456789" canonical check value for CRC-16/MODBUS = 0x4B37
+ [InlineData(new byte[] { 0x31,0x32,0x33,0x34,0x35,0x36,0x37,0x38,0x39 }, (ushort)0x4B37)]
+ public void Compute_matches_known_vectors(byte[] frame, ushort expected)
+ => ModbusCrc.Compute(frame).ShouldBe(expected);
+
+ [Fact]
+ public void AppendLowByteFirst_writes_lo_then_hi()
+ {
+ var body = new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01 };
+ var withCrc = ModbusCrc.Append(body);
+ withCrc.Length.ShouldBe(body.Length + 2);
+ withCrc[^2].ShouldBe((byte)0x84); // CRC low byte
+ withCrc[^1].ShouldBe((byte)0x0A); // CRC high byte
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusEdgeCaseValidationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusEdgeCaseValidationTests.cs
index a9879e8d..243c343d 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusEdgeCaseValidationTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusEdgeCaseValidationTests.cs
@@ -14,7 +14,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// (2) Sub-second values on ModbusKeepAliveOptions.Time /
/// Interval — the int-cast in EnableKeepAlive truncated 500 ms to
/// 0, which most OSes interpret as "use the default", silently defeating the
-/// configured timing. ModbusTcpTransport.ClampToWholeSeconds rounds up to a minimum
+/// configured timing. ModbusSocketLifecycle.ClampToWholeSeconds rounds up to a minimum
/// of 1 second.
///
[Trait("Category", "Unit")]
@@ -70,7 +70,7 @@ public sealed class ModbusEdgeCaseValidationTests
[InlineData(60_000, 60)]
public void ClampToWholeSeconds_rounds_up_to_at_least_one_second(int ms, int expected)
{
- ModbusTcpTransport.ClampToWholeSeconds(TimeSpan.FromMilliseconds(ms)).ShouldBe(expected);
+ ModbusSocketLifecycle.ClampToWholeSeconds(TimeSpan.FromMilliseconds(ms)).ShouldBe(expected);
}
/// Verifies that negative time spans are treated as one second.
@@ -80,6 +80,6 @@ public sealed class ModbusEdgeCaseValidationTests
// Defensive — operators occasionally configure a negative TimeSpan thinking it disables
// the feature. The OS would reject the negative int — clamping to 1 keeps the socket
// valid until the operator fixes the config.
- ModbusTcpTransport.ClampToWholeSeconds(TimeSpan.FromSeconds(-5)).ShouldBe(1);
+ ModbusSocketLifecycle.ClampToWholeSeconds(TimeSpan.FromSeconds(-5)).ShouldBe(1);
}
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusRtuFramingTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusRtuFramingTests.cs
new file mode 100644
index 00000000..3a26fa28
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusRtuFramingTests.cs
@@ -0,0 +1,134 @@
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
+
+public sealed class ModbusRtuFramingTests
+{
+ private static MemoryStream Canned(params byte[] frame) => new(frame);
+
+ [Fact]
+ public void BuildAdu_prefixes_unit_and_appends_crc()
+ {
+ var adu = ModbusRtuFraming.BuildAdu(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 });
+ adu.ShouldBe(new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01, 0x84, 0x0A });
+ }
+
+ [Fact]
+ public async Task ReadResponse_FC03_returns_bare_pdu()
+ {
+ // addr=01 fc=03 byteCount=02 data=00 0A + CRC(lo,hi)
+ var body = new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A };
+ var frame = ModbusCrc.Append(body);
+ await using var s = Canned(frame);
+ var pdu = await ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken);
+ pdu.ShouldBe(new byte[] { 0x03, 0x02, 0x00, 0x0A }); // fc + byteCount + data, no addr/CRC
+ }
+
+ [Fact]
+ public async Task ReadResponse_exception_frame_throws_ModbusException()
+ {
+ // addr=01 fc=0x83 exc=0x02 + CRC
+ var frame = ModbusCrc.Append(new byte[] { 0x01, 0x83, 0x02 });
+ await using var s = Canned(frame);
+ var ex = await Should.ThrowAsync(
+ ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken));
+ ex.FunctionCode.ShouldBe((byte)0x03);
+ ex.ExceptionCode.ShouldBe((byte)0x02);
+ }
+
+ [Fact]
+ public async Task ReadResponse_bad_crc_throws_desync()
+ {
+ var frame = ModbusCrc.Append(new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A });
+ frame[^1] ^= 0xFF; // corrupt CRC high byte
+ await using var s = Canned(frame);
+ var ex = await Should.ThrowAsync(
+ ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken));
+ ex.Reason.ShouldBe(ModbusTransportDesyncException.DesyncReason.CrcMismatch);
+ }
+
+ [Fact]
+ public async Task ReadResponse_FC06_write_echo_returns_four_byte_pdu()
+ {
+ // addr=01 fc=06 addr=00 07 value=00 2A + CRC -> PDU = 06 00 07 00 2A
+ var frame = ModbusCrc.Append(new byte[] { 0x01, 0x06, 0x00, 0x07, 0x00, 0x2A });
+ await using var s = Canned(frame);
+ var pdu = await ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken);
+ pdu.ShouldBe(new byte[] { 0x06, 0x00, 0x07, 0x00, 0x2A });
+ }
+
+ [Fact]
+ public async Task ReadResponse_wrong_unit_address_throws_desync()
+ {
+ // CRC-valid FC03 frame addressed to unit 0x02, but we asked for 0x01 -> unit-mismatch desync.
+ var frame = ModbusCrc.Append(new byte[] { 0x02, 0x03, 0x02, 0x00, 0x0A });
+ await using var s = Canned(frame);
+ var ex = await Should.ThrowAsync(
+ ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken));
+ ex.Reason.ShouldBe(ModbusTransportDesyncException.DesyncReason.UnitMismatch);
+ }
+
+ [Fact]
+ public async Task ReadResponse_FC03_deframes_correctly_when_delivered_in_dribbles()
+ {
+ // Same valid FC03 frame, but the stream hands back only 1-2 bytes per ReadAsync — proves
+ // the ReadExactlyAsync accumulation loop reassembles a length-less frame across many reads.
+ var frame = ModbusCrc.Append(new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A });
+ await using var s = new DribbleStream(frame, chunk: 2);
+ var pdu = await ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken);
+ pdu.ShouldBe(new byte[] { 0x03, 0x02, 0x00, 0x0A });
+ }
+
+ [Fact]
+ public async Task ReadResponse_stream_ends_early_throws_desync()
+ {
+ // Only the first 4 bytes of a 7-byte FC03 frame are available, then EOF (ReadAsync returns 0)
+ // -> the truncation path in ReadExactlyAsync must surface a desync, not hang or mis-parse.
+ var frame = ModbusCrc.Append(new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A });
+ var truncated = frame[..4];
+ await using var s = new DribbleStream(truncated, chunk: 2);
+ var ex = await Should.ThrowAsync(
+ ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken));
+ ex.Reason.ShouldBe(ModbusTransportDesyncException.DesyncReason.TruncatedFrame);
+ }
+
+ ///
+ /// A read-only test double that dribbles its backing bytes out at most chunk at a time
+ /// per call, then returns 0 (EOF)
+ /// once exhausted. Exercises the partial-read accumulation loop and the truncation → desync
+ /// path that a single-shot never reaches.
+ ///
+ private sealed class DribbleStream(byte[] data, int chunk) : Stream
+ {
+ private int _pos;
+
+ public override int Read(byte[] buffer, int offset, int count)
+ {
+ var n = Math.Min(Math.Min(chunk, count), data.Length - _pos);
+ if (n <= 0) return 0;
+ Array.Copy(data, _pos, buffer, offset, n);
+ _pos += n;
+ return n;
+ }
+
+ public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default)
+ {
+ var n = Math.Min(Math.Min(chunk, buffer.Length), data.Length - _pos);
+ if (n <= 0) return ValueTask.FromResult(0);
+ data.AsSpan(_pos, n).CopyTo(buffer.Span);
+ _pos += n;
+ return ValueTask.FromResult(n);
+ }
+
+ public override bool CanRead => true;
+ public override bool CanSeek => false;
+ public override bool CanWrite => false;
+ public override long Length => data.Length;
+ public override long Position { get => _pos; set => throw new NotSupportedException(); }
+ public override void Flush() { }
+ public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
+ public override void SetLength(long value) => throw new NotSupportedException();
+ public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusRtuOverTcpTransportTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusRtuOverTcpTransportTests.cs
new file mode 100644
index 00000000..2ba1a622
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusRtuOverTcpTransportTests.cs
@@ -0,0 +1,110 @@
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
+
+///
+/// Exercises 's send/receive orchestration, single-flight,
+/// and per-op deadline against an in-memory duplex fake injected through the ForTest seam —
+/// no real socket. The fake records the request ADU the transport writes and replays a canned RTU
+/// response on read, or blocks forever (honouring cancellation) when stall is set.
+///
+[Trait("Category", "Unit")]
+public sealed class ModbusRtuOverTcpTransportTests
+{
+ [Fact]
+ public async Task SendAsync_writes_rtu_adu_and_returns_deframed_pdu()
+ {
+ // Response the fake gateway will emit: FC03, 1 reg = 0x000A.
+ var response = ModbusCrc.Append(new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A });
+ var fake = new CapturingDuplexStream(response);
+
+ await using var transport = ModbusRtuOverTcpTransport.ForTest(fake, TimeSpan.FromSeconds(2));
+ var pdu = await transport.SendAsync(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 },
+ TestContext.Current.CancellationToken);
+
+ pdu.ShouldBe(new byte[] { 0x03, 0x02, 0x00, 0x0A });
+ // The request went out as an RTU ADU: unit + pdu + CRC, NO MBAP header.
+ fake.Written.ShouldBe(new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01, 0x84, 0x0A });
+ }
+
+ [Fact]
+ public async Task SendAsync_exception_pdu_surfaces_ModbusException()
+ {
+ var response = ModbusCrc.Append(new byte[] { 0x01, 0x83, 0x02 });
+ var fake = new CapturingDuplexStream(response);
+ await using var transport = ModbusRtuOverTcpTransport.ForTest(fake, TimeSpan.FromSeconds(2));
+ await Should.ThrowAsync(
+ transport.SendAsync(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 },
+ TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task SendAsync_stalled_gateway_hits_per_op_deadline()
+ {
+ var fake = new CapturingDuplexStream(respondBytes: Array.Empty(), stall: true);
+ await using var transport = ModbusRtuOverTcpTransport.ForTest(fake, TimeSpan.FromMilliseconds(200));
+ await Should.ThrowAsync(
+ transport.SendAsync(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 },
+ TestContext.Current.CancellationToken));
+ }
+
+ ///
+ /// In-memory duplex test double: records everything written and replays
+ /// respondBytes on read. When stall is set the read blocks until its cancellation
+ /// token fires (simulating a frozen gateway) so the transport's per-op deadline is exercised.
+ ///
+ private sealed class CapturingDuplexStream : Stream
+ {
+ private readonly byte[] _response;
+ private readonly bool _stall;
+ private readonly MemoryStream _written = new();
+ private int _readPos;
+
+ public CapturingDuplexStream(byte[] respondBytes, bool stall = false)
+ {
+ _response = respondBytes;
+ _stall = stall;
+ }
+
+ /// Gets the bytes the transport wrote to this stream (the request ADU).
+ public byte[] Written => _written.ToArray();
+
+ public override bool CanRead => true;
+ public override bool CanSeek => false;
+ public override bool CanWrite => true;
+ public override long Length => throw new NotSupportedException();
+ public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
+
+ public override async ValueTask ReadAsync(Memory buffer, CancellationToken ct = default)
+ {
+ if (_stall)
+ {
+ // Frozen gateway: never answer. Block until the per-op deadline (or caller) cancels.
+ var tcs = new TaskCompletionSource();
+ await using (ct.Register(() => tcs.TrySetCanceled(ct)))
+ await tcs.Task.ConfigureAwait(false);
+ return 0; // unreachable — the await above always throws when cancelled
+ }
+
+ var remaining = _response.Length - _readPos;
+ if (remaining <= 0) return 0;
+ var n = Math.Min(remaining, buffer.Length);
+ _response.AsSpan(_readPos, n).CopyTo(buffer.Span);
+ _readPos += n;
+ return n;
+ }
+
+ public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken ct = default)
+ {
+ await _written.WriteAsync(buffer, ct).ConfigureAwait(false);
+ }
+
+ public override Task FlushAsync(CancellationToken ct) => Task.CompletedTask;
+ public override void Flush() { }
+ public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
+ public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
+ public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
+ public override void SetLength(long value) => throw new NotSupportedException();
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusSocketLifecycleTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusSocketLifecycleTests.cs
new file mode 100644
index 00000000..210c9657
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusSocketLifecycleTests.cs
@@ -0,0 +1,33 @@
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
+
+///
+/// Pins the socket-lifecycle surface extracted from into the
+/// reusable (Task 3). The clamp helper moved with it, and a
+/// connect to a dead port must still surface the underlying socket failure.
+///
+[Trait("Category", "Unit")]
+public sealed class ModbusSocketLifecycleTests
+{
+ /// Verifies the whole-seconds clamp rounds sub-second/negative durations up to a minimum of one.
+ /// The input duration in seconds.
+ /// The expected clamped value in whole seconds.
+ [Theory]
+ [InlineData(0.4, 1)] // sub-second rounds up to 1 (the int-cast truncation guard)
+ [InlineData(2.0, 2)]
+ [InlineData(-5.0, 1)] // negative clamps to 1
+ public void ClampToWholeSeconds_rounds_up_min_one(double seconds, int expected)
+ => ModbusSocketLifecycle.ClampToWholeSeconds(TimeSpan.FromSeconds(seconds)).ShouldBe(expected);
+
+ /// Verifies a connect to a dead port surfaces the underlying socket failure.
+ [Fact]
+ public async Task Connect_to_dead_port_surfaces_socket_failure()
+ {
+ var life = new ModbusSocketLifecycle("127.0.0.1", 1, TimeSpan.FromMilliseconds(300),
+ autoReconnect: false);
+ await Should.ThrowAsync(
+ life.ConnectAsync(TestContext.Current.CancellationToken));
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusTransportConfigRoundTripTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusTransportConfigRoundTripTests.cs
new file mode 100644
index 00000000..343eb379
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusTransportConfigRoundTripTests.cs
@@ -0,0 +1,47 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
+
+public sealed class ModbusTransportConfigRoundTripTests
+{
+ // Mirrors the AdminUI ModbusDriverForm serializer: camelCase + string enums.
+ private static readonly JsonSerializerOptions _adminOpts = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ Converters = { new JsonStringEnumConverter() },
+ };
+
+ [Fact]
+ public void Transport_serializes_as_a_string_not_a_number()
+ {
+ var opts = new ModbusDriverOptions { Host = "10.20.0.50", Port = 4001, Transport = ModbusTransportMode.RtuOverTcp };
+ var json = JsonSerializer.Serialize(opts, _adminOpts);
+ json.ShouldContain("\"transport\":\"RtuOverTcp\"");
+ json.ShouldNotContain("\"transport\":1", Case.Sensitive);
+ }
+
+ [Fact]
+ public void Factory_binds_RtuOverTcp_from_string_config()
+ {
+ const string json = """{ "host": "10.20.0.50", "port": 4001, "transport": "RtuOverTcp" }""";
+ using var driver = ModbusDriverFactoryExtensions.CreateInstance("mb-rtu", json);
+ var opts = (ModbusDriverOptions)typeof(ModbusDriver)
+ .GetField("_options", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
+ .GetValue(driver)!;
+ opts.Transport.ShouldBe(ModbusTransportMode.RtuOverTcp);
+ }
+
+ [Fact]
+ public void Factory_defaults_Transport_to_Tcp_when_omitted()
+ {
+ const string json = """{ "host": "10.0.0.10" }""";
+ using var driver = ModbusDriverFactoryExtensions.CreateInstance("mb-tcp", json);
+ var opts = (ModbusDriverOptions)typeof(ModbusDriver)
+ .GetField("_options", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
+ .GetValue(driver)!;
+ opts.Transport.ShouldBe(ModbusTransportMode.Tcp);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusTransportFactoryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusTransportFactoryTests.cs
new file mode 100644
index 00000000..13b8560a
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusTransportFactoryTests.cs
@@ -0,0 +1,27 @@
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
+
+public sealed class ModbusTransportFactoryTests
+{
+ [Fact]
+ public void Tcp_mode_builds_tcp_transport()
+ => ModbusTransportFactory.Create(new ModbusDriverOptions { Transport = ModbusTransportMode.Tcp })
+ .ShouldBeOfType();
+
+ [Fact]
+ public void RtuOverTcp_mode_builds_rtu_transport()
+ => ModbusTransportFactory.Create(new ModbusDriverOptions { Transport = ModbusTransportMode.RtuOverTcp })
+ .ShouldBeOfType();
+
+ [Fact]
+ public void Default_options_build_tcp_transport()
+ => ModbusTransportFactory.Create(new ModbusDriverOptions())
+ .ShouldBeOfType();
+
+ [Fact]
+ public void Unknown_transport_mode_throws()
+ => Should.Throw(
+ () => ModbusTransportFactory.Create(new ModbusDriverOptions { Transport = (ModbusTransportMode)999 }));
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusTransportModeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusTransportModeTests.cs
new file mode 100644
index 00000000..dc3b2d0e
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusTransportModeTests.cs
@@ -0,0 +1,15 @@
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
+
+public sealed class ModbusTransportModeTests
+{
+ [Fact]
+ public void Tcp_is_the_default_zero_member()
+ => ((ModbusTransportMode)0).ShouldBe(ModbusTransportMode.Tcp);
+
+ [Fact]
+ public void Exactly_two_members_ship_in_v1()
+ => Enum.GetNames().ShouldBe(["Tcp", "RtuOverTcp"]);
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusTransportWiringTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusTransportWiringTests.cs
new file mode 100644
index 00000000..edbff6bd
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusTransportWiringTests.cs
@@ -0,0 +1,33 @@
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
+
+///
+/// Confirms the driver's default transport-factory closure — and, by extension, the
+/// probe's transport construction — routes through
+/// instead of hardcoding , so an RtuOverTcp-authored
+/// config actually gets RTU framing rather than silently running as TCP/MBAP.
+///
+[Trait("Category", "Unit")]
+public sealed class ModbusTransportWiringTests
+{
+ private static IModbusTransport BuildDefaultTransport(ModbusDriverOptions opts)
+ {
+ using var driver = new ModbusDriver(opts, "wire-test");
+ var factory = (Func)typeof(ModbusDriver)
+ .GetField("_transportFactory", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
+ .GetValue(driver)!;
+ return factory(opts);
+ }
+
+ [Fact]
+ public void Default_closure_builds_rtu_transport_for_RtuOverTcp()
+ => BuildDefaultTransport(new ModbusDriverOptions { Transport = ModbusTransportMode.RtuOverTcp })
+ .ShouldBeOfType();
+
+ [Fact]
+ public void Default_closure_builds_tcp_transport_for_Tcp()
+ => BuildDefaultTransport(new ModbusDriverOptions())
+ .ShouldBeOfType();
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ModbusDriverFormModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ModbusDriverFormModelTests.cs
index c7e47597..59c90bc2 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ModbusDriverFormModelTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ModbusDriverFormModelTests.cs
@@ -61,4 +61,15 @@ public sealed class ModbusDriverFormModelTests
json.ShouldContain("\"family\":\"MELSEC\""); // enum-as-name (not a number), camelCase key
json.ShouldNotContain("\"family\":0", Case.Sensitive); // never the numeric enum form
}
+
+ [Fact]
+ public void Round_trip_preserves_Transport_mode()
+ {
+ var form = new ModbusDriverForm.FormModel { Transport = ModbusTransportMode.RtuOverTcp };
+ var json = JsonSerializer.Serialize(form.ToOptions(), JsonOpts);
+ var back = ModbusDriverForm.FormModel.FromOptions(
+ JsonSerializer.Deserialize(json, JsonOpts)!);
+ back.Transport.ShouldBe(ModbusTransportMode.RtuOverTcp);
+ json.ShouldContain("\"transport\":\"RtuOverTcp\""); // name string, never a number
+ }
}