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 717da6ad..dcf238fe 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 @@ -1361,13 +1524,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(); } @@ -1458,7 +1622,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; + } + } +}