using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using S7.Net;
using S7NetDataType = global::S7.Net.DataType;
using S7DataItem = global::S7.Net.Types.DataItem;
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
///
/// Siemens S7 native driver — speaks S7comm over ISO-on-TCP (port 102) via the S7netplus
/// library. First implementation of for an in-process .NET Standard
/// PLC protocol that is NOT Modbus, validating that the v2 driver-capability interfaces
/// generalize beyond Modbus + Galaxy.
///
///
///
/// PR 62 ships the scaffold: only (Initialize / Reinitialize /
/// Shutdown / GetHealth). , ,
/// , ,
/// land in PRs 63-65 once the address parser (PR 63) is in place.
///
///
/// Single-connection policy: S7netplus documented pattern is one
/// Plc instance per PLC, serialized with a .
/// Parallelising reads against a single S7 CPU doesn't help — the CPU scans the
/// communication mailbox at most once per cycle (2-10 ms) and queues concurrent
/// requests wire-side anyway. Multiple client-side connections just waste the CPU's
/// 8-64 connection-resource budget.
///
///
public sealed class S7Driver
: IDriver, ITagDiscovery, IReadable, IWritable, IBatchSubscribable, IHostConnectivityProbe, ISkippedTagReporter, IDisposable, IAsyncDisposable
{
/// Authored tags this driver could not build into its poll table (Gitea #541).
private readonly SkippedTagLedger _skippedTags = new();
///
public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot();
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,
IS7PlcFactory? plcFactory = null, ILogger? logger = null)
{
_options = options;
_driverInstanceId = driverInstanceId;
_plcFactory = plcFactory ?? new S7PlcFactory();
_logger = logger ?? NullLogger.Instance;
_resolver = new EquipmentTagRefResolver(
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
// CONV-1: the polling overlay runs on the shared PollGroupEngine (structural array diff +
// drain-before-dispose + capped-exponential failure backoff + caller-token-filtered OCE),
// retiring the bespoke S7 poll fork. minInterval 100 ms matches the S7 scan-mailbox floor;
// backoffCap 30 s matches the retired fork's PollBackoffCap.
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)),
minInterval: TimeSpan.FromMilliseconds(100),
onError: HandlePollError,
backoffCap: TimeSpan.FromSeconds(30),
onBatch: (handle, items) => OnDataChangeBatch?.Invoke(this, new DataChangeBatchEventArgs(handle, items)),
// The engine owns no Meter (Core.Abstractions takes no metrics dependency), so this
// closure IS the production consumer of its overrun signal.
onOverrun: _ => OtOpcUaTelemetry.PollOverruns.Add(1,
new KeyValuePair(OtOpcUaTelemetry.PollOverrunDriverTypeTag, DriverTypeNames.S7),
new KeyValuePair(OtOpcUaTelemetry.PollOverrunDriverInstanceTag, driverInstanceId)));
}
// ---- ISubscribable + IHostConnectivityProbe state ----
private readonly PollGroupEngine _poll;
private readonly object _probeLock = new();
private HostState _hostState = HostState.Unknown;
private DateTime _hostStateChangedUtc = DateTime.UtcNow;
private CancellationTokenSource? _probeCts;
///
/// Handle to the in-flight probe loop. Tracked (rather than fire-and-forget) so
/// can await it after cancelling — otherwise a probe
/// iteration still inside the would race a disposed semaphore.
///
private Task? _probeTask;
///
/// Bounded grace window waits for the probe + poll loops to
/// observe cancellation and exit before it disposes the shared semaphore / CTS objects.
///
private static readonly TimeSpan DrainTimeout = TimeSpan.FromSeconds(5);
/// Occurs when a subscribed tag value or status code changes.
public event EventHandler? OnDataChange;
///
/// Occurs once per poll cycle that produced at least one change, carrying that cycle's
/// changed tags. Both data-change events fire by design — this one once per cycle,
/// once per changed tag — so a consumer subscribes to EXACTLY
/// ONE of the two; subscribing both double-delivers.
///
public event EventHandler? OnDataChangeBatch;
/// Occurs when host connectivity status changes.
public event EventHandler? OnHostStatusChanged;
/// OPC UA StatusCode used when the tag name isn't in the driver's tag map.
private const uint StatusBadNodeIdUnknown = 0x80340000u;
/// OPC UA StatusCode used when the tag's data type isn't implemented yet.
private const uint StatusBadNotSupported = 0x803D0000u;
/// OPC UA StatusCode used when a written value has the wrong shape / length / element type.
private const uint StatusBadTypeMismatch = 0x80740000u;
/// OPC UA StatusCode used when a value is unrepresentable in the S7 type (write) or a PLC buffer does not decode (read).
private const uint StatusBadOutOfRange = 0x803C0000u;
/// OPC UA StatusCode used when the tag is declared read-only.
private const uint StatusBadNotWritable = 0x803B0000u;
/// OPC UA StatusCode used when write fails validation (e.g. out-of-range value).
private const uint StatusBadInternalError = 0x80020000u;
/// OPC UA StatusCode used for socket / timeout / protocol-layer faults.
private const uint StatusBadCommunicationError = 0x80050000u;
/// OPC UA StatusCode used for a genuine device fault (CPU error, hardware fault).
private const uint StatusBadDeviceFailure = 0x808B0000u;
// v3: the RawPath → definition table. Keyed by RawPath (the v3 tag identity, == def.Name),
// case-sensitive ordinal. Built in InitializeAsync from _options.RawTags via the pure factory.
private readonly Dictionary _tagsByRawPath = new(StringComparer.Ordinal);
// Pre-parsed S7 address per tag, keyed by RawPath (== def.Name). Populated alongside
// _tagsByRawPath at init so a config typo fails fast here rather than as a per-read fault.
private readonly Dictionary _parsedByRawPath = new(StringComparer.Ordinal);
// Resolves a read/write/subscribe fullReference (a RawPath in v3) to a tag definition by a single
// authored-table hit; a miss is a miss (the driver surfaces BadNodeIdUnknown, never a parse attempt).
private readonly EquipmentTagRefResolver _resolver;
///
/// Active driver configuration. Seeded from the constructor argument, then replaced by
/// whatever / parse out of
/// the supplied driverConfigJson. The
/// constructor value is the fallback used when the caller passes an empty / placeholder
/// JSON document (e.g. the "{}" some unit tests pass).
///
private S7DriverOptions _options;
private readonly SemaphoreSlim _gate = new(1, 1);
///
/// Per-connection gate. Internal so PRs 63-65 (read/write/subscribe) can serialize on
/// the same semaphore without exposing it publicly. Single-connection-per-PLC is a
/// hard requirement of S7netplus — see class remarks.
///
internal SemaphoreSlim Gate => _gate;
///
/// 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 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);
///
/// The health snapshot, accessed with volatile semantics: it is written from the gate-holding
/// read/write passes, the poll engine's error sink and the lifecycle methods, and read lock-free
/// by (the health tick) — the same Volatile idiom ModbusDriver uses.
/// is an immutable record, so a reference swap is a complete update.
///
private DriverHealth Health
{
get => Volatile.Read(ref _health);
set => Volatile.Write(ref _health, value);
}
///
/// Per-device connect-attempt throttle (STAB-21): a dead PLC costs one connect attempt per
/// backoff window (1 s doubling to 30 s) instead of one per data call / poll tick / probe tick.
/// Not internally synchronized — only touched from (under
/// ) and from init/shutdown, which run with every loop drained.
///
private readonly ConnectionBackoff _connectBackoff =
new(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(30));
/// Clock the connect backoff is measured on. Internal so tests step it deterministically.
internal Func BackoffClock { get; set; } = static () => DateTime.UtcNow;
///
/// The sticky write-side config fault (access denied / PUT-GET disabled), or null. A read
/// pass that finds everything readable must NOT erase it (reads can succeed while writes are
/// protected); it is cleared by the next write pass that writes something successfully.
/// Guarded by .
///
private (DriverState State, string Message)? _writeFault;
private bool _disposed;
///
public string DriverInstanceId => _driverInstanceId;
///
public string DriverType => "S7";
///
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
Health = new DriverHealth(DriverState.Initializing, null, null);
try
{
// Re-parse the supplied DriverConfig JSON so a config change delivered through the
// IDriver contract is honoured. An empty / placeholder document
// (e.g. the "{}" some unit tests pass) keeps the constructor-supplied options.
if (HasConfigBody(driverConfigJson))
_options = S7DriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
// v3: build the RawPath → definition table from the authored raw tags BEFORE the config
// guards run (they now iterate the mapped table). Each entry's TagConfig blob is mapped by
// the pure factory; the entry's WriteIdempotent flag is threaded onto the def (it lives on
// the RawTagEntry, not inside the blob). A mapper miss is skipped (logged), never thrown —
// one bad tag must not fail the whole driver init.
_tagsByRawPath.Clear();
_parsedByRawPath.Clear();
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
_skippedTags.BeginRebuild(_options.RawTags.Count);
foreach (var entry in _options.RawTags)
{
if (S7TagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
_tagsByRawPath[entry.RawPath] = def with { WriteIdempotent = entry.WriteIdempotent };
else
{
_skippedTags.Skip(entry.RawPath, "TagConfig did not map to a definition");
_logger.LogWarning(
"S7 tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
_driverInstanceId, entry.RawPath);
}
}
_skippedTags.Commit();
// Phase 4d combined config guard. Timer/Counter addresses and the wide/structured
// types (Int64/UInt64/Float64/String/DateTime) are now SUPPORTED, but only in
// specific shapes — this rejects the genuinely-unsupported configurations
// (wide-type arrays, non-byte-addressed wide scalars, wrong Timer/Counter DataType)
// so a config typo fails fast at init instead of as a misleading per-read fault.
RejectUnsupportedTagConfigs();
// Legacy data-type seam. The UnimplementedDataTypes set is now
// empty (Phase 4d unblocked all five wide types), so this rejects nothing — kept as
// the single grep target / future seam should a type ever need re-gating at init.
RejectUnsupportedTagDataTypes();
// 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);
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(_options.Timeout);
await plc.OpenAsync(cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// The linked CancelAfter fired — a connect TIMEOUT, not a caller cancellation.
// Surface it as a TimeoutException so init-time and reconnect-time timeouts report
// the same exception type (STAB-14 consistency); the outer catch still faults health.
try { plc.Dispose(); } catch { }
throw new TimeoutException(
$"S7 connect to {_options.Host}:{_options.Port} timed out after " +
$"{(int)_options.Timeout.TotalMilliseconds} ms.");
}
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;
_connectBackoff.RecordSuccess();
Volatile.Write(ref _negotiatedPduSize, plc.MaxPduSize);
// 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
// also rejects bit-offset > 7, DB 0, unknown area letters, etc. Keyed by RawPath
// (== def.Name) so Read/Write can pick up the pre-parsed address.
foreach (var t in _tagsByRawPath.Values)
{
_parsedByRawPath[t.Name] = S7AddressParser.Parse(t.Address); // throws FormatException
}
_initialized = true;
Health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
_logger.LogInformation("S7Driver connected. Driver={DriverInstanceId} Host={Host} CPU={CpuType} PduSize={PduSize} Tags={TagCount}",
_driverInstanceId, _options.Host, _options.CpuType, _negotiatedPduSize, _tagsByRawPath.Count);
// Kick off the probe loop once the connection is up. Initial HostState stays
// Unknown until the first probe tick succeeds — avoids broadcasting a premature
// Running transition before any PDU round-trip has happened.
if (_options.Probe.Enabled)
{
_probeCts = new CancellationTokenSource();
// Track the probe Task (not fire-and-forget) so ShutdownAsync can await it
// before disposing _gate / _probeCts. Pass None to Task.Run so
// the delegate always runs and the handle is always awaitable; the loop's own
// token check handles cancellation.
_probeTask = Task.Run(() => ProbeLoopAsync(_probeCts.Token), CancellationToken.None);
}
}
catch (Exception ex)
{
// Clean up a partially-constructed Plc so a retry from the caller doesn't leak
// the TcpClient. Dispose is best-effort and idempotent.
try { Plc?.Dispose(); } catch { }
Plc = null;
Volatile.Write(ref _negotiatedPduSize, 0);
_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;
}
}
///
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
// InitializeAsync re-parses driverConfigJson, so a config change delivered here is
// applied in place rather than silently discarded.
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
}
///
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
// Signal cancellation to the probe + poll loops first, collect their Task handles,
// then await all of them with a bounded timeout BEFORE disposing the shared semaphore
// and CTS objects. Without the drain, a loop iteration mid-_gate would call Release()
// on (or WaitAsync against) a disposed semaphore.
// Drain the polling overlay first — the engine cancels each loop and awaits it before
// disposing its CTS (drain-before-dispose, STAB-6-S7), so no poll iteration touches _gate
// after this returns.
await _poll.DisposeAsync().ConfigureAwait(false);
var probeCts = _probeCts;
var probeTask = _probeTask;
try { probeCts?.Cancel(); } catch { }
if (probeTask is not null)
{
try
{
await probeTask.WaitAsync(DrainTimeout, CancellationToken.None).ConfigureAwait(false);
}
catch (TimeoutException) { /* a wedged probe loop — proceed; better than leaking the teardown */ }
catch { /* loop faults are already surfaced via health; teardown continues */ }
}
// The probe loop has now observed cancellation and released _gate — safe to dispose the CTS.
probeCts?.Dispose();
_probeCts = null;
_probeTask = null;
_initialized = false;
_plcDead = false;
_writeFault = null;
_connectBackoff.RecordSuccess();
try { Plc?.Dispose(); } catch { /* best-effort — tearing down anyway */ }
Plc = null;
Volatile.Write(ref _negotiatedPduSize, 0); // a stale post-teardown reading would read as "online"
Health = new DriverHealth(DriverState.Unknown, Health.LastSuccessfulRead, null);
}
///
public DriverHealth GetHealth() => Health;
///
/// True when carries a real config body. The
/// bootstrapper always passes a populated document; some unit tests pass "{}" or
/// an empty string to exercise lifecycle shape without a config — those keep the
/// constructor-supplied .
///
private static bool HasConfigBody(string? driverConfigJson)
{
if (string.IsNullOrWhiteSpace(driverConfigJson)) return false;
var trimmed = driverConfigJson.Trim();
return trimmed is not "{}" and not "[]";
}
///
/// S7 data types addressed by START BYTE (the B suffix) rather than by an
/// access-width suffix. Phase 4d decodes these from a contiguous byte buffer
/// (DB{n}.DBB{offset} / MB / IB / QB{offset}), so a wide tag
/// authored against a non-byte suffix (DBW/DBD/…) is a config error — see
/// .
///
///
/// PDU size the CPU negotiated on the most recent successful connect, in bytes; 0 before the first
/// connect and after shutdown. Snapshotted at connect so it stays readable while the handle is
/// being replaced. Surfaced through and the connect log line.
///
public int NegotiatedPduSize => Volatile.Read(ref _negotiatedPduSize);
private int _negotiatedPduSize;
///
/// Driver-specific diagnostics key/value pairs (currently S7.NegotiatedPduSize). The core
/// record carries no diagnostics bag, so this is the surface until one exists.
///
/// A snapshot of the diagnostics.
public IReadOnlyDictionary GetDiagnostics() => new Dictionary
{
["S7.NegotiatedPduSize"] = NegotiatedPduSize,
};
private static readonly HashSet ByteAnchoredDataTypes = new()
{
S7DataType.Int64,
S7DataType.UInt64,
S7DataType.Float64,
S7DataType.String,
S7DataType.DateTime,
S7DataType.WString,
S7DataType.Char,
S7DataType.WChar,
S7DataType.Dtl,
S7DataType.Date,
S7DataType.Tod,
S7DataType.Time,
S7DataType.S5Time,
};
///
/// Combined init-time config guard (Phase 4d, supersedes the old Timer/Counter-only
/// RejectUnsupportedTagAddresses). Timer/Counter tags and the wide/structured
/// types (Int64/UInt64/Float64/String/DateTime) are now supported — but only in
/// specific shapes. This guard fails fast on the genuinely-unsupported configurations so
/// a config typo throws here at init rather than as a misleading per-read fault:
///
/// (a) a wide/structured type declared as an array — wide-type arrays are out of scope this phase.
/// (b) a wide/structured type on a DB/M/I/Q area whose address isn't byte-anchored (DBB/MB/IB/QB).
/// (c) a Timer tag not typed Float64, or a Counter tag not typed Int32.
///
/// Addresses that don't parse are left to the existing
/// pass in so the parse error isn't double-handled.
///
private void RejectUnsupportedTagConfigs()
{
foreach (var t in _tagsByRawPath.Values)
{
var isWide = ByteAnchoredDataTypes.Contains(t.DataType);
// (a) wide-type arrays — out of scope this phase.
if (isWide && t.ArrayCount is >= 1)
{
throw new NotSupportedException(
$"S7 tag '{t.Name}' is a {t.DataType} array, which the S7 driver does not yet " +
"support. Use a scalar wide tag, or an Int16/UInt16/Int32/UInt32/Float32/Byte/Bool " +
"element type for arrays.");
}
// Parse the address; if it doesn't parse, leave it for the InitializeAsync
// S7AddressParser.Parse pass to throw the FormatException (no double-handling).
if (!S7AddressParser.TryParse(t.Address, out var parsed))
continue;
// (b) wide/structured scalar on DB/M/I/Q must be byte-anchored (Size == Byte). The
// codec decodes from a byte buffer at the start byte; a non-byte suffix (DBW/DBD/…)
// would mis-frame the value.
if (isWide
&& parsed.Area is S7Area.DataBlock or S7Area.Memory or S7Area.Input or S7Area.Output
&& parsed.Size != S7Size.Byte)
{
throw new NotSupportedException(
$"S7 tag '{t.Name}' is a {t.DataType} at '{t.Address}', which is not byte-addressed. " +
"Wide/structured types must be byte-addressed (DBB/MB/IB/QB) — e.g. 'DB1.DBB0', 'MB4'.");
}
// (c) Timer/Counter type constraints — Timer decodes to a Float64 (seconds), Counter
// to an Int32 (count); both are read-only.
if (parsed.Area is S7Area.Timer && t.DataType != S7DataType.Float64)
{
throw new NotSupportedException(
$"S7 tag '{t.Name}' is a Timer address ('{t.Address}') but is typed {t.DataType}. " +
"Timer tags must be DataType=Float64 (decoded to seconds, read-only).");
}
if (parsed.Area is S7Area.Counter && t.DataType != S7DataType.Int32)
{
throw new NotSupportedException(
$"S7 tag '{t.Name}' is a Counter address ('{t.Address}') but is typed {t.DataType}. " +
"Counter tags must be DataType=Int32 (decoded to count, read-only).");
}
// (d) Timer/Counter declared Writable — writes are read-only this phase; without this
// a node is discovered as Operate-writable but every write returns BadNotSupported.
if (parsed.Area is S7Area.Timer or S7Area.Counter && t.Writable)
{
throw new NotSupportedException(
$"S7 tag '{t.Name}' is a Timer/Counter ('{t.Address}') declared Writable — " +
"Timer/Counter are read-only this phase; set Writable=false.");
}
// (e) Bool array declared Writable — element writes are byte-granular (one byte per
// element, mirroring the read), so a write would overwrite the 7 neighbouring bits of
// every byte it touches; a safe bit-packed write needs read-modify-write and is not
// implemented. Every other supported element type (Byte/Int16/UInt16/Int32/UInt32/
// Float32) writes as ONE contiguous block (EncodeArrayBlock) — S7-016. Wide-type arrays
// are already rejected above (guard-a).
if (t.ArrayCount is >= 1 && t.Writable && t.DataType == S7DataType.Bool)
{
throw new NotSupportedException(
$"S7 tag '{t.Name}' is a Bool array (ArrayCount={t.ArrayCount}) declared Writable — " +
"Bool array writes are not supported (a byte-granular write would clobber the neighbouring " +
"bits). Set Writable=false, or write the bits as individual scalar Bool tags.");
}
}
}
///
/// Rejects tags configured with an still on the
/// list — types /
/// throw for, which
/// would otherwise create live OPC UA nodes via that return
/// BadNotSupported on every access.
/// The set is now empty (Phase 4d unblocked all five wide types), so this rejects
/// nothing — kept as the seam / grep target should a type ever need re-gating at init.
///
private void RejectUnsupportedTagDataTypes()
{
foreach (var t in _tagsByRawPath.Values)
{
if (UnimplementedDataTypes.Contains(t.DataType))
{
throw new NotSupportedException(
$"S7 tag '{t.Name}' uses data type '{t.DataType}' which is not yet " +
"supported by the S7 driver — Read/Write would return BadNotSupported. " +
"Remove the tag or use Bool/Byte/Int16/UInt16/Int32/UInt32/Float32 until " +
$"{t.DataType} is wired through.");
}
}
}
///
/// S7DataType members that the read/write helpers throw NotSupportedException for.
/// Now empty — Phase 4d (S7 wide types + Timer/Counter) wired Int64, UInt64,
/// Float64, String, and DateTime through the byte-buffer codec, and Timer/Counter read
/// through it on AREA. Kept here (rather than reflecting over
/// ) as the single grep target / future seam should a
/// data type ever need re-gating at init.
///
private static readonly HashSet UnimplementedDataTypes = new();
// ---- IReadable ----
///
///
///
/// The batch is served in two passes under ONE gate acquisition. First
/// groups the resolved tags into contiguous
/// (area, DB) byte spans and each span is read with a single
/// ReadBytesAsync, its members decoded by slicing the returned block — this is
/// what turns one wire request per tag into one per contiguous block. Whatever the
/// planner declines (Timer/Counter, whose offsets are device numbers) plus any span not
/// worth issuing (see ) falls to the leftover list.
///
///
/// Those leftovers get a SECOND grouping pass: the ones a
/// expresses cleanly — the narrow scalars, which is what a
/// demoted single-member span is — are batched into multi-var requests by
/// , which crosses (area, DB)
/// boundaries a block read cannot. Whatever remains (Timer/Counter, unparseable
/// addresses, and any chunk whose multi-var request faulted) goes through the unchanged
/// per-tag path.
///
///
/// Setting SpanReadMaxGapBytes below zero skips BOTH passes, which
/// restores the pre-grouping behaviour exactly. Either way the result array is written
/// by original index, so the caller sees its own ordering regardless of how the batch
/// was partitioned, and both passes share so a fault
/// stamps the same status whichever path observed it.
///
///
public async Task> ReadAsync(
IReadOnlyList fullReferences, CancellationToken cancellationToken)
{
// 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);
RequireInitialized();
var now = DateTime.UtcNow;
var results = new DataValueSnapshot[fullReferences.Count];
// Health is decided ONCE, at the end of the pass, from every outcome the pass observed —
// never per item. See ReadPassOutcome.
var outcome = new ReadPassOutcome();
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);
}
// Rethrow ONLY caller cancellation. A timeout-born OCE (none expected after the
// EnsureConnectedAsync conversion, but e.g. an S7.Net-internal CTS) must degrade the
// batch below, not escape as teardown (STAB-14).
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { 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;
}
// Resolve every reference first. An unresolvable one is a miss, stamped here and never
// offered to the planner — grouping must not be able to change what "unknown tag" means.
var resolved = new S7TagDefinition?[fullReferences.Count];
for (var i = 0; i < fullReferences.Count; i++)
{
if (_resolver.TryResolve(fullReferences[i], out var tag))
resolved[i] = tag;
else
results[i] = new DataValueSnapshot(null, StatusBadNodeIdUnknown, null, now);
}
// Indexes the per-tag path must serve: everything, when grouping is off; otherwise the
// planner's leftovers (Timer/Counter) plus any tag whose address won't parse.
var perTag = new List();
if (_options.SpanReadMaxGapBytes >= 0)
{
List<(int OriginalIndex, S7TagDefinition Tag, S7ParsedAddress Addr)>? plannable = null;
for (var i = 0; i < fullReferences.Count; i++)
{
if (resolved[i] is not { } tag) continue;
if (!TryGetParsedAddress(tag, out var addr))
{
// Defensive: every tag pre-parses at init. A tag that somehow didn't goes
// to the per-tag path, where ReadOneAsync's Parse throws the FormatException
// the catch ladder has always mapped to BadCommunicationError.
perTag.Add(i);
continue;
}
(plannable ??= []).Add((i, tag, addr));
}
if (plannable is not null)
{
var plan = S7ReadSpanPlanner.Build(
plannable, _options.SpanReadMaxGapBytes, _options.SpanReadMaxSpanBytes);
foreach (var span in plan.Spans)
{
// A span that saves no round-trip is not worth changing the wire shape for.
if (PrefersPerTagRead(span))
{
perTag.Add(span.Members[0].OriginalIndex);
continue;
}
await ReadSpanAsync(plc, span, results, now, outcome, cancellationToken).ConfigureAwait(false);
}
perTag.AddRange(plan.LeftoverIndexes);
}
}
else
{
for (var i = 0; i < fullReferences.Count; i++)
if (resolved[i] is not null) perTag.Add(i);
}
// Sorted so a batch is served in reference order regardless of how it was partitioned —
// the per-tag tail is otherwise ordered by the planner's grouping, not by the caller's.
perTag.Sort();
// Second grouping pass: the leftovers a DataItem can express ride ONE multi-var request
// per chunk instead of a wire request each. Deliberately gated on the SAME switch as the
// span pass — SpanReadMaxGapBytes < 0 is documented to restore the pre-grouping wire
// shape exactly, and batching the (then complete) per-tag list would break that. What
// comes back is the tags still owed an individual read, still in reference order.
if (_options.SpanReadMaxGapBytes >= 0)
{
perTag = await ReadLeftoversByMultiVarAsync(
plc, resolved, perTag, results, now, outcome, cancellationToken).ConfigureAwait(false);
}
foreach (var i in perTag)
{
try
{
var value = await ReadOneAsync(plc, resolved[i]!, cancellationToken).ConfigureAwait(false);
results[i] = new DataValueSnapshot(value, 0u, now, now);
outcome.RecordSuccess();
}
catch (OperationCanceledException)
{
// Cancellation observed mid-PDU abandons a half-read ISO-on-TCP response on the
// single gated stream — the handle must be reopened, not reused (STAB-15a). Mark
// dead (we hold _gate) and propagate: the caller cancelled, nobody consumes the
// batch. Marking is unconditional on OCE (not routed through the type classifier):
// whether bytes moved is unknowable, and a false positive costs one cheap reopen
// versus permanent desync for a false negative. Kept AT the call site rather
// than inside RecordReadFailure so the rethrow preserves the original stack.
_plcDead = true;
throw;
}
catch (Exception ex)
{
results[i] = new DataValueSnapshot(null, RecordReadFailure(ex, outcome), null, now);
}
}
}
finally
{
// THE single health write of the pass — worst-state-wins over every outcome recorded
// above. In a FINALLY, so evidence the pass already gathered survives an abandoned
// batch: the three OCE arms rethrow mid-pass, and on a partially-working device a
// repeatedly-cancelled poll would otherwise freeze LastSuccessfulRead while tags were
// demonstrably still being read — tripping the comms-loss watchdog into a reconnect
// loop, the exact false positive the AnySuccess design exists to prevent.
//
// A pass that observed NOTHING reports nothing (HasObservations), which is also what
// makes flushing here safe on the two paths that reach the finally with no outcomes:
// the connect-failure early return, whose own Degraded write must NOT be overwritten,
// and an OCE that fired before any tag was stamped. Both no-op.
//
// LastSuccessfulRead advances whenever ANYTHING read, even into a Degraded/Faulted
// state — see ReadPassOutcome for why that is load-bearing.
//
// A sticky write-side Faulted (PUT/GET denied) outranks a read pass that found less
// wrong: reads can succeed against a CPU whose writes are protected, and a clean read
// must not erase the config alert. The write pass clears it on its next success.
if (outcome.HasObservations)
{
var state = outcome.WorstFailure;
var error = state == DriverState.Healthy ? null : outcome.WorstError;
if (_writeFault is { } wf && ReadPassOutcome.Rank(wf.State) > ReadPassOutcome.Rank(state))
{
state = wf.State;
error = wf.Message;
}
Health = new DriverHealth(
state,
outcome.AnySuccess ? now : Health.LastSuccessfulRead,
error);
}
_gate.Release();
}
return results;
}
///
/// The outcomes ONE pass observed, accumulated so the pass writes
/// exactly once — worst state wins.
///
///
///
/// Health used to be stamped per item, in three separate read loops, so the pass
/// reported whatever its LAST item happened to be: a mixed pass read Healthy, and
/// the access-denied Faulted config alert — usually a minority of the batch — was
/// erased by any later good tag. Ranking (Healthy < Degraded < Faulted) and
/// deciding once at the end makes the verdict a function of the whole pass rather than
/// of its item ordering.
///
///
/// is tracked separately from the state on purpose. The
/// comms-loss watchdog trips only on a non-Healthy state AND a frozen
/// LastSuccessfulRead; several drivers latch Degraded off a single unreadable
/// address, so a pass that lands Degraded must still advance the timestamp when
/// anything at all was read, or an entirely reachable CPU would be reconnected every
/// 90 s forever.
///
///
/// Not thread-safe, and does not need to be: a pass runs wholly under
/// and every read loop inside it is sequential.
///
///
private sealed class ReadPassOutcome
{
/// True once any tag in the pass read successfully.
public bool AnySuccess { get; private set; }
///
/// The worst failure state recorded so far; means no
/// failure has been recorded at all.
///
public DriverState WorstFailure { get; private set; } = DriverState.Healthy;
///
/// The first message recorded at the worst severity — the one that explains
/// .
///
///
/// It must move WITH the state, not independently of it. Keeping the pass's
/// chronologically-first message instead desynced the pair whenever a Degraded fault
/// preceded a Faulted one: the driver reported the access-denied config alert while its
/// message talked about some unrelated device fault and never mentioned PUT/GET, which
/// is the one thing the operator has to act on. Within a severity the FIRST message
/// still wins — a later peer failure is as likely to be a consequence of it as an
/// independent fault.
///
public string? WorstError { get; private set; }
///
/// True once the pass observed anything at all — a success or a failure. False for a
/// degenerate pass (empty batch, or every reference unresolvable), which must report
/// nothing rather than invent a verdict.
///
public bool HasObservations => AnySuccess || WorstFailure != DriverState.Healthy;
/// Records that one tag read successfully.
public void RecordSuccess() => AnySuccess = true;
///
/// Records one failed tag, keeping the worst state and the message that explains it.
///
/// The state this failure would have stamped on its own.
/// The failure's operator-facing message.
public void RecordFailure(DriverState state, string message)
{
if (Rank(state) > Rank(WorstFailure))
{
// Escalating the severity MUST re-point the message at the new cause.
WorstFailure = state;
WorstError = message;
}
else
{
WorstError ??= message;
}
}
/// Severity order the pass ranks by: Healthy (none) < Degraded < Faulted.
/// The state to rank.
/// Its severity, higher being worse.
internal static int Rank(DriverState state) => state switch
{
DriverState.Faulted => 2,
DriverState.Degraded => 1,
_ => 0,
};
}
///
/// Items one multi-var request may carry. The caller owns chunking — S7.Net does NOT
/// split a multi-var read, it throws when the request or the projected response exceeds
/// the negotiated PDU (AssertPduSizeForRead).
///
///
/// The request costs a 19-byte header plus 12 bytes per item; the response costs a
/// 14-byte header plus 4 bytes of item header plus the item's own data. Against the common
/// 240-byte PDU that permits 18 items request-side and — for the 4-byte-or-narrower scalars
/// this pass batches — 28 response-side. 12 sits well inside both, leaving headroom for a
/// CPU that negotiates a smaller PDU than 240, which is the failure this constant exists to
/// avoid: an oversized request is a hard throw, not a paged read. It is deliberately NOT a
/// config key — the safe value is a property of the protocol, not of a deployment.
///
private const int MultiVarReadMaxItems = 12;
///
/// Fewest batchable leftovers worth issuing a multi-var request for. ONE leftover is one
/// wire request either way, so batching it would change the observable wire shape of every
/// single-tag read for zero benefit — the same reasoning
/// applies to a lone narrow span.
///
///
/// The gate is on the PASS, not on each chunk: a batch of 13 still splits into 12 + 1, and
/// that trailing single is left in the multi-var path. It costs one wire request either way,
/// so re-routing it would buy nothing and would make the chunk loop's index bookkeeping
/// answer a second question.
///
private const int MultiVarReadMinItems = 2;
///
/// Serves the span planner's leftovers that a can express by
/// batching them into multi-var requests, and returns the indexes still owed an individual
/// — in ascending reference order.
///
///
///
/// Only a narrow scalar is batched. A carrying the same
/// area / DB / offset / bit / VarType that S7.Net's own address parser derives
/// from the tag's address string, with Count == 1, is decoded by the SAME
/// ParseBytes call the per-address read path uses — so its boxed value is
/// byte-for-byte and type-for-type what would have produced,
/// and applies to it unchanged.
///
///
/// Timer and Counter deliberately stay on the per-tag path even though S7.Net's
/// VarType names them. Their per-tag path does not go through ParseBytes
/// at all — it block-reads the 2-byte word and decodes it in
/// , which surfaces a Counter as int where
/// S7.Net's own Counter.FromByteArray returns ushort, and which carries a
/// documented BCD follow-up for legacy C-area counters. Batching them would fork that
/// decode into a second site that must be kept in step by hand. Correctness beats the
/// round-trip. Arrays, wide/buffer scalars and unparseable addresses are likewise left
/// alone — the first two already read a block apiece, and the last has no address to
/// build an item from.
///
///
/// A multi-var fault is never a tag's fault. The whole chunk falls back to the
/// per-tag path, which is what decides the tag's status — batching must not be able to
/// stamp a tag Bad that reading it one at a time would have served, so
/// is deliberately NOT called for the request itself.
/// S7.Net offers no per-item error surface to be finer-grained with: a
/// carries only its Value, and the response parser
/// throws on the FIRST item the CPU refused (before populating any later item), so
/// per-chunk fallback is the finest granularity the library actually admits. Per-ITEM
/// decoding, by contrast, is guarded individually — exactly as the span path guards its
/// members — because a mis-authored tag must not spoil the neighbours it shared a
/// request with, and it fails identically on the per-tag path anyway.
///
///
/// The live connection (caller holds ).
/// The batch's resolved tags, indexed by reference position.
/// Indexes the span pass left for the per-tag path, ascending.
/// The batch's result array, written in place at each tag's original index.
/// The batch timestamp, shared by every tag in the batch.
/// The pass's outcome accumulator; every decoded item records into it.
/// Cancellation token for the reads.
/// The indexes still owed an individual read, ascending.
private async Task> ReadLeftoversByMultiVarAsync(
IS7Plc plc,
S7TagDefinition?[] resolved,
List leftovers,
DataValueSnapshot[] results,
DateTime now,
ReadPassOutcome outcome,
CancellationToken ct)
{
var stillPerTag = new List();
List<(int Index, S7TagDefinition Tag, S7ParsedAddress Addr)>? batchable = null;
foreach (var i in leftovers)
{
var tag = resolved[i]!;
if (tag.ArrayCount is >= 1
|| !TryGetParsedAddress(tag, out var addr)
|| IsBufferType(tag, addr))
{
stillPerTag.Add(i);
continue;
}
(batchable ??= []).Add((i, tag, addr));
}
// Nothing to gain: leave the (at most one) batchable tag exactly where it was.
if (batchable is null || batchable.Count < MultiVarReadMinItems)
{
if (batchable is not null)
foreach (var (index, _, _) in batchable) stillPerTag.Add(index);
stillPerTag.Sort();
return stillPerTag;
}
for (var start = 0; start < batchable.Count; start += MultiVarReadMaxItems)
{
var chunk = batchable.GetRange(start, Math.Min(MultiVarReadMaxItems, batchable.Count - start));
IReadOnlyList answered;
try
{
var items = new List(chunk.Count);
foreach (var (_, _, addr) in chunk) items.Add(ToDataItem(addr));
answered = await plc.ReadMultipleVarsAsync(items, ct).ConfigureAwait(false);
// The contract is "the same items back, in the supplied order". Anything else is
// unmappable to the batch's indexes, so treat it as a failed chunk rather than
// guessing which tag got which value.
if (answered is null || answered.Count != chunk.Count)
{
throw new System.IO.InvalidDataException(
$"S7.Net returned {answered?.Count.ToString() ?? "no"} multi-var item(s) " +
$"for a {chunk.Count}-item request");
}
}
catch (OperationCanceledException)
{
// Same reasoning as the per-tag and span paths: a cancelled read abandons a half-read
// response on the single gated stream, so the handle must be reopened (STAB-15a).
_plcDead = true;
throw;
}
catch (Exception ex)
{
_logger.LogDebug(ex,
"S7 driver {DriverInstanceId}: multi-var read of {ItemCount} leftover tag(s) failed; " +
"falling back to per-tag reads for that chunk", _driverInstanceId, chunk.Count);
foreach (var (index, _, _) in chunk) stillPerTag.Add(index);
continue;
}
for (var k = 0; k < chunk.Count; k++)
{
var (index, tag, addr) = chunk[k];
try
{
var raw = answered[k].Value
?? throw new System.IO.InvalidDataException(
$"S7.Net returned null multi-var value for '{tag.Address}'");
results[index] = new DataValueSnapshot(ReinterpretRawValue(tag, addr, raw), 0u, now, now);
outcome.RecordSuccess();
}
catch (Exception ex)
{
// Decoding is pure and synchronous — no cancellation can surface here, so there
// is deliberately no OperationCanceledException arm to shadow the request one.
results[index] = new DataValueSnapshot(null, RecordReadFailure(ex, outcome), null, now);
}
}
}
// The fallback chunks were appended out of order behind the non-batchable tags.
stillPerTag.Sort();
return stillPerTag;
}
///
/// Builds the multi-var request item for one narrow scalar — the exact
/// (DataType, DB, StartByteAdr, BitAdr, VarType, Count) tuple S7.Net's own address
/// parser derives from the equivalent address string, so the item reads and decodes
/// identically to 's per-address read.
///
/// The tag's parsed address; its picks the var type.
/// The request item, with no value yet.
private static S7DataItem ToDataItem(S7ParsedAddress addr) => new()
{
DataType = ToS7NetArea(addr.Area),
DB = addr.DbNumber,
StartByteAdr = addr.ByteOffset,
// Non-Bit sizes parse to a bit offset of 0, which S7.Net ignores for those var types.
BitAdr = (byte)addr.BitOffset,
VarType = addr.Size switch
{
S7Size.Bit => VarType.Bit,
S7Size.Byte => VarType.Byte,
S7Size.Word => VarType.Word,
S7Size.DWord => VarType.DWord,
_ => throw new NotSupportedException($"S7 size {addr.Size} has no multi-var VarType mapping"),
},
Count = 1,
};
///
/// True when a planned span is better served by the per-tag path: it holds exactly ONE
/// narrow scalar, so issuing it as a block read saves no round-trip and buys nothing.
///
///
/// The point of grouping is to replace N wire requests with one. A lone narrow tag is one
/// wire request either way — but a block read reaches the CPU differently from S7.Net's
/// per-address string read, so converting it would change the observable wire shape of every
/// single-tag read on the server for zero benefit. A lone ARRAY or wide/buffer scalar is NOT
/// demoted: those already read a block on the per-tag path, so serving them as a
/// single-member span is byte-for-byte the request they always issued.
///
/// The planned span to judge.
/// true when the span's single member should go through .
private static bool PrefersPerTagRead(S7ReadSpanPlanner.Span span)
=> span.Members.Count == 1
&& span.Members[0].Tag.ArrayCount is not >= 1
&& !IsBufferType(span.Members[0].Tag, span.Members[0].Addr);
///
/// Reads one planned span — a SINGLE ReadBytesAsync serving every member — and
/// decodes each member from its slice of the returned block.
///
///
/// A span read is one wire request standing in for many per-tag requests, so a transport
/// fault that used to spoil one tag now spoils all of them: every member is stamped with
/// the SAME status would have produced for that exception on
/// the per-tag path, and health degrades identically. Decoding, by contrast, is per member
/// inside its own guard — a single mis-authored tag (declared type vs. address size) must
/// not spoil the neighbours that shared its block.
///
/// The live connection (caller holds ).
/// The planned block read.
/// The batch's result array, written in place at each member's original index.
/// The batch timestamp, shared by every tag in the batch.
/// The pass's outcome accumulator; every member records into it.
/// Cancellation token for the read.
/// A task that completes once every member of the span has been stamped.
private async Task ReadSpanAsync(
IS7Plc plc, S7ReadSpanPlanner.Span span, DataValueSnapshot[] results, DateTime now,
ReadPassOutcome outcome, CancellationToken ct)
{
byte[] block;
try
{
block = await plc.ReadBytesAsync(
ToS7NetArea(span.Area), span.DbNumber, span.StartByte, span.LengthBytes, ct)
.ConfigureAwait(false)
?? throw new System.IO.InvalidDataException(
$"S7.Net returned null block for {span.Area} db{span.DbNumber} " +
$"@{span.StartByte}+{span.LengthBytes}");
}
catch (OperationCanceledException)
{
// Same reasoning as the per-tag path: a cancelled block read leaves the single gated
// stream at an untrustworthy position, so the handle must be reopened (STAB-15a).
_plcDead = true;
throw;
}
catch (Exception ex)
{
var status = RecordReadFailure(ex, outcome);
foreach (var member in span.Members)
results[member.OriginalIndex] = new DataValueSnapshot(null, status, null, now);
return;
}
foreach (var member in span.Members)
{
try
{
var value = DecodeMember(member, block);
results[member.OriginalIndex] = new DataValueSnapshot(value, 0u, now, now);
outcome.RecordSuccess();
}
catch (Exception ex)
{
// Decoding is pure and synchronous — no cancellation can surface here, so there is
// deliberately no OperationCanceledException arm to shadow the block-read one above.
results[member.OriginalIndex] =
new DataValueSnapshot(null, RecordReadFailure(ex, outcome), null, now);
}
}
}
///
/// Decodes one span member from its slice of the block, routing to exactly the decoder
/// would have chosen for that tag, in the same order.
///
/// The member to decode, carrying its slice position and width.
/// The block the span read.
/// The decoded value, boxed identically to the per-tag path's.
private static object DecodeMember(S7ReadSpanPlanner.Member member, byte[] block)
{
// The two block decoders take byte[] (they hand the buffer to S7.Net's own
// S7String/DateTime/Timer/Counter FromByteArray helpers, which are byte[]-only), so a
// member that is not the whole block is copied out. Narrow scalars need no copy at all —
// DecodeNarrowScalar reads straight from a ReadOnlySpan positioned at the tag's own byte.
if (member.Tag.ArrayCount is >= 1)
return DecodeArrayBlock(member.Tag, member.Addr, SliceForMember(member, block));
if (IsBufferType(member.Tag, member.Addr))
return DecodeScalarBlock(member.Tag, member.Addr, SliceForMember(member, block));
return DecodeNarrowScalar(member.Tag, member.Addr, block.AsSpan(member.RelativeOffset, member.WidthBytes));
}
///
/// The member's own bytes as a byte[] for the block decoders. Returns the block
/// itself — no copy — when the member IS the whole block, which is the single-member span
/// every array / wide scalar produces today.
///
/// The member whose bytes are wanted.
/// The block the span read.
/// The member's bytes, starting at its own first byte.
private static byte[] SliceForMember(S7ReadSpanPlanner.Member member, byte[] block)
=> member.RelativeOffset == 0 && member.WidthBytes == block.Length
? block
: block[member.RelativeOffset..(member.RelativeOffset + member.WidthBytes)];
///
/// The tag's parsed address: the table built at init, with an on-demand parse as the
/// defensive fallback for a tag that isn't in it. Mirrors 's
/// lookup, but never throws — an unparseable address returns false so the caller can
/// route the tag to the per-tag path, where the surfaces
/// through the ladder exactly as it always has.
///
/// The tag whose address is wanted.
/// The parsed address, valid only when this returns true.
/// true when the address is known or parses.
private bool TryGetParsedAddress(S7TagDefinition tag, out S7ParsedAddress addr)
=> _parsedByRawPath.TryGetValue(tag.Name, out addr) || S7AddressParser.TryParse(tag.Address, out addr);
///
/// The read-failure classification ladder, shared by the per-tag path and the span path so
/// a failed span stamps every member with exactly the status the same exception would have
/// produced on a single tag. Records the failure into the pass's outcome, applies the
/// dead-handle side effect, and returns the status code the caller must stamp.
///
///
///
/// The health state is recorded, not applied. It goes into
/// so the pass can write once at the
/// end, worst-state-wins — writing it here is what let a later good tag erase this
/// failure. The _plcDead / side effects
/// stay IMMEDIATE: they govern whether the very next read reopens the handle, which
/// cannot wait for the pass to finish.
///
///
/// is deliberately NOT handled here: it is
/// control flow rather than a per-tag status (it marks the handle dead and propagates),
/// and rethrowing it from a helper would rewrite the stack. Both call sites catch it
/// first.
///
///
/// The exception observed on a read.
/// The pass's outcome accumulator this failure is recorded into.
/// The OPC UA status code every affected tag must be stamped with.
private uint RecordReadFailure(Exception ex, ReadPassOutcome outcome)
{
switch (ex)
{
case NotSupportedException:
return StatusBadNotSupported;
case S7ValueRangeException:
// A PLC buffer the type codec refuses (uninitialised DTL, non-BCD S5TIME…) is a data
// problem, not a transport fault — no health change, no connection teardown.
return StatusBadOutOfRange;
case PlcException pex when IsAccessDenied(pex):
// PUT/GET-disabled (S7-1200/1500) / access-protection — a permanent configuration
// fault, NOT a transient one. Blind retry is wasted effort, so map it to
// BadNotSupported and flag the driver as a config alert (Faulted) rather than
// Degraded — per driver-specs.md §5.
outcome.RecordFailure(DriverState.Faulted,
"S7 access denied — enable PUT/GET communication in TIA Portal " +
$"(Protection & Security) for this CPU. PLC reported: {pex.Message}");
return StatusBadNotSupported;
case PlcException pex:
// A genuine device-layer fault (CPU error, hardware fault) — transient 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);
outcome.RecordFailure(DriverState.Degraded, pex.Message);
return StatusBadDeviceFailure;
default:
// Socket / timeout / transport fault — mark the handle dead when it's a connection
// loss so EnsureConnectedAsync reopens on the next call.
MarkConnectionDeadIfFatal(ex);
outcome.RecordFailure(DriverState.Degraded, ex.Message);
return StatusBadCommunicationError;
}
}
private async Task