Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e27eb77187 | |||
| fcc7ed698e | |||
| 4a8c9badce | |||
| 650e27075f | |||
| 132e7b63f6 | |||
| 13bd9820b0 | |||
| 1d90bf3611 | |||
| 20be5416b9 | |||
| 47e9cd56ef | |||
| 2f38b5b285 | |||
| 20100c36ad | |||
| eed6617784 | |||
| 8d0f60ec51 | |||
| e787aa572b | |||
| 963eec1b32 |
@@ -58,3 +58,6 @@ lib/
|
|||||||
# Session working notes — never stage (pending.md's own hard rule; R2-12/U-4)
|
# Session working notes — never stage (pending.md's own hard rule; R2-12/U-4)
|
||||||
/pending.md
|
/pending.md
|
||||||
/current.md
|
/current.md
|
||||||
|
|
||||||
|
# Subagent-driven-development git worktrees
|
||||||
|
.worktrees/
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ it reaches 📝.
|
|||||||
| Wave | Deliverable | Design | Impl. plan | Status | Effort | Fixture / hardware |
|
| 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) |
|
| **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 |
|
| **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** | 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 |
|
| **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
|
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.
|
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)
|
- **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
|
- **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.
|
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
|
**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.
|
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
|
- **Fixture:** added the `rtu_over_tcp` profile (`framer=rtu`, host `:5021`, co-runs with `standard`)
|
||||||
step is confirming the pymodbus simulator exposes the RTU framer on a TCP server.
|
to the Modbus integration fixture. **Unknown confirmed:** pymodbus 3.13's simulator DOES honour
|
||||||
- **Effort:** **S — the lowest on the roadmap.** Recommended first.
|
`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
|
### SQL poll — 📝 Plan ready
|
||||||
- **Design:** [`2026-07-15-sql-poll-driver-design.md`](2026-07-15-sql-poll-driver-design.md)
|
- **Design:** [`2026-07-15-sql-poll-driver-design.md`](2026-07-15-sql-poll-driver-design.md)
|
||||||
|
|||||||
@@ -130,6 +130,14 @@ public sealed class ModbusDriverOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public MelsecFamily MelsecSubFamily { get; init; } = MelsecFamily.Q_L_iQR;
|
public MelsecFamily MelsecSubFamily { get; init; } = MelsecFamily.Q_L_iQR;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wire transport selector. <see cref="ModbusTransportMode.Tcp"/> (default) is the
|
||||||
|
/// historical Modbus TCP/MBAP framing. <see cref="ModbusTransportMode.RtuOverTcp"/>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public ModbusTransportMode Transport { get; init; } = ModbusTransportMode.Tcp;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// When <c>true</c>, the driver suppresses redundant writes: if the most recent
|
/// When <c>true</c>, 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
|
/// successful write to a tag carried value V and a new write of V arrives, the second
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wire transport for a Modbus driver instance. <see cref="Tcp"/> = Modbus/TCP (MBAP + TxId);
|
||||||
|
/// <see cref="RtuOverTcp"/> = RTU framing (address + CRC-16, no MBAP) tunnelled over a socket to
|
||||||
|
/// a serial→Ethernet gateway. Default <see cref="Tcp"/> — existing configs omit the field.
|
||||||
|
/// A direct-serial <c>Rtu</c> member is intentionally NOT reserved here (descoped 2026-07-15);
|
||||||
|
/// do not number-squat it.
|
||||||
|
/// </summary>
|
||||||
|
public enum ModbusTransportMode
|
||||||
|
{
|
||||||
|
/// <summary>Modbus/TCP — 7-byte MBAP header + transaction id (the historical default).</summary>
|
||||||
|
Tcp,
|
||||||
|
|
||||||
|
/// <summary>RTU framing (<c>[addr][PDU][CRC-16]</c>) tunnelled over a socket to a serial→Ethernet gateway.</summary>
|
||||||
|
RtuOverTcp,
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// CRC-16/MODBUS helper: reflected polynomial <c>0xA001</c>, initial value <c>0xFFFF</c>.
|
||||||
|
/// On the wire the CRC is appended low byte first, high byte second — <see cref="Append"/>
|
||||||
|
/// does that; <see cref="Compute"/> just returns the 16-bit value. Byte-stream-agnostic:
|
||||||
|
/// no socket or framing knowledge lives here.
|
||||||
|
/// </summary>
|
||||||
|
public static class ModbusCrc
|
||||||
|
{
|
||||||
|
/// <summary>Computes the CRC-16/MODBUS checksum over <paramref name="data"/>.</summary>
|
||||||
|
/// <param name="data">The bytes to checksum (e.g. the RTU frame minus the trailing CRC).</param>
|
||||||
|
/// <returns>The 16-bit CRC value.</returns>
|
||||||
|
public static ushort Compute(ReadOnlySpan<byte> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns <paramref name="body"/> with its CRC-16/MODBUS appended, low byte first.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="body">The frame body to checksum.</param>
|
||||||
|
/// <returns>A new array: <paramref name="body"/> followed by <c>[crc & 0xFF, crc >> 8]</c>.</returns>
|
||||||
|
public static byte[] Append(ReadOnlySpan<byte> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,7 +94,8 @@ public sealed class ModbusDriver
|
|||||||
/// <summary>Initializes a new Modbus TCP driver with the specified options and transport factory.</summary>
|
/// <summary>Initializes a new Modbus TCP driver with the specified options and transport factory.</summary>
|
||||||
/// <param name="options">Driver configuration options.</param>
|
/// <param name="options">Driver configuration options.</param>
|
||||||
/// <param name="driverInstanceId">Unique identifier for this driver instance.</param>
|
/// <param name="driverInstanceId">Unique identifier for this driver instance.</param>
|
||||||
/// <param name="transportFactory">Factory to create the Modbus transport; defaults to ModbusTcpTransport.</param>
|
/// <param name="transportFactory">Factory to create the Modbus transport; defaults to
|
||||||
|
/// <see cref="ModbusTransportFactory.Create"/>, which honours <see cref="ModbusDriverOptions.Transport"/>.</param>
|
||||||
/// <param name="logger">Logger instance; defaults to null logger if not provided.</param>
|
/// <param name="logger">Logger instance; defaults to null logger if not provided.</param>
|
||||||
public ModbusDriver(ModbusDriverOptions options, string driverInstanceId,
|
public ModbusDriver(ModbusDriverOptions options, string driverInstanceId,
|
||||||
Func<ModbusDriverOptions, IModbusTransport>? transportFactory = null,
|
Func<ModbusDriverOptions, IModbusTransport>? transportFactory = null,
|
||||||
@@ -107,11 +108,7 @@ public sealed class ModbusDriver
|
|||||||
_resolver = new EquipmentTagRefResolver<ModbusTagDefinition>(
|
_resolver = new EquipmentTagRefResolver<ModbusTagDefinition>(
|
||||||
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
|
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
|
||||||
_transportFactory = transportFactory
|
_transportFactory = transportFactory
|
||||||
?? (o => new ModbusTcpTransport(
|
?? (o => ModbusTransportFactory.Create(o));
|
||||||
o.Host, o.Port, o.Timeout, o.AutoReconnect,
|
|
||||||
keepAlive: o.KeepAlive,
|
|
||||||
idleDisconnect: o.IdleDisconnectTimeout,
|
|
||||||
reconnect: o.Reconnect));
|
|
||||||
_poll = new PollGroupEngine(
|
_poll = new PollGroupEngine(
|
||||||
reader: ReadAsync,
|
reader: ReadAsync,
|
||||||
onChange: (handle, tagRef, snapshot) =>
|
onChange: (handle, tagRef, snapshot) =>
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ public static class ModbusDriverFactoryExtensions
|
|||||||
: ParseEnum<ModbusFamily>(dto.Family, "<driver-level>", driverInstanceId, "Family"),
|
: ParseEnum<ModbusFamily>(dto.Family, "<driver-level>", driverInstanceId, "Family"),
|
||||||
MelsecSubFamily = dto.MelsecSubFamily is null ? MelsecFamily.Q_L_iQR
|
MelsecSubFamily = dto.MelsecSubFamily is null ? MelsecFamily.Q_L_iQR
|
||||||
: ParseEnum<MelsecFamily>(dto.MelsecSubFamily, "<driver-level>", driverInstanceId, "MelsecSubFamily"),
|
: ParseEnum<MelsecFamily>(dto.MelsecSubFamily, "<driver-level>", driverInstanceId, "MelsecSubFamily"),
|
||||||
|
Transport = dto.Transport is null ? ModbusTransportMode.Tcp
|
||||||
|
: ParseEnum<ModbusTransportMode>(dto.Transport, "<driver-level>", driverInstanceId, "Transport"),
|
||||||
AutoProhibitReprobeInterval = dto.AutoProhibitReprobeMs is { } reprobeMs ? TimeSpan.FromMilliseconds(reprobeMs) : null,
|
AutoProhibitReprobeInterval = dto.AutoProhibitReprobeMs is { } reprobeMs ? TimeSpan.FromMilliseconds(reprobeMs) : null,
|
||||||
AutoReconnect = dto.AutoReconnect ?? true,
|
AutoReconnect = dto.AutoReconnect ?? true,
|
||||||
// v3: the deploy artifact (Wave C) populates rawTags with the cluster's authored raw Modbus
|
// 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; }
|
public string? Family { get; init; }
|
||||||
/// <summary>Gets or sets the Melsec subfamily.</summary>
|
/// <summary>Gets or sets the Melsec subfamily.</summary>
|
||||||
public string? MelsecSubFamily { get; init; }
|
public string? MelsecSubFamily { get; init; }
|
||||||
|
/// <summary>Gets or sets the wire transport mode (Tcp / RtuOverTcp). Null defaults to Tcp.</summary>
|
||||||
|
public string? Transport { get; init; }
|
||||||
/// <summary>Gets or sets the automatic prohibition reprobei interval in milliseconds.</summary>
|
/// <summary>Gets or sets the automatic prohibition reprobei interval in milliseconds.</summary>
|
||||||
public int? AutoProhibitReprobeMs { get; init; }
|
public int? AutoProhibitReprobeMs { get; init; }
|
||||||
/// <summary>Gets or sets a value indicating whether automatic reconnection is enabled.</summary>
|
/// <summary>Gets or sets a value indicating whether automatic reconnection is enabled.</summary>
|
||||||
|
|||||||
@@ -29,15 +29,19 @@ public sealed class ModbusDriverProbe : IDriverProbe
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string DriverType => "Modbus";
|
public string DriverType => "Modbus";
|
||||||
|
|
||||||
/// <summary>The parsed probe target — host/port/unitId + the effective connection timeout, all read
|
/// <summary>The parsed probe target — host/port/unitId + the effective connection timeout + the wire
|
||||||
/// from the SAME factory DTO shape (<c>ModbusDriverConfigDto</c>) the driver factory parses, so
|
/// transport mode, all read from the SAME factory DTO shape (<c>ModbusDriverConfigDto</c>) the driver
|
||||||
/// <c>timeoutMs</c> binds identically to the factory (R2-11, 05/CONV-2; the OpcUaClient parity rule).</summary>
|
/// factory parses, so <c>timeoutMs</c> binds identically to the factory (R2-11, 05/CONV-2; the
|
||||||
internal readonly record struct ProbeTarget(string Host, int Port, byte UnitId, TimeSpan Timeout);
|
/// OpcUaClient parity rule) and an <c>RtuOverTcp</c>-authored config probes over RTU framing —
|
||||||
|
/// matching how the driver will actually run.</summary>
|
||||||
|
internal readonly record struct ProbeTarget(string Host, int Port, byte UnitId, TimeSpan Timeout, ModbusTransportMode Transport);
|
||||||
|
|
||||||
/// <summary>Parse the driver config JSON into a <see cref="ProbeTarget"/> using the factory DTO shape.
|
/// <summary>Parse the driver config JSON into a <see cref="ProbeTarget"/> using the factory DTO shape.
|
||||||
/// Returns a null target + an error message when the JSON is invalid or has no host/port. The effective
|
/// Returns a null target + an error message when the JSON is invalid or has no host/port. The effective
|
||||||
/// timeout mirrors the factory (<c>TimeSpan.FromMilliseconds(dto.TimeoutMs ?? …)</c>); when the config
|
/// timeout mirrors the factory (<c>TimeSpan.FromMilliseconds(dto.TimeoutMs ?? …)</c>); when the config
|
||||||
/// omits <c>timeoutMs</c> the caller's <paramref name="fallbackTimeout"/> is used.</summary>
|
/// omits <c>timeoutMs</c> the caller's <paramref name="fallbackTimeout"/> is used. <c>Transport</c>
|
||||||
|
/// mirrors the factory's null-defaults-to-Tcp convention (<see cref="ModbusDriverFactoryExtensions"/>);
|
||||||
|
/// an unrecognized value is reported as a probe-target error rather than silently falling back.</summary>
|
||||||
/// <param name="configJson">The driver config JSON (factory DTO shape).</param>
|
/// <param name="configJson">The driver config JSON (factory DTO shape).</param>
|
||||||
/// <param name="fallbackTimeout">The timeout used when the config omits <c>timeoutMs</c>.</param>
|
/// <param name="fallbackTimeout">The timeout used when the config omits <c>timeoutMs</c>.</param>
|
||||||
/// <returns>The parsed target, or a null target with an error string.</returns>
|
/// <returns>The parsed target, or a null target with an error string.</returns>
|
||||||
@@ -52,8 +56,15 @@ public sealed class ModbusDriverProbe : IDriverProbe
|
|||||||
if (string.IsNullOrWhiteSpace(dto.Host) || port <= 0)
|
if (string.IsNullOrWhiteSpace(dto.Host) || port <= 0)
|
||||||
return (null, "Config has no host/port to probe.");
|
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<ModbusTransportMode>())}");
|
||||||
|
}
|
||||||
|
|
||||||
var timeout = dto.TimeoutMs is { } ms && ms > 0 ? TimeSpan.FromMilliseconds(ms) : fallbackTimeout;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -72,9 +83,17 @@ public sealed class ModbusDriverProbe : IDriverProbe
|
|||||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
cts.CancelAfter(timeout);
|
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.
|
// 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))
|
await using (transport.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Modbus RTU framing: builds a request ADU (<c>[unitId] + PDU + CRC</c>) and deframes a
|
||||||
|
/// response back to its bare PDU. Byte-stream-agnostic — it operates on a <see cref="Stream"/>
|
||||||
|
/// and knows nothing about sockets or serial ports, so a future serial transport can reuse it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The single genuinely-new correctness risk of the RTU path: an RTU frame carries <b>no
|
||||||
|
/// length field</b> (unlike TCP's MBAP header), so the response size must be derived from
|
||||||
|
/// the function code. After reading <c>addr(1)</c> + <c>fc(1)</c>, the remaining byte count
|
||||||
|
/// is one of three shapes:
|
||||||
|
/// </para>
|
||||||
|
/// <list type="table">
|
||||||
|
/// <listheader><term>Shape</term><description>Trailing bytes after <c>addr,fc</c></description></listheader>
|
||||||
|
/// <item>
|
||||||
|
/// <term>Exception (<c>fc & 0x80</c>)</term>
|
||||||
|
/// <description><c>excCode(1)</c> + <c>CRC(2)</c></description>
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <term>Read (FC 01/02/03/04)</term>
|
||||||
|
/// <description><c>byteCount(1)</c> then <c>byteCount</c> data bytes + <c>CRC(2)</c></description>
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <term>Write echo (FC 05/06/15/16)</term>
|
||||||
|
/// <description>fixed <c>4</c> bytes + <c>CRC(2)</c></description>
|
||||||
|
/// </item>
|
||||||
|
/// </list>
|
||||||
|
/// <para>
|
||||||
|
/// The trailing CRC is validated over <c>[addr, ...pdu]</c> (everything except the two CRC
|
||||||
|
/// bytes) <b>before</b> the frame is interpreted — so a corrupt exception frame surfaces as a
|
||||||
|
/// desync, never as a bogus <see cref="ModbusException"/>. A CRC mismatch or a short read
|
||||||
|
/// (truncation / stream closed mid-frame) throws <see cref="ModbusTransportDesyncException"/>;
|
||||||
|
/// a CRC-valid exception PDU throws <see cref="ModbusException"/> carrying the original
|
||||||
|
/// function code (<c>fc & 0x7F</c>) and exception code.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// 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 <b>wrong</b> slave would otherwise be
|
||||||
|
/// silently accepted as the addressed unit's data; such a frame is a unit-mismatch
|
||||||
|
/// <see cref="ModbusTransportDesyncException"/>. 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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class ModbusRtuFraming
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Builds an RTU request ADU: the unit id, the PDU, and the trailing CRC-16/MODBUS
|
||||||
|
/// (appended low byte first).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="unitId">The RTU slave/unit address.</param>
|
||||||
|
/// <param name="pdu">The bare PDU (function code + data).</param>
|
||||||
|
/// <returns>A new array: <c>[unitId, ...pdu, crcLo, crcHi]</c>.</returns>
|
||||||
|
public static byte[] BuildAdu(byte unitId, ReadOnlySpan<byte> pdu)
|
||||||
|
{
|
||||||
|
var frame = new byte[1 + pdu.Length];
|
||||||
|
frame[0] = unitId;
|
||||||
|
pdu.CopyTo(frame.AsSpan(1));
|
||||||
|
return ModbusCrc.Append(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads a single RTU response frame from <paramref name="stream"/> and returns its bare PDU
|
||||||
|
/// (<c>[fc, ...data]</c>), with the leading unit-id and trailing CRC stripped.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="stream">The byte stream to read the response from.</param>
|
||||||
|
/// <param name="expectedUnit">
|
||||||
|
/// 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.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="ct">A token to cancel the read.</param>
|
||||||
|
/// <returns>The bare response PDU (function code followed by its data).</returns>
|
||||||
|
/// <exception cref="ModbusTransportDesyncException">
|
||||||
|
/// The frame was truncated (stream closed mid-frame), its trailing CRC did not validate, or its
|
||||||
|
/// address byte did not match <paramref name="expectedUnit"/>.
|
||||||
|
/// </exception>
|
||||||
|
/// <exception cref="ModbusException">
|
||||||
|
/// The frame was a CRC-valid Modbus exception PDU (high bit set on the function code).
|
||||||
|
/// </exception>
|
||||||
|
public static async Task<byte[]> 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<byte>.Empty, tail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Assembles the full frame from its pieces, validates the trailing CRC over everything
|
||||||
|
/// except the CRC bytes, then validates the response address against
|
||||||
|
/// <paramref name="expectedUnit"/> and strips <c>addr</c> + <c>CRC</c> to return the bare PDU.
|
||||||
|
/// A CRC-valid exception PDU throws <see cref="ModbusException"/>.
|
||||||
|
/// </summary>
|
||||||
|
private static byte[] ValidateAndStrip(
|
||||||
|
byte expectedUnit, ReadOnlySpan<byte> header, ReadOnlySpan<byte> middle, ReadOnlySpan<byte> 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.TruncatedFrame,
|
||||||
|
$"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.TruncatedFrame,
|
||||||
|
$"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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fills <paramref name="buf"/> completely from <paramref name="stream"/>, throwing a
|
||||||
|
/// truncation desync if the stream ends first. Mirrors
|
||||||
|
/// <c>ModbusTcpTransport.ReadExactlyAsync</c>, but normalises the end-of-stream to a
|
||||||
|
/// <see cref="ModbusTransportDesyncException"/> so a length-less RTU frame that arrives
|
||||||
|
/// short is classified as a desync rather than a bare <see cref="EndOfStreamException"/>.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
using System.Net.Sockets;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Concrete Modbus RTU-over-TCP transport. Composes <see cref="ModbusSocketLifecycle"/> for the
|
||||||
|
/// connect / reconnect / keep-alive / idle machinery and <see cref="ModbusRtuFraming"/> for the
|
||||||
|
/// wire layout — the same lifecycle <see cref="ModbusTcpTransport"/> uses, but with CRC framing
|
||||||
|
/// instead of MBAP.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Delta from <see cref="ModbusTcpTransport"/>: an RTU ADU is <c>[unitId][PDU][CRC]</c> with
|
||||||
|
/// <b>no MBAP header and no transaction id</b>. Because there is no TxId to correlate
|
||||||
|
/// interleaved responses, at most one transaction may be on the bus at a time — the
|
||||||
|
/// <see cref="_gate"/> single-flight is therefore <b>mandatory</b>, not merely a
|
||||||
|
/// diagnostics convenience.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Survives mid-transaction socket drops the same way the TCP transport does: a socket-level
|
||||||
|
/// failure (<see cref="IOException"/> / <see cref="SocketException"/> /
|
||||||
|
/// <see cref="EndOfStreamException"/>, and the <see cref="ModbusTransportDesyncException"/>
|
||||||
|
/// that derives from <see cref="IOException"/>) triggers a single reconnect-then-retry; a
|
||||||
|
/// second failure bubbles up so the driver's health surface reflects the real state.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Every transaction runs under a per-op deadline (a linked
|
||||||
|
/// <see cref="CancellationTokenSource.CancelAfter(TimeSpan)"/>, design §7 / R2-01) so a
|
||||||
|
/// frozen gateway can never wedge a poll: the deadline firing is normalised to a
|
||||||
|
/// <see cref="ModbusTransportDesyncException"/> and tears the socket down so the next attempt
|
||||||
|
/// reconnects. A <b>caller</b> cancellation is distinguished from that timeout and propagates
|
||||||
|
/// as a plain <see cref="OperationCanceledException"/> with no teardown.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The constructor is connection-free; all socket I/O happens in <see cref="ConnectAsync"/> /
|
||||||
|
/// <see cref="SendAsync"/>. The <see cref="ForTest"/> seam injects a pre-connected
|
||||||
|
/// <see cref="Stream"/> (an in-memory duplex fake) so the send/receive orchestration,
|
||||||
|
/// single-flight, and deadline can be exercised with no socket — in that path
|
||||||
|
/// <see cref="_life"/> is <see langword="null"/> and the idle / reconnect machinery is
|
||||||
|
/// skipped (a raw injected stream cannot reconnect).
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>Initializes a new instance of the <see cref="ModbusRtuOverTcpTransport"/> class.</summary>
|
||||||
|
/// <param name="host">The host address or hostname of the Modbus gateway.</param>
|
||||||
|
/// <param name="port">The TCP port of the Modbus gateway.</param>
|
||||||
|
/// <param name="timeout">The timeout for socket operations and the per-op response deadline.</param>
|
||||||
|
/// <param name="autoReconnect">Whether to automatically reconnect on socket failures.</param>
|
||||||
|
/// <param name="keepAlive">Optional keep-alive configuration for the socket.</param>
|
||||||
|
/// <param name="idleDisconnect">Optional duration after which an idle socket is disconnected.</param>
|
||||||
|
/// <param name="reconnect">Optional reconnect backoff configuration.</param>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test seam: builds a transport over a pre-connected, in-memory duplex <paramref name="stream"/>,
|
||||||
|
/// bypassing <see cref="ConnectAsync"/> and the idle / reconnect machinery so the send/receive
|
||||||
|
/// orchestration, single-flight, and per-op deadline can be exercised with no real socket.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="stream">A pre-connected duplex stream standing in for the socket.</param>
|
||||||
|
/// <param name="timeout">The per-op response deadline.</param>
|
||||||
|
/// <returns>A transport driving <paramref name="stream"/> directly.</returns>
|
||||||
|
internal static ModbusRtuOverTcpTransport ForTest(Stream stream, TimeSpan timeout) => new(stream, timeout);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<byte[]> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The live stream: the lifecycle's <see cref="NetworkStream"/>, or the injected test stream.</summary>
|
||||||
|
private Stream? CurrentStream => _life?.Stream ?? _testStream;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<byte[]> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Asynchronously disposes the transport and underlying socket resources.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
if (_life is not null) await _life.DisposeAsync().ConfigureAwait(false);
|
||||||
|
_gate.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
using System.Net.Sockets;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// <see cref="NetworkStream"/> so the composing transport can drive its own framing on top —
|
||||||
|
/// the lifecycle knows nothing about MBAP / RTU wire layout.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Extracted verbatim from <see cref="ModbusTcpTransport"/> 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 <see cref="ConnectAsync"/> /
|
||||||
|
/// <see cref="ConnectWithBackoffAsync"/>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Why keep-alive matters for DL205/DL260: the AutomationDirect H2-ECOM100 does NOT send
|
||||||
|
/// TCP keepalives per <c>docs/v2/dl205.md</c> §behavioral-oddities, so any NAT/firewall
|
||||||
|
/// between the gateway and PLC can silently close an idle socket after 2-5 minutes.
|
||||||
|
/// Enabling OS-level <c>SO_KEEPALIVE</c> lets the driver's own side detect a stuck socket
|
||||||
|
/// in reasonable time even when the application is mostly idle.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>Initializes a new instance of the <see cref="ModbusSocketLifecycle"/> class.</summary>
|
||||||
|
/// <param name="host">The host address or hostname of the Modbus server.</param>
|
||||||
|
/// <param name="port">The TCP port of the Modbus server.</param>
|
||||||
|
/// <param name="timeout">The timeout for socket operations.</param>
|
||||||
|
/// <param name="autoReconnect">Whether to automatically reconnect on socket failures.</param>
|
||||||
|
/// <param name="keepAlive">Optional keep-alive configuration for the socket.</param>
|
||||||
|
/// <param name="idleDisconnect">Optional duration after which an idle socket is disconnected.</param>
|
||||||
|
/// <param name="reconnect">Optional reconnect backoff configuration.</param>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gets the current live <see cref="NetworkStream"/>, or <see langword="null"/> when not connected.</summary>
|
||||||
|
public NetworkStream? Stream => _stream;
|
||||||
|
|
||||||
|
/// <summary>Gets a value indicating whether auto-reconnect on socket failures is enabled.</summary>
|
||||||
|
public bool AutoReconnect => _autoReconnect;
|
||||||
|
|
||||||
|
/// <summary>Establishes a connection to the Modbus server, preferring IPv4.</summary>
|
||||||
|
/// <param name="ct">A cancellation token to observe for cancellation.</param>
|
||||||
|
/// <returns>A task representing the asynchronous connection operation.</returns>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Connect attempt with the configured geometric backoff. The first attempt fires after
|
||||||
|
/// <see cref="ModbusReconnectOptions.InitialDelay"/> (default zero — immediate); each
|
||||||
|
/// subsequent attempt sleeps for the previous delay times <c>BackoffMultiplier</c>,
|
||||||
|
/// capped at <c>MaxDelay</c>. Caller's cancellation token aborts the loop.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ct">A cancellation token to observe for cancellation.</param>
|
||||||
|
/// <returns>A task representing the asynchronous reconnect operation.</returns>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see langword="false"/> when no idle-disconnect timeout is configured.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns><see langword="true"/> when the idle threshold has been exceeded.</returns>
|
||||||
|
public bool ShouldReconnectForIdle() =>
|
||||||
|
_idleDisconnect.HasValue && DateTime.UtcNow - _lastSuccessUtc > _idleDisconnect.Value;
|
||||||
|
|
||||||
|
/// <summary>Records that a transaction just succeeded, resetting the idle-disconnect clock.</summary>
|
||||||
|
public void MarkSuccess() => _lastSuccessUtc = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>Tears down the current socket + stream, leaving the lifecycle ready to reconnect.</summary>
|
||||||
|
/// <returns>A task representing the asynchronous teardown operation.</returns>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
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 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cast a <see cref="TimeSpan"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ts">The timespan to clamp to whole seconds.</param>
|
||||||
|
/// <returns>The clamped duration expressed as a whole number of seconds, never less than 1.</returns>
|
||||||
|
internal static int ClampToWholeSeconds(TimeSpan ts)
|
||||||
|
{
|
||||||
|
var seconds = (int)Math.Ceiling(ts.TotalSeconds);
|
||||||
|
return seconds < 1 ? 1 : seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ex">The exception to classify.</param>
|
||||||
|
/// <returns><see langword="true"/> when the exception is a socket-level failure.</returns>
|
||||||
|
internal static bool IsSocketLevelFailure(Exception ex) =>
|
||||||
|
ex is EndOfStreamException
|
||||||
|
|| ex is IOException
|
||||||
|
|| ex is SocketException
|
||||||
|
|| ex is ObjectDisposedException;
|
||||||
|
|
||||||
|
/// <summary>Asynchronously disposes the underlying socket + stream resources.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch { /* best-effort */ }
|
||||||
|
_client?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,13 +3,19 @@ using System.Net.Sockets;
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Concrete Modbus TCP transport. Wraps a single <see cref="TcpClient"/> and serializes
|
/// Concrete Modbus TCP transport. Wraps a single socket (owned by <see cref="ModbusSocketLifecycle"/>)
|
||||||
/// requests so at most one transaction is in-flight at a time — Modbus servers typically
|
/// and serializes requests so at most one transaction is in-flight at a time — Modbus servers
|
||||||
/// support concurrent transactions, but the single-flight model keeps the wire trace
|
/// typically support concurrent transactions, but the single-flight model keeps the wire trace
|
||||||
/// easy to diagnose and avoids interleaved-response correlation bugs.
|
/// easy to diagnose and avoids interleaved-response correlation bugs.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
|
/// Owns the MBAP framing (<see cref="SendOnceAsync"/>: 7-byte header + transaction-id
|
||||||
|
/// pairing) and composes <see cref="ModbusSocketLifecycle"/> for the connect / reconnect /
|
||||||
|
/// keep-alive / idle-disconnect machinery — the same lifecycle the RTU-over-TCP transport
|
||||||
|
/// reuses with different framing.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
/// Survives mid-transaction socket drops: when a send/read fails with a socket-level
|
/// Survives mid-transaction socket drops: when a send/read fails with a socket-level
|
||||||
/// error (<see cref="IOException"/>, <see cref="SocketException"/>, <see cref="EndOfStreamException"/>)
|
/// error (<see cref="IOException"/>, <see cref="SocketException"/>, <see cref="EndOfStreamException"/>)
|
||||||
/// the transport disposes the dead socket, reconnects, and retries the PDU exactly
|
/// the transport disposes the dead socket, reconnects, and retries the PDU exactly
|
||||||
@@ -26,19 +32,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class ModbusTcpTransport : IModbusTransport
|
public sealed class ModbusTcpTransport : IModbusTransport
|
||||||
{
|
{
|
||||||
private readonly string _host;
|
private readonly ModbusSocketLifecycle _life;
|
||||||
private readonly int _port;
|
|
||||||
private readonly TimeSpan _timeout;
|
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 readonly SemaphoreSlim _gate = new(1, 1);
|
||||||
private TcpClient? _client;
|
|
||||||
private NetworkStream? _stream;
|
|
||||||
private ushort _nextTx;
|
private ushort _nextTx;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
private DateTime _lastSuccessUtc = DateTime.UtcNow;
|
|
||||||
|
|
||||||
/// <summary>Initializes a new instance of the <see cref="ModbusTcpTransport"/> class.</summary>
|
/// <summary>Initializes a new instance of the <see cref="ModbusTcpTransport"/> class.</summary>
|
||||||
/// <param name="host">The host address or hostname of the Modbus server.</param>
|
/// <param name="host">The host address or hostname of the Modbus server.</param>
|
||||||
@@ -54,84 +52,18 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
|||||||
TimeSpan? idleDisconnect = null,
|
TimeSpan? idleDisconnect = null,
|
||||||
ModbusReconnectOptions? reconnect = null)
|
ModbusReconnectOptions? reconnect = null)
|
||||||
{
|
{
|
||||||
_host = host;
|
|
||||||
_port = port;
|
|
||||||
_timeout = timeout;
|
_timeout = timeout;
|
||||||
_autoReconnect = autoReconnect;
|
_life = new ModbusSocketLifecycle(host, port, timeout, autoReconnect, keepAlive, idleDisconnect, reconnect);
|
||||||
_keepAlive = keepAlive ?? new ModbusKeepAliveOptions();
|
|
||||||
_idleDisconnect = idleDisconnect;
|
|
||||||
_reconnect = reconnect ?? new ModbusReconnectOptions();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task ConnectAsync(CancellationToken ct)
|
public Task ConnectAsync(CancellationToken ct) => _life.ConnectAsync(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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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).
|
|
||||||
/// </summary>
|
|
||||||
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 */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Cast a <see cref="TimeSpan"/> 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.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="ts">The timespan to clamp to whole seconds.</param>
|
|
||||||
/// <returns>The clamped duration expressed as a whole number of seconds, never less than 1.</returns>
|
|
||||||
internal static int ClampToWholeSeconds(TimeSpan ts)
|
|
||||||
{
|
|
||||||
var seconds = (int)Math.Ceiling(ts.TotalSeconds);
|
|
||||||
return seconds < 1 ? 1 : seconds;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
public async Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (_disposed) throw new ObjectDisposedException(nameof(ModbusTcpTransport));
|
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);
|
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||||
try
|
try
|
||||||
@@ -140,27 +72,27 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
|||||||
// threshold, tear it down + reconnect before this PDU lands. Defends against silent
|
// 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
|
// NAT / firewall reaping where the socket looks alive locally but the upstream side
|
||||||
// dropped it minutes ago.
|
// dropped it minutes ago.
|
||||||
if (_idleDisconnect.HasValue && DateTime.UtcNow - _lastSuccessUtc > _idleDisconnect.Value)
|
if (_life.ShouldReconnectForIdle())
|
||||||
{
|
{
|
||||||
await TearDownAsync().ConfigureAwait(false);
|
await _life.TearDownAsync().ConfigureAwait(false);
|
||||||
await ConnectWithBackoffAsync(ct).ConfigureAwait(false);
|
await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
|
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
|
||||||
_lastSuccessUtc = DateTime.UtcNow;
|
_life.MarkSuccess();
|
||||||
return result;
|
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
|
// Mid-transaction drop: tear down the dead socket, reconnect (with backoff if
|
||||||
// configured), resend. Single retry — if it fails again, let it propagate so
|
// configured), resend. Single retry — if it fails again, let it propagate so
|
||||||
// health/status reflect reality.
|
// health/status reflect reality.
|
||||||
await TearDownAsync().ConfigureAwait(false);
|
await _life.TearDownAsync().ConfigureAwait(false);
|
||||||
await ConnectWithBackoffAsync(ct).ConfigureAwait(false);
|
await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
|
||||||
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
|
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
|
||||||
_lastSuccessUtc = DateTime.UtcNow;
|
_life.MarkSuccess();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,43 +102,6 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Connect attempt with the configured geometric backoff. The first attempt fires after
|
|
||||||
/// <see cref="ModbusReconnectOptions.InitialDelay"/> (default zero — immediate); each
|
|
||||||
/// subsequent attempt sleeps for the previous delay times <c>BackoffMultiplier</c>,
|
|
||||||
/// capped at <c>MaxDelay</c>. Caller's cancellation token aborts the loop.
|
|
||||||
/// </summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Executes exactly one Modbus transaction on the current socket.
|
/// Executes exactly one Modbus transaction on the current socket.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -230,7 +125,8 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private async Task<byte[]> SendOnceAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
private async Task<byte[]> 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;
|
var txId = ++_nextTx;
|
||||||
|
|
||||||
// MBAP: [TxId(2)][Proto=0(2)][Length(2)][UnitId(1)] + PDU
|
// MBAP: [TxId(2)][Proto=0(2)][Length(2)][UnitId(1)] + PDU
|
||||||
@@ -248,11 +144,11 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
|||||||
cts.CancelAfter(_timeout);
|
cts.CancelAfter(_timeout);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false);
|
await stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false);
|
||||||
await _stream.FlushAsync(cts.Token).ConfigureAwait(false);
|
await stream.FlushAsync(cts.Token).ConfigureAwait(false);
|
||||||
|
|
||||||
var header = new byte[7];
|
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]);
|
var respTxId = (ushort)((header[0] << 8) | header[1]);
|
||||||
if (respTxId != txId)
|
if (respTxId != txId)
|
||||||
throw new ModbusTransportDesyncException(
|
throw new ModbusTransportDesyncException(
|
||||||
@@ -264,7 +160,7 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
|||||||
ModbusTransportDesyncException.DesyncReason.TruncatedFrame,
|
ModbusTransportDesyncException.DesyncReason.TruncatedFrame,
|
||||||
$"Modbus response length too small: {respLen}");
|
$"Modbus response length too small: {respLen}");
|
||||||
var respPdu = new byte[respLen - 1];
|
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
|
// 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.
|
// error — the socket is still coherent, so it MUST propagate without teardown.
|
||||||
@@ -280,7 +176,7 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
|||||||
catch (ModbusTransportDesyncException)
|
catch (ModbusTransportDesyncException)
|
||||||
{
|
{
|
||||||
// Framing violation: the socket is desynchronized — never reuse it.
|
// Framing violation: the socket is desynchronized — never reuse it.
|
||||||
await TearDownAsync().ConfigureAwait(false);
|
await _life.TearDownAsync().ConfigureAwait(false);
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
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
|
// 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
|
// and corrupt the next read — tear the socket down and normalize as a desync so the
|
||||||
// reconnect retry / status mapping engages.
|
// reconnect retry / status mapping engages.
|
||||||
await TearDownAsync().ConfigureAwait(false);
|
await _life.TearDownAsync().ConfigureAwait(false);
|
||||||
throw new ModbusTransportDesyncException(
|
throw new ModbusTransportDesyncException(
|
||||||
ModbusTransportDesyncException.DesyncReason.Timeout,
|
ModbusTransportDesyncException.DesyncReason.Timeout,
|
||||||
$"Modbus per-op response timeout after {_timeout.TotalMilliseconds:0}ms");
|
$"Modbus per-op response timeout after {_timeout.TotalMilliseconds:0}ms");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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).
|
|
||||||
/// </summary>
|
|
||||||
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)
|
private static async Task ReadExactlyAsync(Stream s, byte[] buf, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var read = 0;
|
var read = 0;
|
||||||
@@ -332,12 +208,7 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
|||||||
{
|
{
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
try
|
await _life.DisposeAsync().ConfigureAwait(false);
|
||||||
{
|
|
||||||
if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch { /* best-effort */ }
|
|
||||||
_client?.Dispose();
|
|
||||||
_gate.Dispose();
|
_gate.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single mapping point from <see cref="ModbusDriverOptions"/> to the concrete
|
||||||
|
/// <see cref="IModbusTransport"/> its <see cref="ModbusDriverOptions.Transport"/> selects.
|
||||||
|
/// Consumed by both <see cref="ModbusDriver"/>'s default transport-factory closure and the
|
||||||
|
/// AdminUI Test-Connect probe, so the <see cref="ModbusTransportMode"/> switch lives in
|
||||||
|
/// exactly one place.
|
||||||
|
/// </summary>
|
||||||
|
public static class ModbusTransportFactory
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the transport <paramref name="options"/>.<see cref="ModbusDriverOptions.Transport"/>
|
||||||
|
/// selects, threading every shared connection setting (Host/Port/Timeout/AutoReconnect/
|
||||||
|
/// KeepAlive/IdleDisconnectTimeout/Reconnect) through to whichever concrete transport is built.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">Driver configuration options.</param>
|
||||||
|
/// <returns>A <see cref="ModbusRtuOverTcpTransport"/> when <see cref="ModbusTransportMode.RtuOverTcp"/>
|
||||||
|
/// is selected; a <see cref="ModbusTcpTransport"/> when <see cref="ModbusTransportMode.Tcp"/> is selected.</returns>
|
||||||
|
/// <exception cref="ArgumentOutOfRangeException">
|
||||||
|
/// <paramref name="options"/>.<see cref="ModbusDriverOptions.Transport"/> is not a recognized
|
||||||
|
/// <see cref="ModbusTransportMode"/> member — fail loudly rather than silently falling back to TCP/MBAP.
|
||||||
|
/// </exception>
|
||||||
|
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."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
@@ -27,6 +27,16 @@
|
|||||||
}
|
}
|
||||||
</InputSelect>
|
</InputSelect>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Transport</label>
|
||||||
|
<InputSelect @bind-Value="_form.Transport" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
|
||||||
|
@foreach (var e in Enum.GetValues<ModbusTransportMode>())
|
||||||
|
{
|
||||||
|
<option value="@e">@e</option>
|
||||||
|
}
|
||||||
|
</InputSelect>
|
||||||
|
<div class="form-text">RtuOverTcp = talk raw RTU frames to a serial→Ethernet gateway; must match the gateway's mode.</div>
|
||||||
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<label class="form-label">MELSEC sub-family</label>
|
<label class="form-label">MELSEC sub-family</label>
|
||||||
<InputSelect @bind-Value="_form.MelsecSubFamily" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
|
<InputSelect @bind-Value="_form.MelsecSubFamily" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
|
||||||
@@ -290,6 +300,9 @@
|
|||||||
public ModbusFamily Family { get; set; } = ModbusFamily.Generic;
|
public ModbusFamily Family { get; set; } = ModbusFamily.Generic;
|
||||||
public MelsecFamily MelsecSubFamily { get; set; } = MelsecFamily.Q_L_iQR;
|
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
|
// Transport flags
|
||||||
public bool AutoReconnect { get; set; } = true;
|
public bool AutoReconnect { get; set; } = true;
|
||||||
public int IdleDisconnectTimeoutSeconds { get; set; } = 0;
|
public int IdleDisconnectTimeoutSeconds { get; set; } = 0;
|
||||||
@@ -337,6 +350,7 @@
|
|||||||
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
||||||
Family = o.Family,
|
Family = o.Family,
|
||||||
MelsecSubFamily = o.MelsecSubFamily,
|
MelsecSubFamily = o.MelsecSubFamily,
|
||||||
|
Transport = o.Transport,
|
||||||
AutoReconnect = o.AutoReconnect,
|
AutoReconnect = o.AutoReconnect,
|
||||||
IdleDisconnectTimeoutSeconds = o.IdleDisconnectTimeout.HasValue ? (int)o.IdleDisconnectTimeout.Value.TotalSeconds : 0,
|
IdleDisconnectTimeoutSeconds = o.IdleDisconnectTimeout.HasValue ? (int)o.IdleDisconnectTimeout.Value.TotalSeconds : 0,
|
||||||
MaxRegistersPerRead = o.MaxRegistersPerRead,
|
MaxRegistersPerRead = o.MaxRegistersPerRead,
|
||||||
@@ -387,6 +401,7 @@
|
|||||||
MaxReadGap = (ushort)Math.Clamp(MaxReadGap, 0, 65535),
|
MaxReadGap = (ushort)Math.Clamp(MaxReadGap, 0, 65535),
|
||||||
Family = Family,
|
Family = Family,
|
||||||
MelsecSubFamily = MelsecSubFamily,
|
MelsecSubFamily = MelsecSubFamily,
|
||||||
|
Transport = Transport,
|
||||||
WriteOnChangeOnly = WriteOnChangeOnly,
|
WriteOnChangeOnly = WriteOnChangeOnly,
|
||||||
AutoReconnect = AutoReconnect,
|
AutoReconnect = AutoReconnect,
|
||||||
KeepAlive = new ModbusKeepAliveOptions
|
KeepAlive = new ModbusKeepAliveOptions
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ COPY profiles/ /fixtures/
|
|||||||
# compose profile. See Docker/README.md §exception injection.
|
# compose profile. See Docker/README.md §exception injection.
|
||||||
COPY exception_injector.py /fixtures/
|
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.
|
# Default to the standard profile; docker-compose.yml overrides per service.
|
||||||
# --http_port intentionally omitted; pymodbus 3.13's web UI binds on a
|
# --http_port intentionally omitted; pymodbus 3.13's web UI binds on a
|
||||||
|
|||||||
@@ -2,15 +2,16 @@
|
|||||||
|
|
||||||
The Modbus driver's integration tests talk to a
|
The Modbus driver's integration tests talk to a
|
||||||
[`pymodbus`](https://pymodbus.readthedocs.io/) simulator running as a
|
[`pymodbus`](https://pymodbus.readthedocs.io/) simulator running as a
|
||||||
pinned Docker container. One image, per-profile service in compose, same
|
pinned Docker container. One image, per-profile service in compose. Most
|
||||||
port binding (`5020`) regardless of which profile is live. Docker is the
|
profiles bind `:5020`, so only one of *those* runs at a time; the
|
||||||
only supported launch path — a fresh clone needs Docker Desktop and
|
`rtu_over_tcp` profile binds `:5021` and is designed to co-run alongside
|
||||||
nothing else.
|
`standard`. Docker is the only supported launch path — a fresh clone needs
|
||||||
|
Docker Desktop and nothing else.
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [`Dockerfile`](Dockerfile) | `python:3.12-slim-bookworm` + `pymodbus[simulator]==3.13.0` + every profile JSON + `exception_injector.py` |
|
| [`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 |
|
| [`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 |
|
| [`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
|
docker compose -f tests\...\Docker\docker-compose.yml --profile dl205 down
|
||||||
```
|
```
|
||||||
|
|
||||||
Only one profile binds `:5020` at a time; switch by stopping the current
|
Only one of the `:5020` profiles binds at a time; switch by stopping the
|
||||||
service + starting another. The integration tests discriminate by a
|
current service + starting another. (`rtu_over_tcp` is the exception — it
|
||||||
separate `MODBUS_SIM_PROFILE` env var so they skip correctly when the
|
binds `:5021` and co-runs with `standard`.) The integration tests
|
||||||
wrong profile is live.
|
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
|
> **⚠️ Profile-cycling gotcha (bit us in the 2026-07 sweep — see
|
||||||
> `archreview/plans/INTEGRATION-SWEEP-STATUS.md`).** Each profile is a
|
> `archreview/plans/INTEGRATION-SWEEP-STATUS.md`).** Each profile is a
|
||||||
|
|||||||
+24
@@ -78,6 +78,30 @@ services:
|
|||||||
"--json_file", "/fixtures/s7_1500.json"
|
"--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
|
# Exception-injection profile. Runs the standalone pure-stdlib Modbus/TCP
|
||||||
# server shipped as exception_injector.py instead of the pymodbus
|
# server shipped as exception_injector.py instead of the pymodbus
|
||||||
# simulator — pymodbus naturally emits only exception codes 02 + 03, and
|
# simulator — pymodbus naturally emits only exception codes 02 + 03, and
|
||||||
|
|||||||
+97
@@ -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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
using System.Net.Sockets;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reachability probe for the RTU-over-TCP Modbus simulator (pymodbus in Docker with
|
||||||
|
/// <c>framer=rtu</c>, see <c>Docker/docker-compose.yml</c> profile <c>rtu_over_tcp</c>) or a
|
||||||
|
/// real serial→Ethernet Modbus gateway. Mirrors <see cref="ModbusSimulatorFixture"/> but reads
|
||||||
|
/// <c>MODBUS_RTU_SIM_ENDPOINT</c> (default <c>10.100.0.35:5021</c> — the shared Docker host, a
|
||||||
|
/// distinct port from the standard profile's <c>:5020</c> so the two fixtures co-run). TCP-connects
|
||||||
|
/// once at fixture construction; each test checks <see cref="SkipReason"/> and calls
|
||||||
|
/// <c>Assert.Skip</c> when the endpoint was unreachable, so a dev box without a running simulator
|
||||||
|
/// still passes `dotnet test` cleanly.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Same one-shot-probe discipline as <see cref="ModbusSimulatorFixture"/>: the probe socket is
|
||||||
|
/// not held for the life of the fixture (tests open their own <see cref="ModbusRtuOverTcpTransport"/>
|
||||||
|
/// against the same endpoint), and the fixture is a collection fixture so the probe runs once per
|
||||||
|
/// session rather than per test.
|
||||||
|
/// </remarks>
|
||||||
|
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";
|
||||||
|
|
||||||
|
/// <summary>Gets the host address of the RTU-over-TCP Modbus simulator.</summary>
|
||||||
|
public string Host { get; }
|
||||||
|
|
||||||
|
/// <summary>Gets the port of the RTU-over-TCP Modbus simulator.</summary>
|
||||||
|
public int Port { get; }
|
||||||
|
|
||||||
|
/// <summary>Gets the skip reason if the simulator is unreachable; otherwise null.</summary>
|
||||||
|
public string? SkipReason { get; }
|
||||||
|
|
||||||
|
/// <summary>Initializes a new instance of the <see cref="ModbusRtuOverTcpFixture"/> class.</summary>
|
||||||
|
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.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Xunit.CollectionDefinition(Name)]
|
||||||
|
public sealed class ModbusRtuOverTcpCollection : Xunit.ICollectionFixture<ModbusRtuOverTcpFixture>
|
||||||
|
{
|
||||||
|
public const string Name = "ModbusRtuOverTcp";
|
||||||
|
}
|
||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// End-to-end round-trip against the <c>rtu_over_tcp.json</c> pymodbus profile (or a real
|
||||||
|
/// serial→Ethernet Modbus gateway when <c>MODBUS_RTU_SIM_ENDPOINT</c> points at one), driving the
|
||||||
|
/// full <see cref="ModbusDriver"/> + real <see cref="ModbusRtuOverTcpTransport"/> stack with
|
||||||
|
/// <see cref="ModbusTransportMode.RtuOverTcp"/>. Proves the driver reads a seeded holding register
|
||||||
|
/// and writes+reads-back a scratch register when the wire carries RTU framing
|
||||||
|
/// (<c>[addr][PDU][CRC-16]</c>, no MBAP header) rather than Modbus/TCP.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// pymodbus 3.13's simulator honors <c>framer=rtu</c> on a TCP server — confirmed on the wire
|
||||||
|
/// (controller spike, 2026-07-24): a raw RTU FC03 read of HR[5] returned
|
||||||
|
/// <c>01 03 02 00 05 78 47</c>, 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.
|
||||||
|
/// </remarks>
|
||||||
|
[Collection(ModbusRtuOverTcpCollection.Name)]
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
[Trait("Device", "RtuOverTcp")]
|
||||||
|
public sealed class ModbusRtuOverTcpTests(ModbusRtuOverTcpFixture sim)
|
||||||
|
{
|
||||||
|
/// <summary>Reads a seeded holding register (HR[5]=5) over RTU-over-TCP framing.</summary>
|
||||||
|
[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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Writes then reads back a scratch holding register (HR[200]) over RTU-over-TCP framing.</summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-3
@@ -14,7 +14,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
|||||||
/// (2) Sub-second <see cref="TimeSpan"/> values on <c>ModbusKeepAliveOptions.Time</c> /
|
/// (2) Sub-second <see cref="TimeSpan"/> values on <c>ModbusKeepAliveOptions.Time</c> /
|
||||||
/// <c>Interval</c> — the int-cast in <c>EnableKeepAlive</c> truncated <c>500 ms</c> to
|
/// <c>Interval</c> — the int-cast in <c>EnableKeepAlive</c> truncated <c>500 ms</c> to
|
||||||
/// <c>0</c>, which most OSes interpret as "use the default", silently defeating the
|
/// <c>0</c>, which most OSes interpret as "use the default", silently defeating the
|
||||||
/// configured timing. <c>ModbusTcpTransport.ClampToWholeSeconds</c> rounds up to a minimum
|
/// configured timing. <c>ModbusSocketLifecycle.ClampToWholeSeconds</c> rounds up to a minimum
|
||||||
/// of 1 second.
|
/// of 1 second.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Trait("Category", "Unit")]
|
[Trait("Category", "Unit")]
|
||||||
@@ -70,7 +70,7 @@ public sealed class ModbusEdgeCaseValidationTests
|
|||||||
[InlineData(60_000, 60)]
|
[InlineData(60_000, 60)]
|
||||||
public void ClampToWholeSeconds_rounds_up_to_at_least_one_second(int ms, int expected)
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that negative time spans are treated as one second.</summary>
|
/// <summary>Verifies that negative time spans are treated as one second.</summary>
|
||||||
@@ -80,6 +80,6 @@ public sealed class ModbusEdgeCaseValidationTests
|
|||||||
// Defensive — operators occasionally configure a negative TimeSpan thinking it disables
|
// 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
|
// the feature. The OS would reject the negative int — clamping to 1 keeps the socket
|
||||||
// valid until the operator fixes the config.
|
// valid until the operator fixes the config.
|
||||||
ModbusTcpTransport.ClampToWholeSeconds(TimeSpan.FromSeconds(-5)).ShouldBe(1);
|
ModbusSocketLifecycle.ClampToWholeSeconds(TimeSpan.FromSeconds(-5)).ShouldBe(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
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<ModbusException>(
|
||||||
|
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);
|
||||||
|
await Should.ThrowAsync<ModbusTransportDesyncException>(
|
||||||
|
ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[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);
|
||||||
|
await Should.ThrowAsync<ModbusTransportDesyncException>(
|
||||||
|
ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[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);
|
||||||
|
await Should.ThrowAsync<ModbusTransportDesyncException>(
|
||||||
|
ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A read-only test double that dribbles its backing bytes out at most <c>chunk</c> at a time
|
||||||
|
/// per <see cref="ReadAsync(Memory{byte}, CancellationToken)"/> call, then returns 0 (EOF)
|
||||||
|
/// once exhausted. Exercises the partial-read accumulation loop and the truncation → desync
|
||||||
|
/// path that a single-shot <see cref="MemoryStream"/> never reaches.
|
||||||
|
/// </summary>
|
||||||
|
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<int> ReadAsync(Memory<byte> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exercises <see cref="ModbusRtuOverTcpTransport"/>'s send/receive orchestration, single-flight,
|
||||||
|
/// and per-op deadline against an in-memory duplex fake injected through the <c>ForTest</c> 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 <c>stall</c> is set.
|
||||||
|
/// </summary>
|
||||||
|
[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<ModbusException>(
|
||||||
|
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<byte>(), stall: true);
|
||||||
|
await using var transport = ModbusRtuOverTcpTransport.ForTest(fake, TimeSpan.FromMilliseconds(200));
|
||||||
|
await Should.ThrowAsync<ModbusTransportDesyncException>(
|
||||||
|
transport.SendAsync(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 },
|
||||||
|
TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-memory duplex <see cref="Stream"/> test double: records everything written and replays
|
||||||
|
/// <c>respondBytes</c> on read. When <c>stall</c> is set the read blocks until its cancellation
|
||||||
|
/// token fires (simulating a frozen gateway) so the transport's per-op deadline is exercised.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gets the bytes the transport wrote to this stream (the request ADU).</summary>
|
||||||
|
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<int> ReadAsync(Memory<byte> 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<byte> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pins the socket-lifecycle surface extracted from <see cref="ModbusTcpTransport"/> into the
|
||||||
|
/// reusable <see cref="ModbusSocketLifecycle"/> (Task 3). The clamp helper moved with it, and a
|
||||||
|
/// connect to a dead port must still surface the underlying socket failure.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class ModbusSocketLifecycleTests
|
||||||
|
{
|
||||||
|
/// <summary>Verifies the whole-seconds clamp rounds sub-second/negative durations up to a minimum of one.</summary>
|
||||||
|
/// <param name="seconds">The input duration in seconds.</param>
|
||||||
|
/// <param name="expected">The expected clamped value in whole seconds.</param>
|
||||||
|
[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);
|
||||||
|
|
||||||
|
/// <summary>Verifies a connect to a dead port surfaces the underlying socket failure.</summary>
|
||||||
|
[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<System.Net.Sockets.SocketException>(
|
||||||
|
life.ConnectAsync(TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ModbusTcpTransport>();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RtuOverTcp_mode_builds_rtu_transport()
|
||||||
|
=> ModbusTransportFactory.Create(new ModbusDriverOptions { Transport = ModbusTransportMode.RtuOverTcp })
|
||||||
|
.ShouldBeOfType<ModbusRtuOverTcpTransport>();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Default_options_build_tcp_transport()
|
||||||
|
=> ModbusTransportFactory.Create(new ModbusDriverOptions())
|
||||||
|
.ShouldBeOfType<ModbusTcpTransport>();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unknown_transport_mode_throws()
|
||||||
|
=> Should.Throw<ArgumentOutOfRangeException>(
|
||||||
|
() => ModbusTransportFactory.Create(new ModbusDriverOptions { Transport = (ModbusTransportMode)999 }));
|
||||||
|
}
|
||||||
@@ -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<ModbusTransportMode>().ShouldBe(["Tcp", "RtuOverTcp"]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Confirms the driver's default transport-factory closure — and, by extension, the
|
||||||
|
/// probe's transport construction — routes through <see cref="ModbusTransportFactory"/>
|
||||||
|
/// instead of hardcoding <see cref="ModbusTcpTransport"/>, so an <c>RtuOverTcp</c>-authored
|
||||||
|
/// config actually gets RTU framing rather than silently running as TCP/MBAP.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class ModbusTransportWiringTests
|
||||||
|
{
|
||||||
|
private static IModbusTransport BuildDefaultTransport(ModbusDriverOptions opts)
|
||||||
|
{
|
||||||
|
using var driver = new ModbusDriver(opts, "wire-test");
|
||||||
|
var factory = (Func<ModbusDriverOptions, IModbusTransport>)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<ModbusRtuOverTcpTransport>();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Default_closure_builds_tcp_transport_for_Tcp()
|
||||||
|
=> BuildDefaultTransport(new ModbusDriverOptions())
|
||||||
|
.ShouldBeOfType<ModbusTcpTransport>();
|
||||||
|
}
|
||||||
@@ -61,4 +61,15 @@ public sealed class ModbusDriverFormModelTests
|
|||||||
json.ShouldContain("\"family\":\"MELSEC\""); // enum-as-name (not a number), camelCase key
|
json.ShouldContain("\"family\":\"MELSEC\""); // enum-as-name (not a number), camelCase key
|
||||||
json.ShouldNotContain("\"family\":0", Case.Sensitive); // never the numeric enum form
|
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<ModbusDriverOptions>(json, JsonOpts)!);
|
||||||
|
back.Transport.ShouldBe(ModbusTransportMode.RtuOverTcp);
|
||||||
|
json.ShouldContain("\"transport\":\"RtuOverTcp\""); // name string, never a number
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user