diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/IS7Plc.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/IS7Plc.cs
new file mode 100644
index 00000000..083b584b
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/IS7Plc.cs
@@ -0,0 +1,149 @@
+using S7.Net;
+using S7NetDataType = global::S7.Net.DataType;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
+
+///
+/// Wire-layer abstraction over a single S7comm / ISO-on-TCP connection — the exact subset of
+/// members uses. One instance per PLC, reused across
+/// reads / writes / probes and serialized by the driver's single connection gate.
+///
+///
+/// The seam exists so can dispose a dead handle and
+/// re-open a fresh one after a transient PLC reboot / network blip — the reconnect path
+/// (STAB-1) — and so that path is unit-testable via a fake without a
+/// live PLC (S7.Net ships no in-process fake and is sealed ). Mirrors
+/// the TwinCAT driver's ITwinCATClient / ITwinCATClientFactory pattern.
+///
+public interface IS7Plc : IDisposable
+{
+ /// True when the underlying TCP connection is established.
+ bool IsConnected { get; }
+
+ /// Opens the S7 connection (ISO-on-TCP handshake). Honours the ambient read/write timeouts baked in at construction.
+ /// Cancellation token for the connect attempt.
+ /// A task that completes when the connection is established.
+ Task OpenAsync(CancellationToken cancellationToken);
+
+ /// Closes the S7 connection. Best-effort and idempotent.
+ void Close();
+
+ /// Reads a scalar value by S7 address string (e.g. DB1.DBW0 ); the boxed type follows the size suffix.
+ /// The S7 address string.
+ /// Cancellation token for the read.
+ /// The boxed value, or null when the read produced no data.
+ Task ReadAsync(string address, CancellationToken cancellationToken);
+
+ /// Reads a contiguous byte block from an area — a single wire round-trip S7.Net chunks past the PDU limit.
+ /// The S7 area (DataBlock / Memory / Input / Output / Timer / Counter).
+ /// Data-block number (0 for non-DB areas).
+ /// Start byte offset (or timer/counter number for those areas).
+ /// Byte count to read.
+ /// Cancellation token for the read.
+ /// The raw byte block.
+ Task ReadBytesAsync(S7NetDataType area, int db, int startByteAdr, int count, CancellationToken cancellationToken);
+
+ /// Writes a scalar value by S7 address string; the value must be the unsigned wire type for the address size.
+ /// The S7 address string.
+ /// The wire-typed value to write.
+ /// Cancellation token for the write.
+ /// A task that completes when the write is acknowledged.
+ Task WriteAsync(string address, object value, CancellationToken cancellationToken);
+
+ /// Writes a contiguous byte block to an area (buffer-based wide/structured write).
+ /// The S7 area.
+ /// Data-block number (0 for non-DB areas).
+ /// Start byte offset.
+ /// The byte block to write.
+ /// Cancellation token for the write.
+ /// A task that completes when the write is acknowledged.
+ Task WriteBytesAsync(S7NetDataType area, int db, int startByteAdr, byte[] value, CancellationToken cancellationToken);
+
+ /// Reads the CPU status (Run/Stop) — the light-weight probe PDU. The result is discarded; success means the PLC answered.
+ /// Cancellation token for the probe.
+ /// A task that completes when the CPU status PDU round-trips.
+ Task ReadStatusAsync(CancellationToken cancellationToken);
+}
+
+/// Factory for s — one connection per PLC. Tests swap in a fake.
+public interface IS7PlcFactory
+{
+ /// Creates a new, not-yet-opened for the given target.
+ /// The CPU family (drives the ISO-TSAP slot byte).
+ /// PLC IP address or hostname.
+ /// TCP port (102 on every S7 model).
+ /// Hardware rack number.
+ /// CPU slot.
+ /// Connect + per-operation timeout, applied to the underlying read/write timeouts.
+ /// A new, not-yet-opened .
+ IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout);
+}
+
+///
+/// Default — a thin adapter over a real S7.Net . Bakes the
+/// read/write timeouts into the connection at construction so
+/// 's reopen reproduces the exact same configuration.
+///
+internal sealed class S7PlcAdapter : IS7Plc
+{
+ private readonly Plc _plc;
+
+ /// Initializes a new instance of the class.
+ /// The CPU family.
+ /// PLC IP address or hostname.
+ /// TCP port.
+ /// Hardware rack number.
+ /// CPU slot.
+ /// Connect + per-operation timeout.
+ public S7PlcAdapter(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout)
+ {
+ _plc = new Plc(S7CpuTypeMap.ToS7Net(cpuType), host, port, rack, slot);
+ // S7netplus writes timeouts into the underlying TcpClient via ReadTimeout / WriteTimeout
+ // (milliseconds). Set before OpenAsync so the handshake itself honours the bound — matches
+ // the original inline construction in S7Driver.InitializeAsync.
+ var ms = (int)timeout.TotalMilliseconds;
+ _plc.ReadTimeout = ms;
+ _plc.WriteTimeout = ms;
+ }
+
+ ///
+ public bool IsConnected => _plc.IsConnected;
+
+ ///
+ public Task OpenAsync(CancellationToken cancellationToken) => _plc.OpenAsync(cancellationToken);
+
+ ///
+ public void Close() => _plc.Close();
+
+ ///
+ public Task ReadAsync(string address, CancellationToken cancellationToken) =>
+ _plc.ReadAsync(address, cancellationToken)!;
+
+ ///
+ public Task ReadBytesAsync(S7NetDataType area, int db, int startByteAdr, int count, CancellationToken cancellationToken) =>
+ _plc.ReadBytesAsync(area, db, startByteAdr, count, cancellationToken);
+
+ ///
+ public Task WriteAsync(string address, object value, CancellationToken cancellationToken) =>
+ _plc.WriteAsync(address, value, cancellationToken);
+
+ ///
+ public Task WriteBytesAsync(S7NetDataType area, int db, int startByteAdr, byte[] value, CancellationToken cancellationToken) =>
+ _plc.WriteBytesAsync(area, db, startByteAdr, value, cancellationToken);
+
+ ///
+ public async Task ReadStatusAsync(CancellationToken cancellationToken) =>
+ _ = await _plc.ReadStatusAsync(cancellationToken).ConfigureAwait(false);
+
+ ///
+ // Plc implements IDisposable explicitly, so cast to invoke it; disposal tears down the TcpClient.
+ public void Dispose() => ((IDisposable)_plc).Dispose();
+}
+
+/// Default producing real S7.Net-backed s.
+internal sealed class S7PlcFactory : IS7PlcFactory
+{
+ ///
+ public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout) =>
+ new S7PlcAdapter(cpuType, host, port, rack, slot, timeout);
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
index 20b95983..fca4b08d 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
@@ -34,16 +34,21 @@ public sealed class S7Driver
{
private readonly string _driverInstanceId;
private readonly ILogger _logger;
+ private readonly IS7PlcFactory _plcFactory;
/// Initializes a new instance of the class.
/// Driver configuration (the constructor-supplied fallback used when
/// Initialize/Reinitialize receive an empty config body).
/// Unique driver instance identifier.
+ /// Optional S7 connection factory; defaults to
+ /// (real S7.Net). Tests inject a fake to exercise the reconnect path without a live PLC.
/// Optional logger; a null logger is used when not supplied.
- public S7Driver(S7DriverOptions options, string driverInstanceId, ILogger? logger = null)
+ public S7Driver(S7DriverOptions options, string driverInstanceId,
+ IS7PlcFactory? plcFactory = null, ILogger? logger = null)
{
_options = options;
_driverInstanceId = driverInstanceId;
+ _plcFactory = plcFactory ?? new S7PlcFactory();
_logger = logger ?? NullLogger.Instance;
_resolver = new EquipmentTagRefResolver(
r => _tagsByName.TryGetValue(r, out var t) ? t : null,
@@ -115,11 +120,30 @@ public sealed class S7Driver
internal SemaphoreSlim Gate => _gate;
///
- /// Active S7.Net PLC connection. Null until returns; null
- /// after . Read-only outside this class; PR 64's Read/Write
- /// will take the before touching it.
+ /// Active S7 PLC connection. Null until returns; null
+ /// after . Also (re)opened lazily by
+ /// after a transient drop. Read-only outside this class;
+ /// Read/Write/probe take the before touching it.
///
- internal Plc? Plc { get; private set; }
+ internal IS7Plc? Plc { get; private set; }
+
+ ///
+ /// True once has parsed the tag table + opened the first
+ /// connection, false after . Distinct from Plc != null
+ /// because the reconnect path can leave transiently null / dead while the
+ /// driver is still initialized — a data call then repairs the connection rather than
+ /// throwing "not initialized". Guarded by .
+ ///
+ private bool _initialized;
+
+ ///
+ /// Set when a connection-fatal fault (socket drop / )
+ /// is observed on a read/write, so the next disposes the
+ /// dead handle and re-opens. Kept as a flag (rather than nulling inline) so
+ /// the dead handle is disposed exactly once, in . Guarded
+ /// by .
+ ///
+ private bool _plcDead;
private DriverHealth _health = new(DriverState.Unknown, null, null);
private bool _disposed;
@@ -153,18 +177,26 @@ public sealed class S7Driver
// the single grep target / future seam should a type ever need re-gating at init.
RejectUnsupportedTagDataTypes();
- var plc = new Plc(S7CpuTypeMap.ToS7Net(_options.CpuType), _options.Host, _options.Port, _options.Rack, _options.Slot);
- // S7netplus writes timeouts into the underlying TcpClient via Plc.WriteTimeout /
- // Plc.ReadTimeout (milliseconds). Set before OpenAsync so the handshake itself
- // honours the bound.
- plc.WriteTimeout = (int)_options.Timeout.TotalMilliseconds;
- plc.ReadTimeout = (int)_options.Timeout.TotalMilliseconds;
-
- using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
- cts.CancelAfter(_options.Timeout);
- await plc.OpenAsync(cts.Token).ConfigureAwait(false);
+ // Build the connection through the factory (the same seam EnsureConnectedAsync's reopen
+ // uses) so the read/write timeouts are baked in identically on connect and reconnect.
+ var plc = _plcFactory.Create(
+ _options.CpuType, _options.Host, _options.Port, _options.Rack, _options.Slot, _options.Timeout);
+ try
+ {
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ cts.CancelAfter(_options.Timeout);
+ await plc.OpenAsync(cts.Token).ConfigureAwait(false);
+ }
+ catch
+ {
+ // Dispose the partially-constructed connection so a failed init doesn't leak it
+ // before the outer catch nulls Plc (Plc isn't assigned yet, so it can't).
+ try { plc.Dispose(); } catch { }
+ throw;
+ }
Plc = plc;
+ _plcDead = false;
// Parse every tag's address once at init so config typos fail fast here instead
// of surfacing as BadInternalError on every Read against the bad tag. The parser
@@ -179,6 +211,7 @@ public sealed class S7Driver
_parsedByName[t.Name] = parsed;
}
+ _initialized = true;
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
_logger.LogInformation("S7Driver connected. Driver={DriverInstanceId} Host={Host} CPU={CpuType} Tags={TagCount}",
_driverInstanceId, _options.Host, _options.CpuType, _options.Tags.Count);
@@ -199,9 +232,11 @@ public sealed class S7Driver
catch (Exception ex)
{
// Clean up a partially-constructed Plc so a retry from the caller doesn't leak
- // the TcpClient. S7netplus's Close() is best-effort and idempotent.
- try { Plc?.Close(); } catch { }
+ // the TcpClient. Dispose is best-effort and idempotent.
+ try { Plc?.Dispose(); } catch { }
Plc = null;
+ _initialized = false;
+ _plcDead = false;
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
_logger.LogError(ex, "S7Driver connect failed. Driver={DriverInstanceId} Host={Host}", _driverInstanceId, _options.Host);
throw;
@@ -257,7 +292,9 @@ public sealed class S7Driver
foreach (var state in subscriptions)
state.Cts.Dispose();
- try { Plc?.Close(); } catch { /* best-effort — tearing down anyway */ }
+ _initialized = false;
+ _plcDead = false;
+ try { Plc?.Dispose(); } catch { /* best-effort — tearing down anyway */ }
Plc = null;
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
}
@@ -429,17 +466,34 @@ public sealed class S7Driver
public async Task> ReadAsync(
IReadOnlyList fullReferences, CancellationToken cancellationToken)
{
- // Validate the list before RequirePlc() so a null argument produces an
+ // Validate the list before the init check so a null argument produces an
// ArgumentNullException (consistent with DiscoverAsync) rather than an
// InvalidOperationException from the not-initialized check.
ArgumentNullException.ThrowIfNull(fullReferences);
- var plc = RequirePlc();
+ RequireInitialized();
var now = DateTime.UtcNow;
var results = new DataValueSnapshot[fullReferences.Count];
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
+ // (Re)open the connection under the gate. A failed (re)connect degrades the whole
+ // batch to BadCommunicationError rather than throwing — the poll loop / next read
+ // retries, and EnsureConnectedAsync repairs on the following call.
+ IS7Plc plc;
+ try
+ {
+ plc = await EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (Exception ex)
+ {
+ for (var i = 0; i < fullReferences.Count; i++)
+ results[i] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
+ _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
+ return results;
+ }
+
for (var i = 0; i < fullReferences.Count; i++)
{
var name = fullReferences[i];
@@ -472,12 +526,18 @@ public sealed class S7Driver
catch (PlcException pex)
{
// A genuine device-layer fault (CPU error, hardware fault) — transient
- // enough to keep retrying; report BadDeviceFailure and degrade health.
+ // enough to keep retrying; report BadDeviceFailure and degrade health. A
+ // connection-fatal PlcException (socket drop / ConnectionError) additionally
+ // marks the handle dead so the next read reopens it.
+ MarkConnectionDeadIfFatal(pex);
results[i] = new DataValueSnapshot(null, StatusBadDeviceFailure, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, pex.Message);
}
catch (Exception ex)
{
+ // Socket / timeout / transport fault — mark the handle dead when it's a
+ // connection loss so EnsureConnectedAsync reopens on the next call.
+ MarkConnectionDeadIfFatal(ex);
results[i] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
}
@@ -487,7 +547,7 @@ public sealed class S7Driver
return results;
}
- private async Task ReadOneAsync(Plc plc, S7TagDefinition tag, CancellationToken ct)
+ private async Task ReadOneAsync(IS7Plc plc, S7TagDefinition tag, CancellationToken ct)
{
// Authored tags pre-parse their address at init (_parsedByName); an equipment-tag ref
// (resolved transiently by _resolver) has no _parsedByName entry, so parse its address
@@ -533,7 +593,7 @@ public sealed class S7Driver
/// to the pure decode loop. Timer/Counter areas are
/// already rejected at init, so only DB/M/I/Q reach here.
///
- private async Task ReadArrayAsync(Plc plc, S7TagDefinition tag, S7ParsedAddress addr, CancellationToken ct)
+ private async Task ReadArrayAsync(IS7Plc plc, S7TagDefinition tag, S7ParsedAddress addr, CancellationToken ct)
{
var count = tag.ArrayCount!.Value;
var elementBytes = ElementByteSize(addr.Size);
@@ -583,7 +643,7 @@ public sealed class S7Driver
/// PLC round-trip), then hands the raw block to the pure
/// decode. Mirrors 's shape.
///
- private async Task ReadScalarBlockAsync(Plc plc, S7TagDefinition tag, S7ParsedAddress addr, CancellationToken ct)
+ private async Task ReadScalarBlockAsync(IS7Plc plc, S7TagDefinition tag, S7ParsedAddress addr, CancellationToken ct)
{
var width = ScalarByteWidth(tag, addr);
// For DB/M/I/Q, addr.ByteOffset is the start BYTE and `width` the byte count. For
@@ -921,15 +981,31 @@ public sealed class S7Driver
public async Task> WriteAsync(
IReadOnlyList writes, CancellationToken cancellationToken)
{
- // Same as ReadAsync — validate before RequirePlc() so a null argument is a
+ // Same as ReadAsync — validate before the init check so a null argument is a
// typed argument error, not the "not initialized" surface.
ArgumentNullException.ThrowIfNull(writes);
- var plc = RequirePlc();
+ RequireInitialized();
var results = new WriteResult[writes.Count];
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
+ // (Re)open under the gate; a failed (re)connect degrades every write to
+ // BadCommunicationError rather than throwing.
+ IS7Plc plc;
+ try
+ {
+ plc = await EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (Exception ex)
+ {
+ for (var i = 0; i < writes.Count; i++)
+ results[i] = new WriteResult(StatusBadCommunicationError);
+ _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
+ return results;
+ }
+
for (var i = 0; i < writes.Count; i++)
{
var w = writes[i];
@@ -988,14 +1064,18 @@ public sealed class S7Driver
{
// Genuine device-layer fault — degrade health so a PLC-down-during-writes
// scenario is visible to the operator (previously health was never updated
- // on write failure).
+ // on write failure). A connection-fatal PlcException also marks the handle
+ // dead so the next call reopens.
+ MarkConnectionDeadIfFatal(pex);
results[i] = new WriteResult(StatusBadDeviceFailure);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, pex.Message);
}
catch (Exception ex)
{
// Socket/timeout/conversion failure. Map to BadCommunicationError (not
- // BadInternalError) for transport faults; degrade health.
+ // BadInternalError) for transport faults; degrade health, and mark the handle
+ // dead when it's a connection loss.
+ MarkConnectionDeadIfFatal(ex);
results[i] = new WriteResult(StatusBadCommunicationError);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
}
@@ -1005,7 +1085,7 @@ public sealed class S7Driver
return results;
}
- private async Task WriteOneAsync(Plc plc, S7TagDefinition tag, object? value, CancellationToken ct)
+ private async Task WriteOneAsync(IS7Plc plc, S7TagDefinition tag, object? value, CancellationToken ct)
{
// Defence-in-depth guard: authored array tags are rejected at init
// (RejectUnsupportedTagConfigs guard-e), but a transient equipment-tag ref resolved by
@@ -1047,7 +1127,7 @@ public sealed class S7Driver
/// The pure produces the big-endian bytes; this method
/// owns only the network I/O (mirrors ).
///
- private async Task WriteScalarBlockAsync(Plc plc, S7TagDefinition tag, S7ParsedAddress addr, object? value, CancellationToken ct)
+ private async Task WriteScalarBlockAsync(IS7Plc plc, S7TagDefinition tag, S7ParsedAddress addr, object? value, CancellationToken ct)
{
var bytes = EncodeScalarBlock(tag, value);
await plc.WriteBytesAsync(ToS7NetArea(addr.Area), addr.DbNumber, addr.ByteOffset, bytes, ct)
@@ -1085,8 +1165,91 @@ public sealed class S7Driver
_ => throw new InvalidOperationException($"Unknown S7DataType {dataType}"),
};
- private Plc RequirePlc() =>
- Plc ?? throw new InvalidOperationException("S7Driver not initialized");
+ private void RequireInitialized()
+ {
+ if (!_initialized) throw new InvalidOperationException("S7Driver not initialized");
+ }
+
+ ///
+ /// Returns a live S7 connection, lazily disposing a dead handle and re-opening a fresh one
+ /// when the previous connection dropped (STAB-1 reconnect path). The fast path returns the
+ /// current when it is still connected and not marked dead; otherwise it
+ /// builds + opens a new connection via with the same
+ /// timeout-linked CTS the initial connect uses.
+ ///
+ ///
+ /// MUST be called while holding — it mutates /
+ /// and reuses the single-connection-per-PLC invariant the gate
+ /// enforces. The read / write / probe paths all take the gate before calling it.
+ ///
+ /// Cancellation token for the (re)connect attempt.
+ /// A live .
+ private async Task EnsureConnectedAsync(CancellationToken ct)
+ {
+ // Fast path — the current connection is live and not flagged dead.
+ if (!_plcDead && Plc is { IsConnected: true } live) return live;
+
+ // Dispose the stale / dead handle before making a fresh one (exactly once, here).
+ if (Plc is not null)
+ {
+ try { Plc.Dispose(); } catch { /* best-effort */ }
+ Plc = null;
+ }
+ _plcDead = false;
+
+ var plc = _plcFactory.Create(
+ _options.CpuType, _options.Host, _options.Port, _options.Rack, _options.Slot, _options.Timeout);
+ try
+ {
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ cts.CancelAfter(_options.Timeout);
+ await plc.OpenAsync(cts.Token).ConfigureAwait(false);
+ }
+ catch
+ {
+ try { plc.Dispose(); } catch { /* best-effort */ }
+ throw;
+ }
+
+ Plc = plc;
+ _logger.LogInformation("S7Driver reconnected. Driver={DriverInstanceId} Host={Host} CPU={CpuType}",
+ _driverInstanceId, _options.Host, _options.CpuType);
+ return plc;
+ }
+
+ ///
+ /// Flags the current connection dead when is a connection-level loss,
+ /// so the next reopens. A no-op for data-address / type
+ /// faults — those don't warrant tearing down a healthy socket.
+ ///
+ /// The exception observed on a read / write.
+ private void MarkConnectionDeadIfFatal(Exception ex)
+ {
+ if (IsS7ConnectionFatal(ex)) _plcDead = true;
+ }
+
+ ///
+ /// True when (or any inner exception) is a socket-level / connection
+ /// loss that a reopen can repair — a /
+ /// / , or an S7.Net
+ /// carrying . A
+ /// data-address / type error (S7.Net's /
+ /// for a bad address, PUT/GET-denied, etc.) is deliberately
+ /// NOT treated as fatal — reopening wouldn't help and would churn a healthy connection.
+ ///
+ /// The exception to classify.
+ /// true when a reconnect is warranted.
+ private static bool IsS7ConnectionFatal(Exception ex)
+ {
+ for (Exception? e = ex; e is not null; e = e.InnerException)
+ {
+ if (e is System.Net.Sockets.SocketException or System.IO.IOException or ObjectDisposedException)
+ return true;
+ if (e is PlcException { ErrorCode: ErrorCode.ConnectionError })
+ return true;
+ }
+ return false;
+ }
///
/// Detects an S7 PUT/GET-disabled / access-protection fault inside an S7.Net
@@ -1363,13 +1526,14 @@ public sealed class S7Driver
using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
probeCts.CancelAfter(_options.Probe.Timeout);
- var plc = Plc;
- if (plc is null) throw new InvalidOperationException("Plc dropped during probe");
-
await _gate.WaitAsync(probeCts.Token).ConfigureAwait(false);
try
{
- _ = await plc.ReadStatusAsync(probeCts.Token).ConfigureAwait(false);
+ // EnsureConnectedAsync doubles as the reconnect backstop: if a read/write
+ // marked the handle dead (or the socket dropped between ticks), the probe
+ // reopens it here so recovery doesn't wait for the next data call.
+ var plc = await EnsureConnectedAsync(probeCts.Token).ConfigureAwait(false);
+ await plc.ReadStatusAsync(probeCts.Token).ConfigureAwait(false);
success = true;
}
finally { _gate.Release(); }
@@ -1460,7 +1624,9 @@ public sealed class S7Driver
try { state.Cts.Dispose(); } catch { }
}
- try { Plc?.Close(); } catch { /* best-effort — tearing down anyway */ }
+ _initialized = false;
+ _plcDead = false;
+ try { Plc?.Dispose(); } catch { /* best-effort — tearing down anyway */ }
Plc = null;
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500ReconnectTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500ReconnectTests.cs
new file mode 100644
index 00000000..0f188b83
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500ReconnectTests.cs
@@ -0,0 +1,95 @@
+using System.Diagnostics;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
+
+///
+/// Live end-to-end proof of the STAB-1 reconnect path against the real python-snap7
+/// S7-1500 profile: a running survives a full PLC bounce (container
+/// stop/start) and resumes reading without a redeploy — the exact failure the
+/// arch-review flagged (a transient drop permanently killed the driver until redeploy).
+///
+///
+///
+/// Double-gated so it never runs destructively by accident:
+///
+/// — the sim must be reachable; and
+/// S7_RECONNECT_BOUNCE_CMD — the shell command that bounces the PLC/container
+/// (e.g. ssh dohertj2@10.100.0.35 docker restart otopcua-python-snap7-s7_1500 ).
+/// Absent ⇒ the test skips, because bouncing the shared sim would disrupt other suites.
+///
+/// There is no per-test container hook in the harness, so the bounce is delegated to this
+/// operator-supplied command — the automation the plan called a "manual recipe", captured in
+/// code so it is reproducible.
+///
+///
+[Collection(Snap7ServerCollection.Name)]
+[Trait("Category", "Integration")]
+[Trait("Category", "Reconnect")]
+[Trait("Device", "S7_1500")]
+public sealed class S7_1500ReconnectTests(Snap7ServerFixture sim)
+{
+ private const string BounceCmdEnvVar = "S7_RECONNECT_BOUNCE_CMD";
+
+ ///
+ /// Reads a seeded tag Good, bounces the PLC out-of-band, observes the read fail (Degraded),
+ /// then asserts the driver reopens on its own and the tag returns to Good — no redeploy.
+ ///
+ [Fact]
+ public async Task Driver_recovers_reads_after_a_live_PLC_bounce_without_redeploy()
+ {
+ if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
+ var bounceCmd = Environment.GetEnvironmentVariable(BounceCmdEnvVar);
+ if (string.IsNullOrWhiteSpace(bounceCmd))
+ Assert.Skip($"Set {BounceCmdEnvVar} to the command that bounces the S7 sim to run this destructive test.");
+
+ var ct = TestContext.Current.CancellationToken;
+ var options = S7_1500Profile.BuildOptions(sim.Host, sim.Port);
+ await using var drv = new S7Driver(options, driverInstanceId: "s7-reconnect-live");
+ await drv.InitializeAsync("{}", ct);
+
+ // Baseline — a Good read against the live sim.
+ (await drv.ReadAsync([S7_1500Profile.ProbeTag], ct))[0].StatusCode.ShouldBe(0u);
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+
+ // Bounce the PLC out-of-band (stop → start), then confirm the driver both NOTICES the drop
+ // (at least one non-Good read) and RECOVERS to Good on its own within a bounded window.
+ await RunBounceCommandAsync(bounceCmd!, ct);
+
+ var sawOutage = false;
+ var recovered = false;
+ var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(60);
+ while (DateTime.UtcNow < deadline)
+ {
+ var status = (await drv.ReadAsync([S7_1500Profile.ProbeTag], ct))[0].StatusCode;
+ if (status != 0u) sawOutage = true;
+ else if (sawOutage) { recovered = true; break; }
+ await Task.Delay(TimeSpan.FromMilliseconds(500), ct);
+ }
+
+ sawOutage.ShouldBeTrue("the driver should have observed the PLC outage as a failed read");
+ recovered.ShouldBeTrue("the driver should have reopened the connection and resumed Good reads without a redeploy");
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ }
+
+ private static async Task RunBounceCommandAsync(string command, CancellationToken ct)
+ {
+ // Run through the login shell so an SSH/docker one-liner in the env var works verbatim.
+ using var proc = new Process
+ {
+ StartInfo = new ProcessStartInfo("/bin/sh", $"-c \"{command.Replace("\"", "\\\"")}\"")
+ {
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ },
+ };
+ proc.Start();
+ await proc.WaitForExitAsync(ct);
+ proc.ExitCode.ShouldBe(0, $"bounce command failed: {await proc.StandardError.ReadToEndAsync(ct)}");
+ // Give the container a moment to release the port before the first post-bounce read.
+ await Task.Delay(TimeSpan.FromSeconds(1), ct);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs
new file mode 100644
index 00000000..e4195df6
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs
@@ -0,0 +1,213 @@
+using S7.Net;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using S7NetDataType = global::S7.Net.DataType;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
+
+///
+/// Unit tests for the S7 reconnect path (STAB-1). The seam lets
+/// these exercise the lazy reconnect logic — dispose-dead-handle +
+/// re-open on the next data call — without a live PLC, which was previously impossible (the
+/// driver new -ed a concrete S7.Net.Plc inline and had no reopen path at all).
+///
+[Trait("Category", "Unit")]
+public sealed class S7DriverReconnectTests
+{
+ private static S7DriverOptions Options() => new()
+ {
+ Host = "192.0.2.1",
+ Timeout = TimeSpan.FromMilliseconds(250),
+ // Probe off — these tests drive reconnect through the read path deterministically; a
+ // background probe would race the created-connection assertions.
+ Probe = new S7ProbeOptions { Enabled = false },
+ Tags = [new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16, Writable: false)],
+ };
+
+ ///
+ /// A connection-fatal fault on a read marks the handle dead; the NEXT read disposes it and
+ /// opens a fresh connection, and the tag recovers to Good with health Degraded→Healthy.
+ ///
+ [Fact]
+ public async Task Read_reopens_a_fresh_connection_after_a_connection_fatal_fault()
+ {
+ var factory = new FakeS7PlcFactory();
+ using var drv = new S7Driver(Options(), "s7-reconnect", factory);
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ factory.Created.Count.ShouldBe(1);
+
+ // Read #1 — good value, Healthy.
+ var r1 = await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken);
+ r1[0].StatusCode.ShouldBe(0u);
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+
+ // The live socket drops on its next read. IsConnected deliberately stays true (a stale
+ // socket that hasn't noticed the drop) so the ONLY trigger for reopen is the driver's
+ // own dead-handle flag set by the connection-fatal classification.
+ factory.Created[0].NextReadThrows = new PlcException(ErrorCode.ConnectionError, "socket dropped");
+
+ // Read #2 — connection-fatal fault → Degraded, handle marked dead, but no reopen yet.
+ var r2 = await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken);
+ r2[0].StatusCode.ShouldNotBe(0u);
+ drv.GetHealth().State.ShouldBe(DriverState.Degraded);
+ factory.Created.Count.ShouldBe(1);
+
+ // Read #3 — EnsureConnectedAsync disposes the dead handle and opens a SECOND connection,
+ // which reads Good; health recovers to Healthy.
+ var r3 = await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken);
+ factory.Created.Count.ShouldBe(2);
+ r3[0].StatusCode.ShouldBe(0u);
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ factory.Created[0].Disposed.ShouldBeTrue();
+ factory.Created[1].Disposed.ShouldBeFalse();
+ }
+
+ ///
+ /// A protocol / data-address error (S7.Net ) is NOT a
+ /// connection loss — the driver keeps the same connection and does not churn a reopen.
+ ///
+ [Fact]
+ public async Task Read_does_not_reopen_on_a_data_address_error()
+ {
+ var factory = new FakeS7PlcFactory();
+ using var drv = new S7Driver(Options(), "s7-dataerr", factory);
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+
+ // A data error — not a socket drop.
+ factory.Created[0].NextReadThrows = new PlcException(ErrorCode.ReadData, "bad address");
+ var bad = await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken);
+ bad[0].StatusCode.ShouldNotBe(0u);
+
+ // Next read reuses the SAME connection — no reopen for a data error.
+ var ok = await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken);
+ ok[0].StatusCode.ShouldBe(0u);
+ factory.Created.Count.ShouldBe(1);
+ }
+
+ ///
+ /// A raw socket exception (no S7.Net wrapper) is also classified connection-fatal and
+ /// triggers a reopen — the driver doesn't depend on every drop arriving as a
+ /// .
+ ///
+ [Fact]
+ public async Task Read_reopens_after_a_raw_socket_exception()
+ {
+ var factory = new FakeS7PlcFactory();
+ using var drv = new S7Driver(Options(), "s7-socket", factory);
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+
+ factory.Created[0].NextReadThrows = new System.Net.Sockets.SocketException();
+ await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken);
+ drv.GetHealth().State.ShouldBe(DriverState.Degraded);
+
+ var recovered = await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken);
+ recovered[0].StatusCode.ShouldBe(0u);
+ factory.Created.Count.ShouldBe(2);
+ }
+
+ ///
+ /// When the reopen itself fails (PLC still down), the batch degrades to a communication
+ /// error rather than throwing, and a later successful reopen recovers the tag.
+ ///
+ [Fact]
+ public async Task Read_degrades_when_reopen_fails_then_recovers()
+ {
+ var factory = new FakeS7PlcFactory();
+ using var drv = new S7Driver(Options(), "s7-reopen-fail", factory);
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+
+ // Drop the live socket, and make the NEXT-created connection fail to open.
+ factory.Created[0].NextReadThrows = new PlcException(ErrorCode.ConnectionError, "dropped");
+ factory.NextOpenThrows = new PlcException(ErrorCode.ConnectionError, "still down");
+ await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken); // marks dead
+
+ // Reopen attempt #1 fails — whole batch is a comm error, no throw.
+ var down = await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken);
+ down[0].StatusCode.ShouldNotBe(0u);
+ drv.GetHealth().State.ShouldBe(DriverState.Degraded);
+
+ // PLC comes back; the next call reopens cleanly and the tag reads Good.
+ var up = await drv.ReadAsync(["W0"], TestContext.Current.CancellationToken);
+ up[0].StatusCode.ShouldBe(0u);
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ }
+
+ // ---- fakes ----
+
+ private sealed class FakeS7PlcFactory : IS7PlcFactory
+ {
+ /// Every connection handed out, in creation order.
+ public List Created { get; } = new();
+
+ /// When set, the NEXT d connection throws this on OpenAsync (once).
+ public Exception? NextOpenThrows { get; set; }
+
+ public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout)
+ {
+ var plc = new FakeS7Plc();
+ if (NextOpenThrows is { } ex)
+ {
+ plc.OpenThrowsOnce = ex;
+ NextOpenThrows = null;
+ }
+ Created.Add(plc);
+ return plc;
+ }
+ }
+
+ private sealed class FakeS7Plc : IS7Plc
+ {
+ public bool IsConnected { get; private set; }
+ public bool Disposed { get; private set; }
+
+ /// Thrown once by , then cleared.
+ public Exception? OpenThrowsOnce { get; set; }
+
+ /// Thrown once by the next , then cleared.
+ public Exception? NextReadThrows { get; set; }
+
+ /// Boxed value returned by a good read (UInt16 tag → ushort).
+ public object? ReadValue { get; set; } = (ushort)42;
+
+ public Task OpenAsync(CancellationToken cancellationToken)
+ {
+ if (OpenThrowsOnce is { } ex)
+ {
+ OpenThrowsOnce = null;
+ return Task.FromException(ex);
+ }
+ IsConnected = true;
+ return Task.CompletedTask;
+ }
+
+ public void Close() => IsConnected = false;
+
+ public Task ReadAsync(string address, CancellationToken cancellationToken)
+ {
+ if (NextReadThrows is { } ex)
+ {
+ NextReadThrows = null;
+ return Task.FromException(ex);
+ }
+ return Task.FromResult(ReadValue);
+ }
+
+ public Task ReadBytesAsync(S7NetDataType area, int db, int startByteAdr, int count, CancellationToken cancellationToken) =>
+ throw new NotSupportedException("FakeS7Plc: block reads not needed for the reconnect tests");
+
+ public Task WriteAsync(string address, object value, CancellationToken cancellationToken) =>
+ Task.CompletedTask;
+
+ public Task WriteBytesAsync(S7NetDataType area, int db, int startByteAdr, byte[] value, CancellationToken cancellationToken) =>
+ Task.CompletedTask;
+
+ public Task ReadStatusAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+
+ public void Dispose()
+ {
+ Disposed = true;
+ IsConnected = false;
+ }
+ }
+}