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 + } }