fix(s7): add lazy reconnect path (archreview STAB-1 / Critical 3)
The S7 Plc was opened once in InitializeAsync and never re-opened: a transient PLC reboot / network blip permanently killed the driver until redeploy. Introduce an IS7Plc / IS7PlcFactory seam (mirrors TwinCAT's ITwinCATClientFactory) and a lazy EnsureConnectedAsync that disposes a dead handle and re-opens a fresh connection on the next data call. Reads/writes mark the handle dead on a connection-fatal fault (socket drop / ErrorCode.ConnectionError) but NOT on a data-address error; the probe loop routes through EnsureConnectedAsync as a backstop. The seam also closes the S7 TEST-1 gap — the reconnect state machine is now unit- testable without a live PLC (S7.Net.Plc is sealed with no in-process fake). Verification: - 4 deterministic unit guards (fatal→reopen, data-error→no-reopen, raw socket→reopen, reopen-fail→degrade-then-recover); full S7 unit suite 234/234 green. - LIVE end-to-end proof against real python-snap7 (S7_1500ReconnectTests, double-gated on sim reachability + S7_RECONNECT_BOUNCE_CMD): bounced the container, driver observed the outage and resumed Good reads with NO redeploy. Baseline smoke 3/3 still green.
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
using S7.Net;
|
||||
using S7NetDataType = global::S7.Net.DataType;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-layer abstraction over a single S7comm / ISO-on-TCP connection — the exact subset of
|
||||
/// <see cref="Plc"/> members <see cref="S7Driver"/> uses. One instance per PLC, reused across
|
||||
/// reads / writes / probes and serialized by the driver's single connection gate.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The seam exists so <see cref="S7Driver.EnsureConnectedAsync"/> 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 <see cref="IS7PlcFactory"/> without a
|
||||
/// live PLC (S7.Net ships no in-process fake and <see cref="Plc"/> is <c>sealed</c>). Mirrors
|
||||
/// the TwinCAT driver's <c>ITwinCATClient</c> / <c>ITwinCATClientFactory</c> pattern.
|
||||
/// </remarks>
|
||||
public interface IS7Plc : IDisposable
|
||||
{
|
||||
/// <summary>True when the underlying TCP connection is established.</summary>
|
||||
bool IsConnected { get; }
|
||||
|
||||
/// <summary>Opens the S7 connection (ISO-on-TCP handshake). Honours the ambient read/write timeouts baked in at construction.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the connect attempt.</param>
|
||||
/// <returns>A task that completes when the connection is established.</returns>
|
||||
Task OpenAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Closes the S7 connection. Best-effort and idempotent.</summary>
|
||||
void Close();
|
||||
|
||||
/// <summary>Reads a scalar value by S7 address string (e.g. <c>DB1.DBW0</c>); the boxed type follows the size suffix.</summary>
|
||||
/// <param name="address">The S7 address string.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the read.</param>
|
||||
/// <returns>The boxed value, or <c>null</c> when the read produced no data.</returns>
|
||||
Task<object?> ReadAsync(string address, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Reads a contiguous byte block from an area — a single wire round-trip S7.Net chunks past the PDU limit.</summary>
|
||||
/// <param name="area">The S7 area (DataBlock / Memory / Input / Output / Timer / Counter).</param>
|
||||
/// <param name="db">Data-block number (0 for non-DB areas).</param>
|
||||
/// <param name="startByteAdr">Start byte offset (or timer/counter number for those areas).</param>
|
||||
/// <param name="count">Byte count to read.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the read.</param>
|
||||
/// <returns>The raw byte block.</returns>
|
||||
Task<byte[]> ReadBytesAsync(S7NetDataType area, int db, int startByteAdr, int count, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Writes a scalar value by S7 address string; the value must be the unsigned wire type for the address size.</summary>
|
||||
/// <param name="address">The S7 address string.</param>
|
||||
/// <param name="value">The wire-typed value to write.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the write.</param>
|
||||
/// <returns>A task that completes when the write is acknowledged.</returns>
|
||||
Task WriteAsync(string address, object value, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Writes a contiguous byte block to an area (buffer-based wide/structured write).</summary>
|
||||
/// <param name="area">The S7 area.</param>
|
||||
/// <param name="db">Data-block number (0 for non-DB areas).</param>
|
||||
/// <param name="startByteAdr">Start byte offset.</param>
|
||||
/// <param name="value">The byte block to write.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the write.</param>
|
||||
/// <returns>A task that completes when the write is acknowledged.</returns>
|
||||
Task WriteBytesAsync(S7NetDataType area, int db, int startByteAdr, byte[] value, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Reads the CPU status (Run/Stop) — the light-weight probe PDU. The result is discarded; success means the PLC answered.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the probe.</param>
|
||||
/// <returns>A task that completes when the CPU status PDU round-trips.</returns>
|
||||
Task ReadStatusAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Factory for <see cref="IS7Plc"/>s — one connection per PLC. Tests swap in a fake.</summary>
|
||||
public interface IS7PlcFactory
|
||||
{
|
||||
/// <summary>Creates a new, not-yet-opened <see cref="IS7Plc"/> for the given target.</summary>
|
||||
/// <param name="cpuType">The CPU family (drives the ISO-TSAP slot byte).</param>
|
||||
/// <param name="host">PLC IP address or hostname.</param>
|
||||
/// <param name="port">TCP port (102 on every S7 model).</param>
|
||||
/// <param name="rack">Hardware rack number.</param>
|
||||
/// <param name="slot">CPU slot.</param>
|
||||
/// <param name="timeout">Connect + per-operation timeout, applied to the underlying read/write timeouts.</param>
|
||||
/// <returns>A new, not-yet-opened <see cref="IS7Plc"/>.</returns>
|
||||
IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IS7Plc"/> — a thin adapter over a real S7.Net <see cref="Plc"/>. Bakes the
|
||||
/// read/write timeouts into the connection at construction so
|
||||
/// <see cref="S7Driver.EnsureConnectedAsync"/>'s reopen reproduces the exact same configuration.
|
||||
/// </summary>
|
||||
internal sealed class S7PlcAdapter : IS7Plc
|
||||
{
|
||||
private readonly Plc _plc;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="S7PlcAdapter"/> class.</summary>
|
||||
/// <param name="cpuType">The CPU family.</param>
|
||||
/// <param name="host">PLC IP address or hostname.</param>
|
||||
/// <param name="port">TCP port.</param>
|
||||
/// <param name="rack">Hardware rack number.</param>
|
||||
/// <param name="slot">CPU slot.</param>
|
||||
/// <param name="timeout">Connect + per-operation timeout.</param>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsConnected => _plc.IsConnected;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task OpenAsync(CancellationToken cancellationToken) => _plc.OpenAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Close() => _plc.Close();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<object?> ReadAsync(string address, CancellationToken cancellationToken) =>
|
||||
_plc.ReadAsync(address, cancellationToken)!;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<byte[]> ReadBytesAsync(S7NetDataType area, int db, int startByteAdr, int count, CancellationToken cancellationToken) =>
|
||||
_plc.ReadBytesAsync(area, db, startByteAdr, count, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task WriteAsync(string address, object value, CancellationToken cancellationToken) =>
|
||||
_plc.WriteAsync(address, value, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task WriteBytesAsync(S7NetDataType area, int db, int startByteAdr, byte[] value, CancellationToken cancellationToken) =>
|
||||
_plc.WriteBytesAsync(area, db, startByteAdr, value, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ReadStatusAsync(CancellationToken cancellationToken) =>
|
||||
_ = await _plc.ReadStatusAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
/// <inheritdoc />
|
||||
// Plc implements IDisposable explicitly, so cast to invoke it; disposal tears down the TcpClient.
|
||||
public void Dispose() => ((IDisposable)_plc).Dispose();
|
||||
}
|
||||
|
||||
/// <summary>Default <see cref="IS7PlcFactory"/> producing real S7.Net-backed <see cref="S7PlcAdapter"/>s.</summary>
|
||||
internal sealed class S7PlcFactory : IS7PlcFactory
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout) =>
|
||||
new S7PlcAdapter(cpuType, host, port, rack, slot, timeout);
|
||||
}
|
||||
@@ -34,16 +34,21 @@ public sealed class S7Driver
|
||||
{
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly ILogger<S7Driver> _logger;
|
||||
private readonly IS7PlcFactory _plcFactory;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="S7Driver"/> class.</summary>
|
||||
/// <param name="options">Driver configuration (the constructor-supplied fallback used when
|
||||
/// Initialize/Reinitialize receive an empty config body).</param>
|
||||
/// <param name="driverInstanceId">Unique driver instance identifier.</param>
|
||||
/// <param name="plcFactory">Optional S7 connection factory; defaults to <see cref="S7PlcFactory"/>
|
||||
/// (real S7.Net). Tests inject a fake to exercise the reconnect path without a live PLC.</param>
|
||||
/// <param name="logger">Optional logger; a null logger is used when not supplied.</param>
|
||||
public S7Driver(S7DriverOptions options, string driverInstanceId, ILogger<S7Driver>? logger = null)
|
||||
public S7Driver(S7DriverOptions options, string driverInstanceId,
|
||||
IS7PlcFactory? plcFactory = null, ILogger<S7Driver>? logger = null)
|
||||
{
|
||||
_options = options;
|
||||
_driverInstanceId = driverInstanceId;
|
||||
_plcFactory = plcFactory ?? new S7PlcFactory();
|
||||
_logger = logger ?? NullLogger<S7Driver>.Instance;
|
||||
_resolver = new EquipmentTagRefResolver<S7TagDefinition>(
|
||||
r => _tagsByName.TryGetValue(r, out var t) ? t : null,
|
||||
@@ -115,11 +120,30 @@ public sealed class S7Driver
|
||||
internal SemaphoreSlim Gate => _gate;
|
||||
|
||||
/// <summary>
|
||||
/// Active S7.Net PLC connection. Null until <see cref="InitializeAsync"/> returns; null
|
||||
/// after <see cref="ShutdownAsync"/>. Read-only outside this class; PR 64's Read/Write
|
||||
/// will take the <see cref="_gate"/> before touching it.
|
||||
/// Active S7 PLC connection. Null until <see cref="InitializeAsync"/> returns; null
|
||||
/// after <see cref="ShutdownAsync"/>. Also (re)opened lazily by
|
||||
/// <see cref="EnsureConnectedAsync"/> after a transient drop. Read-only outside this class;
|
||||
/// Read/Write/probe take the <see cref="_gate"/> before touching it.
|
||||
/// </summary>
|
||||
internal Plc? Plc { get; private set; }
|
||||
internal IS7Plc? Plc { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// True once <see cref="InitializeAsync"/> has parsed the tag table + opened the first
|
||||
/// connection, false after <see cref="ShutdownAsync"/>. Distinct from <c>Plc != null</c>
|
||||
/// because the reconnect path can leave <see cref="Plc"/> transiently null / dead while the
|
||||
/// driver is still initialized — a data call then repairs the connection rather than
|
||||
/// throwing "not initialized". Guarded by <see cref="_gate"/>.
|
||||
/// </summary>
|
||||
private bool _initialized;
|
||||
|
||||
/// <summary>
|
||||
/// Set when a connection-fatal fault (socket drop / <see cref="ErrorCode.ConnectionError"/>)
|
||||
/// is observed on a read/write, so the next <see cref="EnsureConnectedAsync"/> disposes the
|
||||
/// dead handle and re-opens. Kept as a flag (rather than nulling <see cref="Plc"/> inline) so
|
||||
/// the dead handle is disposed exactly once, in <see cref="EnsureConnectedAsync"/>. Guarded
|
||||
/// by <see cref="_gate"/>.
|
||||
/// </summary>
|
||||
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<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> 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<object> ReadOneAsync(Plc plc, S7TagDefinition tag, CancellationToken ct)
|
||||
private async Task<object> 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 <see cref="DecodeArrayBlock"/> decode loop. Timer/Counter areas are
|
||||
/// already rejected at init, so only DB/M/I/Q reach here.
|
||||
/// </summary>
|
||||
private async Task<object> ReadArrayAsync(Plc plc, S7TagDefinition tag, S7ParsedAddress addr, CancellationToken ct)
|
||||
private async Task<object> 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 <see cref="DecodeScalarBlock"/>
|
||||
/// decode. Mirrors <see cref="ReadArrayAsync"/>'s shape.
|
||||
/// </summary>
|
||||
private async Task<object> ReadScalarBlockAsync(Plc plc, S7TagDefinition tag, S7ParsedAddress addr, CancellationToken ct)
|
||||
private async Task<object> 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<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
IReadOnlyList<WriteRequest> 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 <see cref="EncodeScalarBlock"/> produces the big-endian bytes; this method
|
||||
/// owns only the network I/O (mirrors <see cref="ReadScalarBlockAsync"/>).
|
||||
/// </summary>
|
||||
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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="Plc"/> when it is still connected and not marked dead; otherwise it
|
||||
/// builds + opens a new connection via <see cref="_plcFactory"/> with the same
|
||||
/// timeout-linked CTS the initial connect uses.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// MUST be called while holding <see cref="_gate"/> — it mutates <see cref="Plc"/> /
|
||||
/// <see cref="_plcDead"/> and reuses the single-connection-per-PLC invariant the gate
|
||||
/// enforces. The read / write / probe paths all take the gate before calling it.
|
||||
/// </remarks>
|
||||
/// <param name="ct">Cancellation token for the (re)connect attempt.</param>
|
||||
/// <returns>A live <see cref="IS7Plc"/>.</returns>
|
||||
private async Task<IS7Plc> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flags the current connection dead when <paramref name="ex"/> is a connection-level loss,
|
||||
/// so the next <see cref="EnsureConnectedAsync"/> reopens. A no-op for data-address / type
|
||||
/// faults — those don't warrant tearing down a healthy socket.
|
||||
/// </summary>
|
||||
/// <param name="ex">The exception observed on a read / write.</param>
|
||||
private void MarkConnectionDeadIfFatal(Exception ex)
|
||||
{
|
||||
if (IsS7ConnectionFatal(ex)) _plcDead = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="ex"/> (or any inner exception) is a socket-level / connection
|
||||
/// loss that a reopen can repair — a <see cref="System.Net.Sockets.SocketException"/> /
|
||||
/// <see cref="System.IO.IOException"/> / <see cref="ObjectDisposedException"/>, or an S7.Net
|
||||
/// <see cref="PlcException"/> carrying <see cref="ErrorCode.ConnectionError"/>. A
|
||||
/// data-address / type error (S7.Net's <see cref="ErrorCode.ReadData"/> /
|
||||
/// <see cref="ErrorCode.WriteData"/> for a bad address, PUT/GET-denied, etc.) is deliberately
|
||||
/// NOT treated as fatal — reopening wouldn't help and would churn a healthy connection.
|
||||
/// </summary>
|
||||
/// <param name="ex">The exception to classify.</param>
|
||||
/// <returns><c>true</c> when a reconnect is warranted.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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);
|
||||
}
|
||||
|
||||
+95
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Live end-to-end proof of the STAB-1 reconnect path against the real python-snap7
|
||||
/// S7-1500 profile: a running <see cref="S7Driver"/> survives a full PLC bounce (container
|
||||
/// stop/start) and resumes reading <b>without a redeploy</b> — the exact failure the
|
||||
/// arch-review flagged (a transient drop permanently killed the driver until redeploy).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Double-gated so it never runs destructively by accident:
|
||||
/// <list type="number">
|
||||
/// <item><see cref="Snap7ServerFixture.SkipReason"/> — the sim must be reachable; and</item>
|
||||
/// <item><c>S7_RECONNECT_BOUNCE_CMD</c> — the shell command that bounces the PLC/container
|
||||
/// (e.g. <c>ssh dohertj2@10.100.0.35 docker restart otopcua-python-snap7-s7_1500</c>).
|
||||
/// Absent ⇒ the test skips, because bouncing the shared sim would disrupt other suites.</item>
|
||||
/// </list>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[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";
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the S7 reconnect path (STAB-1). The <see cref="IS7PlcFactory"/> seam lets
|
||||
/// these exercise the lazy <see cref="S7Driver"/> reconnect logic — dispose-dead-handle +
|
||||
/// re-open on the next data call — without a live PLC, which was previously impossible (the
|
||||
/// driver <c>new</c>-ed a concrete <c>S7.Net.Plc</c> inline and had no reopen path at all).
|
||||
/// </summary>
|
||||
[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)],
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A protocol / data-address error (S7.Net <see cref="ErrorCode.ReadData"/>) is NOT a
|
||||
/// connection loss — the driver keeps the same connection and does not churn a reopen.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="PlcException"/>.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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
|
||||
{
|
||||
/// <summary>Every connection handed out, in creation order.</summary>
|
||||
public List<FakeS7Plc> Created { get; } = new();
|
||||
|
||||
/// <summary>When set, the NEXT <see cref="Create"/>d connection throws this on OpenAsync (once).</summary>
|
||||
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; }
|
||||
|
||||
/// <summary>Thrown once by <see cref="OpenAsync"/>, then cleared.</summary>
|
||||
public Exception? OpenThrowsOnce { get; set; }
|
||||
|
||||
/// <summary>Thrown once by the next <see cref="ReadAsync"/>, then cleared.</summary>
|
||||
public Exception? NextReadThrows { get; set; }
|
||||
|
||||
/// <summary>Boxed value returned by a good read (UInt16 tag → ushort).</summary>
|
||||
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<object?> ReadAsync(string address, CancellationToken cancellationToken)
|
||||
{
|
||||
if (NextReadThrows is { } ex)
|
||||
{
|
||||
NextReadThrows = null;
|
||||
return Task.FromException<object?>(ex);
|
||||
}
|
||||
return Task.FromResult(ReadValue);
|
||||
}
|
||||
|
||||
public Task<byte[]> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user