Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
T
Joseph Doherty 081fa693af fix(s7): access-denied is an answer; review round-1 minors (R07:H4 follow-through)
I-1: a PUT/GET-disabled CPU (every read denied) stayed Faulted with
LastSuccessfulRead frozen, so the comms-loss watchdog reconnected and
re-subscribed it every 90 s forever. Both access-denied arms now also record
an answer: Faulted is kept (worst state wins), the stamp advances, and the
sticky write fault still clears only on a real success. Pinned end to end in
Runtime.Tests: the real S7Driver under the real DriverInstanceActor watchdog
is not reconnected across 2x the threshold; the control (every read timing
out) is.

Minors:
- m-1: injectable pass clock (UtcNow); "LastSuccessfulRead advanced" is now
  asserted strictly, including the write-refusal case.
- m-2: the editor uses S7TagShapeRules.IsByteAnchored instead of its own list.
- m-3: a release that races an init publish also cancels the probe loop the
  publish started after the release took its snapshot.
- m-4: a retained poll failing during a lifecycle operation writes no health.
- m-5: a multi-var chunk that fell back memoises its members refused alone.
- m-7: access-denied needs S7.Net's exact wording and no transport fault in
  the chain.
- m-8: sync teardown clears the write-side Degraded owner; a sync Dispose
  racing a running shutdown is tested.

Refs #742
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:57:53 -04:00

3027 lines
168 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// Siemens S7 native driver — speaks S7comm over ISO-on-TCP (port 102) via the S7netplus
/// library. First implementation of <see cref="IDriver"/> for an in-process .NET Standard
/// PLC protocol that is NOT Modbus, validating that the v2 driver-capability interfaces
/// generalize beyond Modbus + Galaxy.
/// </summary>
/// <remarks>
/// <para>
/// PR 62 ships the scaffold: <see cref="IDriver"/> only (Initialize / Reinitialize /
/// Shutdown / GetHealth). <see cref="ITagDiscovery"/>, <see cref="IReadable"/>,
/// <see cref="IWritable"/>, <see cref="ISubscribable"/>, <see cref="IHostConnectivityProbe"/>
/// land in PRs 63-65 once the address parser (PR 63) is in place.
/// </para>
/// <para>
/// <b>Single-connection policy</b>: S7netplus documented pattern is one
/// <c>Plc</c> instance per PLC, serialized with a <see cref="SemaphoreSlim"/>.
/// 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.
/// </para>
/// </remarks>
public sealed class S7Driver
: IDriver, ITagDiscovery, IReadable, IWritable, IBatchSubscribable, IHostConnectivityProbe, ISkippedTagReporter, IDriverDiagnostics, IDisposable, IAsyncDisposable
{
/// <summary>Authored tags this driver could not build into its poll table.</summary>
private readonly SkippedTagLedger _skippedTags = new();
/// <inheritdoc />
public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot();
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,
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 => TagsByRawPath.TryGetValue(r, out var t) ? t : null);
// 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<string, object?>(OtOpcUaTelemetry.PollOverrunDriverTypeTag, DriverTypeNames.S7),
new KeyValuePair<string, object?>(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;
/// <summary>
/// Handle to the in-flight probe loop. Tracked (rather than fire-and-forget) so
/// <see cref="ShutdownAsync"/> can await it after cancelling — otherwise a probe
/// iteration still inside the <see cref="_gate"/> would race a disposed semaphore.
/// </summary>
private Task? _probeTask;
/// <summary>
/// Bounded grace window <see cref="ShutdownAsync"/> waits for the probe + poll loops to
/// observe cancellation and exit before it disposes the shared semaphore / CTS objects.
/// </summary>
private static readonly TimeSpan DrainTimeout = TimeSpan.FromSeconds(5);
/// <summary>Occurs when a subscribed tag value or status code changes.</summary>
public event EventHandler<DataChangeEventArgs>? OnDataChange;
/// <summary>
/// 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,
/// <see cref="OnDataChange"/> once per changed tag — so a consumer subscribes to EXACTLY
/// ONE of the two; subscribing both double-delivers.
/// </summary>
public event EventHandler<DataChangeBatchEventArgs>? OnDataChangeBatch;
/// <summary>Occurs when host connectivity status changes.</summary>
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
/// <summary>OPC UA StatusCode used when the tag name isn't in the driver's tag map.</summary>
private const uint StatusBadNodeIdUnknown = 0x80340000u;
/// <summary>OPC UA StatusCode used when the tag's data type isn't implemented yet.</summary>
private const uint StatusBadNotSupported = 0x803D0000u;
/// <summary>OPC UA StatusCode used when a written value has the wrong shape / length / element type.</summary>
private const uint StatusBadTypeMismatch = 0x80740000u;
/// <summary>OPC UA StatusCode used when a value is unrepresentable in the S7 type (write) or a PLC buffer does not decode (read).</summary>
private const uint StatusBadOutOfRange = 0x803C0000u;
/// <summary>OPC UA StatusCode used when the tag is declared read-only.</summary>
private const uint StatusBadNotWritable = 0x803B0000u;
/// <summary>OPC UA StatusCode used when write fails validation (e.g. out-of-range value).</summary>
private const uint StatusBadInternalError = 0x80020000u;
/// <summary>OPC UA StatusCode used for socket / timeout / protocol-layer faults.</summary>
private const uint StatusBadCommunicationError = 0x80050000u;
/// <summary>OPC UA StatusCode used for a genuine device fault (CPU error, hardware fault).</summary>
private const uint StatusBadDeviceFailure = 0x808B0000u;
// 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<S7TagDefinition> _resolver;
/// <summary>
/// Active driver configuration. Seeded from the constructor argument, then replaced by
/// whatever <see cref="InitializeAsync"/> / <see cref="ReinitializeAsync"/> parse out of
/// the supplied <c>driverConfigJson</c>. The
/// constructor value is the fallback used when the caller passes an empty / placeholder
/// JSON document (e.g. the <c>"{}"</c> some unit tests pass).
/// </summary>
private S7DriverOptions _options;
private readonly SemaphoreSlim _gate = new(1, 1);
/// <summary>
/// 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.
/// </summary>
internal SemaphoreSlim Gate => _gate;
/// <summary>
/// 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 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);
/// <summary>
/// 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 <see cref="GetHealth"/> (the health tick) — the same Volatile idiom <c>ModbusDriver</c> uses.
/// <see cref="DriverHealth"/> is an immutable record, so a reference swap is a complete update.
/// </summary>
private DriverHealth Health
{
get => Volatile.Read(ref _health);
set => Volatile.Write(ref _health, value);
}
/// <summary>
/// Per-device connect-attempt throttle: 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 <see cref="EnsureConnectedAsync"/> (under
/// <see cref="_gate"/>) and from init/shutdown, which run with every loop drained.
/// </summary>
private readonly ConnectionBackoff _connectBackoff =
new(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(30));
/// <summary>Clock the connect backoff is measured on. Internal so tests step it deterministically.</summary>
internal Func<DateTime> BackoffClock { get; set; } = static () => DateTime.UtcNow;
/// <summary>
/// Clock the read/write passes and the init publish stamp timestamps and
/// <c>LastSuccessfulRead</c> with. Internal so a test can step it and assert a STRICT advance.
/// </summary>
internal Func<DateTime> UtcNow { get; set; } = static () => DateTime.UtcNow;
/// <summary>
/// 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 <see cref="_gate"/>.
/// </summary>
private (DriverState State, string Message)? _writeFault;
/// <summary>
/// The message of the last Degraded the WRITE side published, or null. A clean write batch
/// retires a Degraded carrying exactly this message (a good write proves the comms fault it
/// reported is gone) and leaves any other Degraded to the read pass. Guarded by <see cref="_gate"/>.
/// </summary>
private string? _writeOwnedError;
/// <summary>
/// Tags whose refusal has already been logged this generation — the once-per-tag Warning. Cleared
/// by every initialize publish.
/// </summary>
private readonly ConcurrentDictionary<string, byte> _refusalWarned = new(StringComparer.Ordinal);
/// <summary>
/// Tags the span planner must not group (R07:P2): members of a span the CPU refused as a block —
/// the ones that were refused on their own, or every member when none was. They are read
/// individually for the rest of the generation; a re-initialize (a corrected DB, a fixed tag)
/// groups everything again. Guarded by <see cref="_gate"/>.
/// </summary>
private readonly HashSet<string> _noGroup = new(StringComparer.Ordinal);
private bool _disposed;
/// <inheritdoc />
public string DriverInstanceId => _driverInstanceId;
/// <inheritdoc />
public string DriverType => "S7";
/// <inheritdoc />
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
// Serialised against every other lifecycle operation: DriverInstanceActor dispatches initialize
// on a fire-and-forget Task.Run and a reconnect retry can start a second attempt while the
// first is still inside OpenAsync. Without the gate attempt A's freshly opened Plc and probe
// loop were overwritten by attempt B and never released (R07:H5) — the Modbus pattern.
await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await InitializeCoreAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
}
finally
{
_lifecycleGate.Release();
}
}
/// <summary>The lifecycle-gated body of <see cref="InitializeAsync"/>.</summary>
/// <param name="driverConfigJson">The driver config.</param>
/// <param name="cancellationToken">Caller cancellation.</param>
/// <returns>A task that completes when the attempt has published, been superseded, or failed.</returns>
private async Task InitializeCoreAsync(string driverConfigJson, CancellationToken cancellationToken)
{
// This attempt's generation. Every health write and table/connection publish below is stamped
// with it and dropped once the generation has moved on: an attempt abandoned by its caller can
// still be running when a ShutdownAsync (which only waits a bounded time for the lifecycle
// gate) starts, and it must never publish over it.
var gen = BeginGeneration();
// InitializeAsync is reachable on a live driver with no teardown first (the IDriver contract
// allows it). Stop the previous probe loop and drop the previous connection before they are
// replaced below. Deliberately NOT a full teardown: that also drops _poll and the subscriptions,
// which are owned by the shutdown path (the actor re-subscribes after every successful init).
await ReleaseConnectionAsync(gen).ConfigureAwait(false);
TryWriteHealth(gen, new DriverHealth(DriverState.Initializing, null, null));
// 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. Local until published: a superseded attempt must
// not leave its options behind for the live connection to reopen against.
var options = _options;
IS7Plc? plc = null;
try
{
if (HasConfigBody(driverConfigJson))
options = S7DriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
// v3: build the RawPath → definition table from the authored raw tags. Each entry's TagConfig
// blob is mapped by the pure factory, which also applies S7TagShapeRules (the address must
// parse; wide arrays, mis-typed Timer/Counter, wrong access widths, writable Timer/Counter or
// Bool arrays and offset Bool arrays are refused). A refused tag is SKIPPED (logged, reported
// through ISkippedTagReporter, reads BadNodeIdUnknown), never thrown: these used to be init
// guards that faulted every tag on the PLC and made the actor retry the same init forever.
// The entry's WriteIdempotent flag is threaded onto the def (it lives on the RawTagEntry).
// Built into fresh dictionaries and published by ONE reference swap — a retained poll pass
// reading the old table never sees it half-cleared.
var tags = new Dictionary<string, S7TagDefinition>(StringComparer.Ordinal);
var parsed = new Dictionary<string, S7ParsedAddress>(StringComparer.Ordinal);
var skipped = new List<(string RawPath, string Reason)>();
foreach (var entry in options.RawTags)
{
if (S7TagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def, out var shapeRejection)
&& S7AddressParser.TryParse(def.Address, out var parsedAddress))
{
tags[entry.RawPath] = def with { WriteIdempotent = entry.WriteIdempotent };
parsed[entry.RawPath] = parsedAddress;
}
else
{
skipped.Add((entry.RawPath, shapeRejection ?? "TagConfig did not map to a definition"));
}
}
// 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.
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 (for consistency); the outer catch still faults health.
throw new TimeoutException(
$"S7 connect to {options.Host}:{options.Port} timed out after " +
$"{(int)options.Timeout.TotalMilliseconds} ms.");
}
// Publish everything this attempt built under _gate (no pass can be mid-flight against a
// half-published state) and only while this attempt is still the current generation.
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (!IsCurrentGeneration(gen))
{
// The outer finally disposes the unpublished plc; the catch writes no health (the
// generation moved on). Thrown, not returned: a caller must never read "initialized"
// into an attempt that published nothing.
throw new InvalidOperationException(
"S7 initialize was superseded by a newer lifecycle operation before it published; " +
"its connection was discarded.");
}
_options = options;
Volatile.Write(ref _tables, new TagTables(tags, parsed));
_refusalWarned.Clear();
_noGroup.Clear();
_skippedTags.BeginRebuild(options.RawTags.Count);
foreach (var (rawPath, reason) in skipped)
{
_skippedTags.Skip(rawPath, reason);
_logger.LogWarning(
"S7 tag skipped: {Reason} Driver={DriverInstanceId} RawPath={RawPath}",
reason, _driverInstanceId, rawPath);
}
_skippedTags.Commit();
Plc = plc;
plc = null; // published — no longer this method's to dispose
_plcDead = false;
_connectBackoff.RecordSuccess();
Volatile.Write(ref _negotiatedPduSize, Plc.MaxPduSize);
_initialized = true;
TryWriteHealth(gen, new DriverHealth(DriverState.Healthy, UtcNow(), null));
// 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. Tracked (not fire-and-forget) so
// a teardown can await it before disposing its CTS; Task.Run gets None so the handle
// is always awaitable — the loop's own token check handles cancellation.
if (options.Probe.Enabled)
{
var probeCts = new CancellationTokenSource();
_probeCts = probeCts;
_probeTask = Task.Run(() => ProbeLoopAsync(probeCts.Token), CancellationToken.None);
}
}
finally
{
_gate.Release();
}
_logger.LogInformation("S7Driver connected. Driver={DriverInstanceId} Host={Host} CPU={CpuType} PduSize={PduSize} Tags={TagCount}",
_driverInstanceId, options.Host, options.CpuType, _negotiatedPduSize, tags.Count);
}
catch (Exception ex)
{
// A superseded attempt reports nothing: the generation that replaced it owns health.
if (TryWriteHealth(gen, new DriverHealth(DriverState.Faulted, null, ex.Message)))
{
_options = options;
_logger.LogError(ex, "S7Driver connect failed. Driver={DriverInstanceId} Host={Host}", _driverInstanceId, options.Host);
}
throw;
}
finally
{
// A connection this attempt opened but did not publish (failed or superseded) is released
// here, so a retry from the caller never leaks the TcpClient.
if (plc is not null) { try { plc.Dispose(); } catch { /* best-effort */ } }
}
}
/// <inheritdoc />
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
// Holds the lifecycle gate across BOTH halves so no other lifecycle operation slips between
// them. InitializeCoreAsync re-parses driverConfigJson, so a config change delivered here is
// applied in place rather than silently discarded.
await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await ShutdownCoreAsync().ConfigureAwait(false);
await InitializeCoreAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
}
finally
{
_lifecycleGate.Release();
}
}
/// <inheritdoc />
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
// Bounded wait for the lifecycle gate: a wedged initialize (a connect that never returns, or an
// attempt its caller abandoned) must not turn shutdown into a hang. Past the bound we tear down
// anyway — the generation bump makes the still-running attempt discard instead of publish.
var gated = false;
try { gated = await _lifecycleGate.WaitAsync(LifecycleGateTimeout, cancellationToken).ConfigureAwait(false); }
catch (OperationCanceledException) { /* the caller stopped waiting for the gate — still tear down */ }
if (!gated)
_logger.LogWarning(
"S7 lifecycle gate still held at shutdown; tearing down anyway. Driver={DriverInstanceId}",
_driverInstanceId);
try
{
await ShutdownCoreAsync().ConfigureAwait(false);
}
finally
{
if (gated) _lifecycleGate.Release();
}
}
/// <summary>
/// The complete teardown: the poll engine (every subscription), the probe loop, the connection,
/// the sticky write fault and the per-generation memos. It is its own generation, so its final
/// health write can never clobber an initialize that started after it.
/// </summary>
/// <returns>A task that completes once everything is released.</returns>
private async Task ShutdownCoreAsync()
{
var gen = BeginGeneration();
// Drain the polling overlay first — the engine cancels each loop and awaits it before
// disposing its CTS (drain-before-dispose), so no poll iteration touches _gate after this.
try { await _poll.DisposeAsync().ConfigureAwait(false); }
catch (Exception ex) { _logger.LogWarning(ex, "S7 poll engine drain failed. Driver={DriverInstanceId}", _driverInstanceId); }
await ReleaseConnectionAsync(gen).ConfigureAwait(false);
lock (_healthLock)
{
if (_generation != gen) return; // a newer initialize owns the state now
_writeFault = null;
_writeOwnedError = null;
_connectBackoff.RecordSuccess();
Health = new DriverHealth(DriverState.Unknown, Health.LastSuccessfulRead, null);
}
}
/// <summary>
/// Cancels + bounded-joins the probe loop, then disposes its CTS and the PLC connection. The
/// connection part of a teardown (everything but the poll engine and health). Releases only
/// while <paramref name="gen"/> is still current — a teardown that ran past the lifecycle gate
/// must not dispose a connection a NEWER initialize already published.
/// </summary>
/// <param name="gen">The generation of the lifecycle operation doing the release.</param>
/// <returns>A task that completes once the prior state is released.</returns>
private async Task ReleaseConnectionAsync(long gen)
{
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 */ }
}
// Swap the connection out under _gate so a Read/Write/poll pass that already holds the gate (they
// re-read Plc inside it) can never see a disposed Plc mid-swap. Bounded: a wedged holder must not hang
// teardown, in which case we proceed exactly as the un-gated path always did.
var gated = false;
try { gated = await _gate.WaitAsync(DrainTimeout).ConfigureAwait(false); } catch { }
if (!gated)
_logger.LogWarning(
"S7 driver {DriverInstanceId}: disposing the S7 connection while an operation still holds the gate (waited {Timeout})",
_driverInstanceId, DrainTimeout);
try
{
if (!IsCurrentGeneration(gen)) return; // superseded: the newer generation released (or owns) this state
// The probe loop has now observed cancellation and released _gate — safe to dispose the CTS.
if (ReferenceEquals(_probeCts, probeCts))
{
probeCts?.Dispose();
}
else
{
// An initialize published (and started a probe loop) after this release took its
// snapshot — a teardown whose generation bump raced the publish block. Cancel that
// loop too; it exits on its next gate wait / delay. Not joined: it needs _gate, which
// we hold. Its CTS is left to the GC rather than disposed under a running loop.
try { _probeCts?.Cancel(); } catch { /* best-effort */ }
probeCts?.Dispose();
}
_probeCts = null;
_probeTask = null;
_initialized = false;
_plcDead = false;
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"
}
finally
{
if (gated) { try { _gate.Release(); } catch (ObjectDisposedException) { } }
}
}
// ---- lifecycle generation ----
/// <summary>
/// Guards <see cref="InitializeAsync"/> / <see cref="ReinitializeAsync"/> /
/// <see cref="ShutdownAsync"/> / <see cref="DisposeAsync"/> against each other (R07:H5, the
/// Modbus <c>_lifecycleGate</c> pattern). Distinct from <see cref="_gate"/>, which serialises I/O.
/// Never disposed: a Dispose racing a still-running ShutdownAsync must not make it throw.
/// </summary>
private readonly SemaphoreSlim _lifecycleGate = new(1, 1);
/// <summary>
/// Bounded wait the teardown paths take on <see cref="_lifecycleGate"/> before tearing down anyway.
/// Internal so a test can shorten it instead of waiting the production 10 s.
/// </summary>
internal TimeSpan LifecycleGateTimeout { get; set; } = TimeSpan.FromSeconds(10);
/// <summary>Serialises generation bumps against generation-stamped health writes.</summary>
private readonly object _healthLock = new();
/// <summary>
/// Lifecycle generation: bumped by every initialize and teardown. Health writes and
/// table/connection publishes carry the generation they were computed under and are dropped
/// once it has moved on. Written under <see cref="_healthLock"/>.
/// </summary>
private long _generation;
/// <summary>Starts a new lifecycle generation.</summary>
/// <returns>The new generation.</returns>
private long BeginGeneration()
{
lock (_healthLock) return ++_generation;
}
/// <summary>The current lifecycle generation.</summary>
private long CurrentGeneration => Interlocked.Read(ref _generation);
/// <summary>True while <paramref name="gen"/> is still the current lifecycle generation.</summary>
/// <param name="gen">The generation to test.</param>
/// <returns><see langword="true"/> when nothing has superseded it.</returns>
private bool IsCurrentGeneration(long gen) => CurrentGeneration == gen;
/// <summary>Writes <paramref name="health"/> only while <paramref name="gen"/> is current.</summary>
/// <param name="gen">The generation the health was computed under.</param>
/// <param name="health">The health to publish.</param>
/// <returns><see langword="true"/> when written.</returns>
private bool TryWriteHealth(long gen, DriverHealth health)
{
lock (_healthLock)
{
if (_generation != gen) return false;
Health = health;
return true;
}
}
/// <summary>
/// Derives and writes a new health from the current one, atomically, only while
/// <paramref name="gen"/> is current — for the passes that preserve <c>LastSuccessfulRead</c>.
/// </summary>
/// <param name="gen">The generation the update was computed under.</param>
/// <param name="update">Maps the current health to the new one; returning null writes nothing.</param>
/// <returns><see langword="true"/> when written.</returns>
private bool TryUpdateHealth(long gen, Func<DriverHealth, DriverHealth?> update)
{
lock (_healthLock)
{
if (_generation != gen) return false;
if (update(Health) is not { } next) return false;
Health = next;
return true;
}
}
/// <summary>The published RawPath → definition and RawPath → parsed-address tables, swapped as one.</summary>
/// <param name="Tags">RawPath → definition.</param>
/// <param name="Parsed">RawPath → pre-parsed address.</param>
private sealed record TagTables(
IReadOnlyDictionary<string, S7TagDefinition> Tags,
IReadOnlyDictionary<string, S7ParsedAddress> Parsed)
{
/// <summary>The empty tables a driver starts with.</summary>
public static readonly TagTables Empty = new(
new Dictionary<string, S7TagDefinition>(StringComparer.Ordinal),
new Dictionary<string, S7ParsedAddress>(StringComparer.Ordinal));
}
/// <summary>The live tag tables; replaced by reference at init, never mutated in place.</summary>
private TagTables _tables = TagTables.Empty;
/// <summary>The live RawPath → definition table.</summary>
private IReadOnlyDictionary<string, S7TagDefinition> TagsByRawPath => Volatile.Read(ref _tables).Tags;
/// <summary>The live RawPath → pre-parsed address table.</summary>
private IReadOnlyDictionary<string, S7ParsedAddress> ParsedByRawPath => Volatile.Read(ref _tables).Parsed;
/// <inheritdoc />
public DriverHealth GetHealth() => Health;
/// <summary>
/// True when <paramref name="driverConfigJson"/> carries a real config body. The
/// bootstrapper always passes a populated document; some unit tests pass <c>"{}"</c> or
/// an empty string to exercise lifecycle shape without a config — those keep the
/// constructor-supplied <see cref="_options"/>.
/// </summary>
private static bool HasConfigBody(string? driverConfigJson)
{
if (string.IsNullOrWhiteSpace(driverConfigJson)) return false;
var trimmed = driverConfigJson.Trim();
return trimmed is not "{}" and not "[]";
}
/// <summary>
/// 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 <see cref="GetDiagnostics"/> and the connect log line.
/// </summary>
public int NegotiatedPduSize => Volatile.Read(ref _negotiatedPduSize);
private int _negotiatedPduSize;
/// <summary>
/// Driver-specific diagnostics key/value pairs (currently <c>S7.NegotiatedPduSize</c>). The core
/// <see cref="DriverHealth"/> record carries no diagnostics bag, so this is the surface until one exists.
/// </summary>
/// <returns>A snapshot of the diagnostics.</returns>
public IReadOnlyDictionary<string, object> GetDiagnostics() => new Dictionary<string, object>
{
["S7.NegotiatedPduSize"] = NegotiatedPduSize,
};
// IDriverDiagnostics view of the same facts, as strings, for the /hosts page. Empty before the
// first connect (PDU size 0 is "not negotiated yet", not a size).
/// <inheritdoc />
IReadOnlyDictionary<string, string> IDriverDiagnostics.GetDiagnostics()
{
var pdu = NegotiatedPduSize;
return pdu > 0
? new Dictionary<string, string> { ["S7.NegotiatedPduSize"] = pdu.ToString(System.Globalization.CultureInfo.InvariantCulture) }
: new Dictionary<string, string>();
}
// ---- IReadable ----
// The batch is served in two passes under ONE gate acquisition. First S7ReadSpanPlanner 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 PrefersPerTagRead) falls
// to the leftover list.
//
// Those leftovers get a SECOND grouping pass: the ones a S7DataItem expresses cleanly - the narrow
// scalars, which is what a demoted single-member span is - are batched into multi-var requests by
// ReadLeftoversByMultiVarAsync, 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 ReadOneAsync 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
// RecordReadFailure so a fault stamps the same status whichever path observed it.
/// <inheritdoc />
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> 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 = 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);
// The lifecycle generation this pass runs in — its health write is dropped if a teardown or
// re-initialize has moved on by the time it lands. Captured under _gate, where the init
// publish happens, and re-checked against _initialized: a pass that passed RequireInitialized
// and then waited out a teardown must not reopen a connection on a shut-down driver.
var gen = CurrentGeneration;
try
{
RequireInitialized();
// (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.
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);
TryUpdateHealth(gen, h => new DriverHealth(DriverState.Degraded, h.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<int>();
// Indexes that must take the per-tag path WITHOUT the multi-var pass: tags the planner may
// not group (R07:P2) and the members of a span refused this pass. Batching them into a
// multi-var request would just refuse the whole chunk again.
List<int>? forcedPerTag = null;
// Members of each span refused this pass, and which of them were then refused on their own.
List<IReadOnlyList<int>>? refusedSpans = null;
// Members of each multi-var chunk that fell back per tag this pass (R07:P2's multi-var sibling).
List<IReadOnlyList<int>>? multiVarFallbacks = null;
HashSet<int>? refusedAlone = null;
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 (_noGroup.Contains(tag.Name))
{
(forcedPerTag ??= []).Add(i);
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;
}
// Fail fast once an earlier span marked the handle dead (R07:P1): every
// further wire op on it would pay the full deadline under the gate.
if (_plcDead)
{
foreach (var member in span.Members) results[member.OriginalIndex] = DeadHandleSnapshot(now);
continue;
}
if (await ReadSpanAsync(plc, span, results, now, outcome, cancellationToken).ConfigureAwait(false)
is { } refusedMembers)
{
(refusedSpans ??= []).Add(refusedMembers);
(forcedPerTag ??= []).AddRange(refusedMembers);
}
}
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,
fallback => (multiVarFallbacks ??= []).Add(fallback)).ConfigureAwait(false);
}
if (forcedPerTag is not null)
{
perTag.AddRange(forcedPerTag);
perTag.Sort();
}
foreach (var i in perTag)
{
// Fail fast once the handle is dead (R07:P1) — see the span loop.
if (_plcDead)
{
results[i] = DeadHandleSnapshot(now);
continue;
}
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. 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)
{
if ((refusedSpans is not null || multiVarFallbacks is not null) && IsRefusal(ex)) (refusedAlone ??= []).Add(i);
results[i] = new DataValueSnapshot(null, RecordReadFailure(ex, outcome, [resolved[i]!.Name]), null, now);
}
}
// Remember what must not be grouped again (R07:P2). Skipped when the handle died mid-pass:
// the per-member reads then proved nothing about which member the CPU refuses.
// A multi-var chunk that fell back: memo only the members refused ON THEIR OWN — without one,
// the chunk's failure explains nothing about its members, and they keep batching.
if (multiVarFallbacks is not null && refusedAlone is not null && !_plcDead)
{
foreach (var members in multiVarFallbacks)
{
var bad = members.Where(refusedAlone.Contains).ToList();
if (bad.Count == 0) continue;
foreach (var m in bad) _noGroup.Add(resolved[m]!.Name);
_logger.LogInformation(
"S7 CPU refused a multi-var read of {MemberCount} tag(s); reading {Tags} individually from now on. Driver={DriverInstanceId}",
members.Count, string.Join(", ", bad.Select(m => resolved[m]!.Name)), _driverInstanceId);
}
}
if (refusedSpans is not null && !_plcDead)
{
foreach (var members in refusedSpans)
{
var bad = members.Where(m => refusedAlone?.Contains(m) == true).ToList();
var memo = bad.Count > 0 ? bad : [.. members];
foreach (var m in memo) _noGroup.Add(resolved[m]!.Name);
_logger.LogInformation(
"S7 CPU refused a grouped read of {MemberCount} tag(s); reading {Tags} individually from now on. Driver={DriverInstanceId}",
members.Count, string.Join(", ", memo.Select(m => resolved[m]!.Name)), _driverInstanceId);
}
}
}
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;
}
TryUpdateHealth(gen, h => new DriverHealth(
state,
outcome.AnySuccess ? now : h.LastSuccessfulRead,
error));
}
_gate.Release();
}
return results;
}
/// <summary>
/// The outcomes ONE <see cref="ReadAsync"/> pass observed, accumulated so the pass writes
/// <see cref="Health"/> exactly once — <b>worst state wins</b>.
/// </summary>
/// <remarks>
/// <para>
/// 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 <c>Healthy</c>, and
/// the access-denied <c>Faulted</c> config alert — usually a minority of the batch — was
/// erased by any later good tag. Ranking (Healthy &lt; Degraded &lt; Faulted) and
/// deciding once at the end makes the verdict a function of the whole pass rather than
/// of its item ordering.
/// </para>
/// <para>
/// <b><see cref="AnySuccess"/> is tracked separately from the state on purpose.</b> The
/// comms-loss watchdog trips only on a non-Healthy state AND a frozen
/// <c>LastSuccessfulRead</c>; 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.
/// </para>
/// <para>
/// Not thread-safe, and does not need to be: a pass runs wholly under <see cref="_gate"/>
/// and every read loop inside it is sequential.
/// </para>
/// </remarks>
private sealed class ReadPassOutcome
{
/// <summary>True once any tag in the pass read successfully.</summary>
public bool AnySuccess { get; private set; }
/// <summary>
/// The worst failure state recorded so far; <see cref="DriverState.Healthy"/> means no
/// failure has been recorded at all.
/// </summary>
public DriverState WorstFailure { get; private set; } = DriverState.Healthy;
/// <summary>
/// The first message recorded <b>at the worst severity</b> — the one that explains
/// <see cref="WorstFailure"/>.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public string? WorstError { get; private set; }
/// <summary>
/// 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.
/// </summary>
public bool HasObservations => AnySuccess || WorstFailure != DriverState.Healthy;
/// <summary>Records that one tag read successfully.</summary>
public void RecordSuccess() => AnySuccess = true;
/// <summary>
/// Records that the CPU answered for one tag without serving it (a refusal, an undecodable
/// buffer). Not a failure — it ranks nothing — but it IS proof of a live device, so it
/// advances <c>LastSuccessfulRead</c> exactly like a success.
/// </summary>
public void RecordAnswered() => AnySuccess = true;
/// <summary>
/// Records one failed tag, keeping the worst state and the message that explains it.
/// </summary>
/// <param name="state">The state this failure would have stamped on its own.</param>
/// <param name="message">The failure's operator-facing message.</param>
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;
}
}
/// <summary>Severity order the pass ranks by: Healthy (none) &lt; Degraded &lt; Faulted.</summary>
/// <param name="state">The state to rank.</param>
/// <returns>Its severity, higher being worse.</returns>
internal static int Rank(DriverState state) => state switch
{
DriverState.Faulted => 2,
DriverState.Degraded => 1,
_ => 0,
};
}
/// <summary>
/// Items one multi-var request may carry. <b>The caller owns chunking — S7.Net does NOT
/// split a multi-var read</b>, it throws when the request or the projected response exceeds
/// the negotiated PDU (<c>AssertPduSizeForRead</c>).
/// </summary>
/// <remarks>
/// The request costs a 19-byte header plus <b>12 bytes per item</b>; 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.
/// </remarks>
private const int MultiVarReadMaxItems = 12;
/// <summary>
/// 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
/// <see cref="PrefersPerTagRead"/> applies to a lone narrow span.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private const int MultiVarReadMinItems = 2;
/// <summary>
/// Serves the span planner's leftovers that a <see cref="S7DataItem"/> can express by
/// batching them into multi-var requests, and returns the indexes still owed an individual
/// <see cref="ReadOneAsync"/> — in ascending reference order.
/// </summary>
/// <remarks>
/// <para>
/// <b>Only a narrow scalar is batched.</b> A <see cref="S7DataItem"/> carrying the same
/// area / DB / offset / bit / <c>VarType</c> that S7.Net's own address parser derives
/// from the tag's address string, with <c>Count == 1</c>, is decoded by the SAME
/// <c>ParseBytes</c> call the per-address read path uses — so its boxed value is
/// byte-for-byte and type-for-type what <see cref="ReadOneAsync"/> would have produced,
/// and <see cref="ReinterpretRawValue"/> applies to it unchanged.
/// </para>
/// <para>
/// <b>Timer and Counter deliberately stay on the per-tag path</b> even though S7.Net's
/// <c>VarType</c> names them. Their per-tag path does not go through <c>ParseBytes</c>
/// at all — it block-reads the 2-byte word and decodes it in
/// <see cref="DecodeScalarBlock"/>, which surfaces a Counter as <c>int</c> where
/// S7.Net's own <c>Counter.FromByteArray</c> returns <c>ushort</c>, 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.
/// </para>
/// <para>
/// <b>A multi-var fault is never a tag's fault.</b> 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
/// <see cref="RecordReadFailure"/> is deliberately NOT called for the request itself.
/// S7.Net offers no per-item error surface to be finer-grained with: a
/// <see cref="S7DataItem"/> carries only its <c>Value</c>, 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.
/// </para>
/// </remarks>
/// <param name="plc">The live connection (caller holds <see cref="_gate"/>).</param>
/// <param name="resolved">The batch's resolved tags, indexed by reference position.</param>
/// <param name="leftovers">Indexes the span pass left for the per-tag path, ascending.</param>
/// <param name="results">The batch's result array, written in place at each tag's original index.</param>
/// <param name="now">The batch timestamp, shared by every tag in the batch.</param>
/// <param name="outcome">The pass's outcome accumulator; every decoded item records into it.</param>
/// <param name="ct">Cancellation token for the reads.</param>
/// <param name="onChunkFallback">Receives the member indexes of each chunk that fell back per tag, so
/// the caller can memo the members then refused on their own (the multi-var sibling of R07:P2).</param>
/// <returns>The indexes still owed an individual read, ascending.</returns>
private async Task<List<int>> ReadLeftoversByMultiVarAsync(
IS7Plc plc,
S7TagDefinition?[] resolved,
List<int> leftovers,
DataValueSnapshot[] results,
DateTime now,
ReadPassOutcome outcome,
CancellationToken ct,
Action<IReadOnlyList<int>> onChunkFallback)
{
var stillPerTag = new List<int>();
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));
// Fail fast once the handle is dead (R07:P1) — see ReadAsync's span loop.
if (_plcDead)
{
foreach (var (index, _, _) in chunk) results[index] = DeadHandleSnapshot(now);
continue;
}
IReadOnlyList<S7DataItem> answered;
try
{
var items = new List<S7DataItem>(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.
_plcDead = true;
throw;
}
catch (Exception ex) when (IsS7TransportLoss(ex))
{
// A transport loss is the connection's fault, not the batch's: falling back would
// send one more doomed per-tag read per member, each paying the full deadline
// (R07:P1). Mark the handle dead and fail the chunk (and, via the guards, the rest
// of the pass) without I/O.
_plcDead = true;
outcome.RecordFailure(DriverState.Degraded, ex.Message);
foreach (var (index, _, _) in chunk) results[index] = DeadHandleSnapshot(now);
continue;
}
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);
onChunkFallback([.. chunk.Select(c => c.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, [tag.Name]), null, now);
}
}
}
// The fallback chunks were appended out of order behind the non-batchable tags.
stillPerTag.Sort();
return stillPerTag;
}
/// <summary>
/// Builds the multi-var request item for one narrow scalar — the exact
/// <c>(DataType, DB, StartByteAdr, BitAdr, VarType, Count)</c> tuple S7.Net's own address
/// parser derives from the equivalent address string, so the item reads and decodes
/// identically to <see cref="ReadOneAsync"/>'s per-address read.
/// </summary>
/// <param name="addr">The tag's parsed address; its <see cref="S7Size"/> picks the var type.</param>
/// <returns>The request item, with no value yet.</returns>
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,
};
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="span">The planned span to judge.</param>
/// <returns><c>true</c> when the span's single member should go through <see cref="ReadOneAsync"/>.</returns>
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);
/// <summary>
/// Reads one planned span — a SINGLE <c>ReadBytesAsync</c> serving every member — and
/// decodes each member from its slice of the returned block.
/// </summary>
/// <remarks>
/// 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 <see cref="RecordReadFailure"/> 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.
/// </remarks>
/// <param name="plc">The live connection (caller holds <see cref="_gate"/>).</param>
/// <param name="span">The planned block read.</param>
/// <param name="results">The batch's result array, written in place at each member's original index.</param>
/// <param name="now">The batch timestamp, shared by every tag in the batch.</param>
/// <param name="outcome">The pass's outcome accumulator; every member records into it.</param>
/// <param name="ct">Cancellation token for the read.</param>
/// <returns>
/// <see langword="null"/> once every member has been stamped; or, when the CPU REFUSED the block
/// of a multi-member span (R07:P2 — one member past the DB's end refuses them all), the members'
/// original indexes, unstamped, for the caller to read one at a time.
/// </returns>
private async Task<IReadOnlyList<int>?> 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.
_plcDead = true;
throw;
}
catch (Exception ex) when (span.Members.Count > 1 && IsRefusal(ex))
{
// The planner's old invariant ("a grouped read cannot fault where the per-tag reads would
// have succeeded") is false: the block is the UNION of the members' ranges, so one member
// running past the DB's real end, or over-declaring a STRING, makes the CPU refuse every
// neighbour with it. Hand the members back for individual reads.
_logger.LogDebug(ex,
"S7 driver {DriverInstanceId}: CPU refused the block read of {Area} db{Db} @{Start}+{Length}; " +
"re-reading its {Count} member(s) individually", _driverInstanceId, span.Area, span.DbNumber,
span.StartByte, span.LengthBytes, span.Members.Count);
return [.. span.Members.Select(m => m.OriginalIndex)];
}
catch (Exception ex)
{
var status = RecordReadFailure(ex, outcome, span.Members.Select(m => m.Tag.Name));
foreach (var member in span.Members)
results[member.OriginalIndex] = new DataValueSnapshot(null, status, null, now);
return null;
}
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, [member.Tag.Name]), null, now);
}
}
return null;
}
/// <summary>
/// Decodes one span member from its slice of the block, routing to exactly the decoder
/// <see cref="ReadOneAsync"/> would have chosen for that tag, in the same order.
/// </summary>
/// <param name="member">The member to decode, carrying its slice position and width.</param>
/// <param name="block">The block the span read.</param>
/// <returns>The decoded value, boxed identically to the per-tag path's.</returns>
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));
}
/// <summary>
/// The member's own bytes as a <c>byte[]</c> 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.
/// </summary>
/// <param name="member">The member whose bytes are wanted.</param>
/// <param name="block">The block the span read.</param>
/// <returns>The member's bytes, starting at its own first byte.</returns>
private static byte[] SliceForMember(S7ReadSpanPlanner.Member member, byte[] block)
=> member.RelativeOffset == 0 && member.WidthBytes == block.Length
? block
: block[member.RelativeOffset..(member.RelativeOffset + member.WidthBytes)];
/// <summary>
/// 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 <see cref="ReadOneAsync"/>'s
/// lookup, but never throws — an unparseable address returns <c>false</c> so the caller can
/// route the tag to the per-tag path, where the <see cref="FormatException"/> surfaces
/// through the ladder exactly as it always has.
/// </summary>
/// <param name="tag">The tag whose address is wanted.</param>
/// <param name="addr">The parsed address, valid only when this returns <c>true</c>.</param>
/// <returns><c>true</c> when the address is known or parses.</returns>
private bool TryGetParsedAddress(S7TagDefinition tag, out S7ParsedAddress addr)
=> ParsedByRawPath.TryGetValue(tag.Name, out addr) || S7AddressParser.TryParse(tag.Address, out addr);
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <para>
/// <b>The health state is recorded, not applied.</b> It goes into
/// <paramref name="outcome"/> so the pass can write <see cref="Health"/> once at the
/// end, worst-state-wins — writing it here is what let a later good tag erase this
/// failure. The <c>_plcDead</c> / <see cref="MarkConnectionDeadIfFatal"/> side effects
/// stay IMMEDIATE: they govern whether the very next read reopens the handle, which
/// cannot wait for the pass to finish.
/// </para>
/// <para>
/// <see cref="OperationCanceledException"/> 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.
/// </para>
/// </remarks>
/// <param name="ex">The exception observed on a read.</param>
/// <param name="outcome">The pass's outcome accumulator this failure is recorded into.</param>
/// <param name="tagNames">The tags the failure belongs to (every member of a failed span), for the
/// once-per-tag refusal Warning.</param>
/// <returns>The OPC UA status code every affected tag must be stamped with.</returns>
private uint RecordReadFailure(Exception ex, ReadPassOutcome outcome, IEnumerable<string> tagNames)
{
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. The
// bytes came off the wire, so the CPU answered.
outcome.RecordAnswered();
return StatusBadOutOfRange;
case var _ when IsAccessDenied(ex):
// 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. Matched over the whole chain: S7.Net's READ
// paths raise the CPU's item error as a bare Exception, not a PlcException.
// It is ALSO an answer from a reachable CPU: LastSuccessfulRead must advance, or a
// factory-default CPU (every read denied) trips the comms-loss watchdog into a
// reconnect + re-subscribe every 90 s that can never fix a PUT/GET setting.
// Faulted still wins the pass (worst state), so the config alert is kept.
outcome.RecordAnswered();
outcome.RecordFailure(DriverState.Faulted,
"S7 access denied — enable PUT/GET communication in TIA Portal " +
$"(Protection & Security) for this CPU. PLC reported: {ex.Message}");
return StatusBadNotSupported;
case var _ when IsRefusal(ex):
// The CPU answered and refused the item (object does not exist, address out of
// range, type inconsistent...) — deterministic, so a retry, a reopen or a Degraded
// would all be wrong: per-tag Bad, a once-per-tag Warning, no health write, and it
// counts as answered so LastSuccessfulRead advances (R07:H4). An all-refused driver
// (wrong DB number) used to sit Degraded with a frozen timestamp and trip the
// comms-loss watchdog every 90 s against a reachable PLC.
outcome.RecordAnswered();
foreach (var name in tagNames) WarnRefusalOnce(name, "read", ex);
return StatusBadDeviceFailure;
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<object> ReadOneAsync(IS7Plc plc, S7TagDefinition tag, CancellationToken ct)
{
// Every tag pre-parses its address at init (ParsedByRawPath); the on-demand
// S7AddressParser.Parse is a defensive fallback for a tag not in that table. Parse throws
// FormatException on a bad address, which the caller's catch maps to BadCommunicationError.
var addr = ParsedByRawPath.TryGetValue(tag.Name, out var parsed)
? parsed
: S7AddressParser.Parse(tag.Address);
// Array path: a tag with a declared count >= 1 reads a CONTIGUOUS block of
// count × element-bytes in a SINGLE round-trip (Plc.ReadBytesAsync), then decodes each
// element from its big-endian slice into an element-typed CLR array. The scalar path
// (count null) is left byte-for-byte unchanged below. A count of 1 IS a valid 1-element
// array (the foundation materialises a [1] OPC UA array node when isArray:true).
if (tag.ArrayCount is >= 1)
return await ReadArrayAsync(plc, tag, addr, ct).ConfigureAwait(false);
// Wide/structured scalar path (Int64/UInt64/Float64/String/DateTime) AND the Timer/Counter
// areas: S7.Net's string ReadAsync only decodes 1/2/4-byte size suffixes, so neither family
// can go through the narrow path below. Read a contiguous byte block and decode it —
// mirrors the array path's buffer read. Timer/Counter route here on AREA (IsBufferType),
// so a Timer/Float64 tag reads its 2-byte S5TIME word here rather than falling through to
// the dead Float64 stub (which would surface BadNotSupported on every read).
if (IsBufferType(tag, addr))
return await ReadScalarBlockAsync(plc, tag, addr, ct).ConfigureAwait(false);
// S7.Net's string-based ReadAsync returns object where the boxed .NET type depends on
// the size suffix: DBX=bool, DBB=byte, DBW=ushort, DBD=uint. Our S7DataType enum
// specifies the SEMANTIC type (Int16 vs UInt16 vs Float32 etc.); the reinterpret below
// converts the raw unsigned boxed value into the requested type without issuing an
// extra PLC round-trip.
var raw = await plc.ReadAsync(tag.Address, ct).ConfigureAwait(false)
?? throw new System.IO.InvalidDataException($"S7.Net returned null for '{tag.Address}'");
return ReinterpretRawValue(tag, addr, raw);
}
/// <summary>
/// Reads a 1-D array tag as ONE contiguous block (<c>count × element-bytes</c>) via
/// S7.Net's buffer-based <c>Plc.ReadBytesAsync(DataType, db, startByteAdr, count, ct)</c>
/// — a single PLC round-trip, NOT <c>N</c> string reads — then hands the raw byte block
/// 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(IS7Plc plc, S7TagDefinition tag, S7ParsedAddress addr, CancellationToken ct)
{
var count = tag.ArrayCount!.Value;
var elementBytes = ElementByteSize(addr.Size);
var totalBytes = count * elementBytes;
// ReadBytesAsync addresses by (area, db, startByteOffset, byteCount). The parser already
// normalised the start to a BYTE offset (ByteOffset) for DB/M/I/Q; a Bit array starts at
// its byte and consumes one byte per element (byte-granular contiguous bit access). S7.Net
// transparently splits a > PDU-sized block into multiple wire requests, so the driver
// doesn't have to chunk.
var area = ToS7NetArea(addr.Area);
var block = await plc.ReadBytesAsync(area, addr.DbNumber, addr.ByteOffset, totalBytes, ct)
.ConfigureAwait(false)
?? throw new System.IO.InvalidDataException($"S7.Net returned null block for '{tag.Address}'");
return DecodeArrayBlock(tag, addr, block);
}
/// <summary>
/// True when this tag must be read/written through the byte-buffer codec rather than
/// S7.Net's string path. Two families route here:
/// <list type="bullet">
/// <item>the wide/structured types (Int64/UInt64/Float64/String/DateTime), which are
/// byte-anchored (<c>DBB</c>/<c>MB</c>/<c>IB</c>/<c>QB</c>) and decoded from a
/// contiguous block — S7.Net's string ReadAsync only understands 1/2/4-byte size
/// suffixes, so they can't use the narrow path;</item>
/// <item>the Timer/Counter AREAS — a Timer (<c>T{n}</c>) decodes a 2-byte S5TIME word
/// to a duration in seconds (<c>double</c>) and a Counter (<c>C{n}</c>) decodes a
/// 2-byte word to a count (<c>int</c>); both are read-only this phase. Routing them
/// here (rather than through the narrow path, which would hit the dead Float64 stub and
/// throw <c>BadNotSupported</c> on every read) closes the interim correctness hole.</item>
/// </list>
/// The init guard already enforces byte-addressing + the Timer→Float64 / Counter→Int32
/// type constraints, so by the time a tag reaches here it is well-formed.
/// </summary>
/// <param name="tag">Tag definition carrying the <see cref="S7DataType"/>.</param>
/// <param name="addr">Parsed address — its <see cref="S7Area"/> routes Timer/Counter into the seam regardless of <see cref="S7DataType"/>.</param>
/// <returns><c>true</c> if the tag routes through the byte-buffer codec.</returns>
internal static bool IsBufferType(S7TagDefinition tag, S7ParsedAddress addr) =>
addr.Area is S7Area.Timer or S7Area.Counter
|| S7TagShapeRules.IsByteAnchored(tag.DataType);
/// <summary>
/// Reads a wide/structured scalar as ONE contiguous byte block via S7.Net's
/// buffer-based <c>Plc.ReadBytesAsync(DataType, db, startByteAdr, count, ct)</c> (a single
/// 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(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
// Timer/Counter, addr.ByteOffset is the timer/counter NUMBER (db stays 0) and width is 2:
// S7.Net's Timer/Counter transport size makes the request "count" word a per-2-byte unit,
// so passing 2 reads exactly one timer/counter's 2-byte block (matching S7.Net's own
// VarType.Timer/Counter path, which sets byteLength = varCount × 2). See ScalarByteWidth.
var block = await plc.ReadBytesAsync(ToS7NetArea(addr.Area), addr.DbNumber, addr.ByteOffset, width, ct)
.ConfigureAwait(false)
?? throw new System.IO.InvalidDataException($"S7.Net returned null block for '{tag.Address}'");
return DecodeScalarBlock(tag, addr, block);
}
/// <summary>
/// Byte width of one wide/structured scalar (or one Timer/Counter word).
/// <b>The parsed AREA takes precedence over the tag's <see cref="S7DataType"/></b>: a
/// Timer/Counter address is always 2 bytes (one S5TIME / counter word), even though a
/// Timer tag is typed <see cref="S7DataType.Float64"/> (which would otherwise yield 8) and
/// a Counter tag <see cref="S7DataType.Int32"/>. For DB/M/I/Q areas the width follows the
/// DataType: Int64/UInt64/Float64/DateTime are 8 bytes; an S7 STRING occupies
/// <c>StringLength + 2</c> bytes (the two-byte header carries the declared max length and
/// the current actual length). Throws for any non-buffer type — defensive;
/// <see cref="IsBufferType"/> gates this so a non-buffer type never reaches here.
/// </summary>
/// <param name="tag">Tag definition carrying the <see cref="S7DataType"/> and (for strings) <c>StringLength</c>.</param>
/// <param name="addr">Parsed address — Timer/Counter areas force a 2-byte width regardless of <see cref="S7DataType"/>.</param>
/// <returns>The byte width to read for this scalar.</returns>
internal static int ScalarByteWidth(S7TagDefinition tag, S7ParsedAddress addr)
{
// Area precedence: Timer (S5TIME) and Counter are a single 16-bit word on the wire. The
// Timer tag's Float64 DataType describes the DECODED value (seconds), not the read width,
// so it must NOT drive an 8-byte read — that would mis-frame the timer word.
if (addr.Area is S7Area.Timer or S7Area.Counter)
return 2;
return tag.DataType switch
{
S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64 => 8,
S7DataType.DateTime => 8, // 8 = S7 DATE_AND_TIME/DT BCD (S7.Net.Types.DateTime; the 8-byte DT, not 12-byte DTL).
S7DataType.String => tag.StringLength + 2,
S7DataType.WString => S7ValueCodecs.WStringWidth(tag.StringLength),
S7DataType.Char => 1,
S7DataType.WChar or S7DataType.Date or S7DataType.S5Time => 2,
S7DataType.Tod or S7DataType.Time => 4,
S7DataType.Dtl => 12,
_ => throw new InvalidOperationException(
$"S7 ScalarByteWidth called for non-buffer type {tag.DataType} (tag '{tag.Name}')"),
};
}
/// <summary>
/// Pure decode step — turns a raw S7 (big-endian) byte block into the wide scalar's CLR
/// value (<c>long</c> / <c>ulong</c> / <c>double</c>), boxed as <see cref="object"/>. No
/// network I/O — factored out of <see cref="ReadScalarBlockAsync"/> so the codec is
/// unit-testable against a known block without a live PLC (S7.Net ships no in-process
/// fake). Mirrors <see cref="DecodeArrayBlock"/>.
/// </summary>
/// <param name="tag">Tag definition carrying the wide <see cref="S7DataType"/>.</param>
/// <param name="addr">Parsed address (carried for the error surface + the Timer/Counter seam).</param>
/// <param name="block">Raw contiguous byte block read from the PLC (length == <see cref="ScalarByteWidth"/>).</param>
/// <returns>The decoded scalar value boxed as <see cref="object"/>.</returns>
internal static object DecodeScalarBlock(S7TagDefinition tag, S7ParsedAddress addr, byte[] block)
{
// AREA precedence first (before the DataType switch): a Timer (T{n}) decodes its 2-byte
// S5TIME word to a duration in SECONDS via S7.Net's Timer.FromByteArray (returns double);
// a Counter (C{n}) exposes its 2-byte RAW WORD via Counter.FromByteArray (returns
// ushort, surfaced as a non-negative int to match the tag's Int32 type). Routing on Area
// here is essential: a Timer tag is typed Float64, so letting it fall to the Float64 arm
// below would ReadDoubleBigEndian over a 2-byte block = garbage/throw. Boxed explicitly so
// the box type is exactly double / int (not the switch-unified common type).
if (addr.Area is S7Area.Timer)
return (object)global::S7.Net.Types.Timer.FromByteArray(block);
if (addr.Area is S7Area.Counter)
{
// S7.Net Counter.FromByteArray returns the raw big-endian word ((bytes[0]<<8)|bytes[1]),
// without BCD reinterpretation. This preserves the raw value, not a verified PLC count:
// S7comm C-area response encoding still needs independent raw bytes and PLC-side
// count/binary/BCD references. CPU family alone does not prove that encoding.
// IEC counter integers in DB memory take the DB type codec, not this C-area path;
// S7-1500 also has legacy SIMATIC counters. See docs/drivers/S7.md for the evidence gate.
return (object)(int)global::S7.Net.Types.Counter.FromByteArray(block);
}
// Each numeric arm is boxed to object explicitly: a bare switch expression would unify
// long/ulong/double to their common type (double) and box THAT, mis-typing Int64/UInt64.
return tag.DataType switch
{
S7DataType.Int64 => (object)System.Buffers.Binary.BinaryPrimitives.ReadInt64BigEndian(block),
S7DataType.UInt64 => (object)System.Buffers.Binary.BinaryPrimitives.ReadUInt64BigEndian(block),
S7DataType.Float64 => System.Buffers.Binary.BinaryPrimitives.ReadDoubleBigEndian(block),
// S7 classic STRING: [maxLen byte][curLen byte][chars…]. S7.Net's S7String.FromByteArray
// reads the two-byte header and returns exactly curLen ASCII chars, ignoring the reserved
// padding past curLen — it validates curLen against the block, so no extra guard is needed.
S7DataType.String => global::S7.Net.Types.S7String.FromByteArray(block),
// S7 classic DATE_AND_TIME (DT): 8-byte BCD value (year/month/day/hour/min/sec + ms +
// day-of-week nibble). S7.Net's DateTime.FromByteArray reads the BCD fields and returns a
// System.DateTime; the DT range is 1990–2089 and round-trips to the millisecond. Boxed as
// System.DateTime explicitly (the cast pins the box type, consistent with the numeric arms).
S7DataType.DateTime => (object)global::S7.Net.Types.DateTime.FromByteArray(block),
// The codecs below live in S7ValueCodecs (pure, big-endian); a malformed PLC buffer throws
// S7ValueRangeException → BadOutOfRange on the read. TOD/TIME/S5TIME surface as Int32 ms.
S7DataType.WString => S7ValueCodecs.DecodeWString(block),
S7DataType.Char => S7ValueCodecs.DecodeChar(block),
S7DataType.WChar => S7ValueCodecs.DecodeWChar(block),
S7DataType.Dtl => (object)S7ValueCodecs.DecodeDtl(block),
S7DataType.Date => (object)S7ValueCodecs.DecodeDate(block),
S7DataType.Tod => (object)S7ValueCodecs.DecodeTod(block),
S7DataType.Time => (object)S7ValueCodecs.DecodeTime(block),
S7DataType.S5Time => (object)S7ValueCodecs.DecodeS5Time(block),
_ => throw new System.IO.InvalidDataException(
$"S7 scalar Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address " +
$"'{tag.Address}' parsed as Size={addr.Size}"),
};
}
/// <summary>
/// Pure encode step — turns the caller's value into the raw S7 (big-endian) byte block
/// for a wide scalar write. No network I/O — factored out of
/// <see cref="WriteScalarBlockAsync"/> so the codec is unit-testable (mirrors
/// <see cref="BoxValueForWrite"/>).
/// </summary>
/// <param name="tag">Tag definition carrying the wide <see cref="S7DataType"/>.</param>
/// <param name="value">Value to encode (coerced via <c>Convert.To*</c>).</param>
/// <returns>The big-endian byte block to write.</returns>
internal static byte[] EncodeScalarBlock(S7TagDefinition tag, object? value)
{
// Timer/Counter are READ-ONLY this phase. A Timer tag is typed Float64 and a Counter tag
// Int32, so the DataType switch below would otherwise hand them a (wrong, 8/wide-byte)
// encode arm — guard on the parsed AREA first and refuse the write. The address parses to
// an S7Area without I/O; TryParse keeps a malformed address from masking the read-only
// contract (a bad address falls through to the DataType arms and is caught upstream).
if (S7AddressParser.TryParse(tag.Address, out var addr)
&& addr.Area is S7Area.Timer or S7Area.Counter)
{
throw new NotSupportedException(
$"S7 Timer/Counter writes are read-only this phase (tag '{tag.Name}' at '{tag.Address}').");
}
switch (tag.DataType)
{
case S7DataType.Int64:
{
var b = new byte[8];
System.Buffers.Binary.BinaryPrimitives.WriteInt64BigEndian(b, Convert.ToInt64(value));
return b;
}
case S7DataType.UInt64:
{
var b = new byte[8];
System.Buffers.Binary.BinaryPrimitives.WriteUInt64BigEndian(b, Convert.ToUInt64(value));
return b;
}
case S7DataType.Float64:
{
var b = new byte[8];
System.Buffers.Binary.BinaryPrimitives.WriteDoubleBigEndian(b, Convert.ToDouble(value));
return b;
}
case S7DataType.String:
// S7.Net's S7String.ToByteArray builds [maxLen=StringLength][curLen][chars…] and pads
// the result to the full reserved field (StringLength + 2 bytes) — exactly the width
// ReadScalarBlockAsync read, so WriteBytesAsync writes the whole reserved STRING. A value
// longer than StringLength throws ArgumentException (S7.Net rejects overflow; we do NOT
// silently truncate). A null value encodes as the empty string.
// Surfaced as S7ValueRangeException (→ BadOutOfRange) like the WSTRING codec — a
// client value error, not a comms fault (the raw ArgumentException used to land in the
// generic catch as BadCommunicationError + Degraded).
try { return global::S7.Net.Types.S7String.ToByteArray(Convert.ToString(value) ?? "", tag.StringLength); }
catch (ArgumentException ex) { throw new S7ValueRangeException($"S7 STRING value does not fit the declared {tag.StringLength}-character field: {ex.Message}"); }
case S7DataType.DateTime:
// S7.Net's DateTime.ToByteArray builds the 8-byte DT BCD block. It validates the
// 1990–2089 DT range and throws ArgumentOutOfRangeException for an out-of-range year —
// we surface that (no silent clamp). Value coerced via Convert.ToDateTime (accepts a
// System.DateTime or a parseable timestamp string).
// The range refusal maps to BadOutOfRange (see STRING above).
var dt = Convert.ToDateTime(value);
try { return global::S7.Net.Types.DateTime.ToByteArray(dt); }
catch (ArgumentOutOfRangeException ex) { throw new S7ValueRangeException($"S7 DATE_AND_TIME value {dt:O} is outside the DT range (1990–2089): {ex.Message}"); }
// Writes refuse (S7ValueRangeException → BadOutOfRange) rather than truncate or clamp.
case S7DataType.WString: return S7ValueCodecs.EncodeWString(value, tag.StringLength);
case S7DataType.Char: return S7ValueCodecs.EncodeChar(value);
case S7DataType.WChar: return S7ValueCodecs.EncodeWChar(value);
case S7DataType.Dtl: return S7ValueCodecs.EncodeDtl(value);
case S7DataType.Date: return S7ValueCodecs.EncodeDate(value);
case S7DataType.Tod: return S7ValueCodecs.EncodeTod(value);
case S7DataType.Time: return S7ValueCodecs.EncodeTime(value);
case S7DataType.S5Time: return S7ValueCodecs.EncodeS5Time(value);
default:
throw new InvalidOperationException(
$"S7 EncodeScalarBlock called for non-buffer type {tag.DataType} (tag '{tag.Name}')");
}
}
/// <summary>Width in bytes of one array element for the given access size. Bit elements are
/// byte-granular over the wire (one byte per bool), so they cost 1 byte each.</summary>
/// <param name="size">The parsed access width.</param>
/// <returns>Element byte size: Bit/Byte = 1, Word = 2, DWord = 4.</returns>
internal static int ElementByteSize(S7Size size) => size switch
{
S7Size.Bit => 1,
S7Size.Byte => 1,
S7Size.Word => 2,
S7Size.DWord => 4,
_ => throw new InvalidOperationException($"Unknown S7Size {size}"),
};
/// <summary>
/// Maps the driver's <see cref="S7Area"/> to S7.Net's <c>DataType</c> for the
/// buffer-based block read. Timer/Counter map to S7.Net's <c>Timer</c>/<c>Counter</c>
/// transport types — the <c>ScalarByteWidth</c> 2-byte read uses these to read one
/// timer/counter word (the array path never reaches Timer/Counter: wide-type arrays are
/// rejected at init and Timer/Counter are always scalar).
/// </summary>
private static S7NetDataType ToS7NetArea(S7Area area) => area switch
{
S7Area.DataBlock => S7NetDataType.DataBlock,
S7Area.Memory => S7NetDataType.Memory,
S7Area.Input => S7NetDataType.Input,
S7Area.Output => S7NetDataType.Output,
S7Area.Timer => S7NetDataType.Timer,
S7Area.Counter => S7NetDataType.Counter,
_ => throw new NotSupportedException($"S7 area {area} has no S7.Net DataType mapping"),
};
/// <summary>
/// Pure decode loop — turns a raw S7 (big-endian) byte block into an element-typed CLR
/// array (<c>short[]</c> / <c>ushort[]</c> / <c>int[]</c> / <c>uint[]</c> / <c>float[]</c>
/// / <c>byte[]</c> / <c>bool[]</c>), boxed as <see cref="object"/>. No network I/O —
/// factored out of <see cref="ReadArrayAsync"/> so the block-decode is unit-testable
/// against a known byte block without a live PLC (S7.Net ships no in-process fake).
/// Each element is read from its <c>i × element-bytes</c> slice using S7 big-endian byte
/// order, identical to the per-element semantics of <see cref="ReinterpretRawValue"/>.
/// </summary>
/// <param name="tag">Tag definition carrying the element <see cref="S7DataType"/> and array count.</param>
/// <param name="addr">Parsed address carrying the access <see cref="S7Size"/>.</param>
/// <param name="block">Raw contiguous byte block read from the PLC (length == count × element-bytes).</param>
/// <returns>An element-typed CLR array boxed as <see cref="object"/>.</returns>
internal static object DecodeArrayBlock(S7TagDefinition tag, S7ParsedAddress addr, byte[] block)
{
var count = tag.ArrayCount!.Value;
var elementBytes = ElementByteSize(addr.Size);
switch (tag.DataType, addr.Size)
{
case (S7DataType.Bool, S7Size.Bit):
{
var a = new bool[count];
for (var i = 0; i < count; i++)
a[i] = (block[i] & 0x01) != 0;
return a;
}
case (S7DataType.Byte, S7Size.Byte):
{
var a = new byte[count];
for (var i = 0; i < count; i++)
a[i] = block[i];
return a;
}
case (S7DataType.UInt16, S7Size.Word):
{
var a = new ushort[count];
for (var i = 0; i < count; i++)
a[i] = ReadBeUInt16(block, i * elementBytes);
return a;
}
case (S7DataType.Int16, S7Size.Word):
{
var a = new short[count];
for (var i = 0; i < count; i++)
a[i] = unchecked((short)ReadBeUInt16(block, i * elementBytes));
return a;
}
case (S7DataType.UInt32, S7Size.DWord):
{
var a = new uint[count];
for (var i = 0; i < count; i++)
a[i] = ReadBeUInt32(block, i * elementBytes);
return a;
}
case (S7DataType.Int32, S7Size.DWord):
{
var a = new int[count];
for (var i = 0; i < count; i++)
a[i] = unchecked((int)ReadBeUInt32(block, i * elementBytes));
return a;
}
case (S7DataType.Float32, S7Size.DWord):
{
var a = new float[count];
for (var i = 0; i < count; i++)
a[i] = BitConverter.UInt32BitsToSingle(ReadBeUInt32(block, i * elementBytes));
return a;
}
case (S7DataType.Int64, _):
case (S7DataType.UInt64, _):
case (S7DataType.Float64, _):
case (S7DataType.String, _):
case (S7DataType.DateTime, _):
case (S7DataType.WString, _):
case (S7DataType.Char, _):
case (S7DataType.WChar, _):
case (S7DataType.Dtl, _):
case (S7DataType.Date, _):
case (S7DataType.Tod, _):
case (S7DataType.Time, _):
case (S7DataType.S5Time, _):
throw new NotSupportedException(
$"S7 array reads of {tag.DataType} land in a follow-up PR");
default:
throw new System.IO.InvalidDataException(
$"S7 array Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address " +
$"'{tag.Address}' parsed as Size={addr.Size}");
}
}
/// <summary>Reads a big-endian 16-bit word from <paramref name="block"/> at <paramref name="offset"/>.</summary>
private static ushort ReadBeUInt16(byte[] block, int offset) =>
(ushort)((block[offset] << 8) | block[offset + 1]);
/// <summary>Reads a big-endian 32-bit dword from <paramref name="block"/> at <paramref name="offset"/>.</summary>
private static uint ReadBeUInt32(byte[] block, int offset) =>
((uint)block[offset] << 24) | ((uint)block[offset + 1] << 16)
| ((uint)block[offset + 2] << 8) | block[offset + 3];
/// <summary>
/// Byte width one narrow scalar (Bit/Byte/Word/DWord) occupies inside a contiguous block —
/// the stride a span planner uses to size and slice a group read. Delegates to
/// <see cref="ElementByteSize"/> so the scalar and array paths can never disagree about a
/// size's width (a Bit costs a whole byte: S7 block reads are byte-granular).
/// </summary>
/// <param name="addr">Parsed address carrying the access <see cref="S7Size"/>.</param>
/// <returns>Byte width: Bit/Byte = 1, Word = 2, DWord = 4.</returns>
internal static int NarrowScalarWidth(S7ParsedAddress addr) => ElementByteSize(addr.Size);
/// <summary>
/// Pure decode step — turns the tag's own slice of a contiguous big-endian block into the
/// SEMANTIC CLR value declared by its <see cref="S7DataType"/>, boxed as
/// <see cref="object"/>. No network I/O and no driver state: this is the span-read twin of
/// <see cref="ReinterpretRawValue"/> (which reinterprets the boxed value S7.Net's
/// per-tag string <c>ReadAsync</c> returns), and it deliberately mirrors that method's
/// sign/width/conversion semantics arm for arm so a tag reads identically whether it was
/// served by one wire request of its own or by one block read shared with its neighbours.
/// </summary>
/// <remarks>
/// <paramref name="slice"/> is positioned at the tag's OWN first byte —
/// <c>slice[0]</c> is the tag's first byte, so <see cref="S7ParsedAddress.ByteOffset"/> is
/// the caller's business and is never re-applied here. Only the leading
/// <see cref="NarrowScalarWidth"/> bytes are consumed; a longer slice (the rest of the
/// block) is fine. Wide/structured types (Int64/UInt64/Float64/String/DateTime) and the
/// Timer/Counter areas are NOT narrow — they route through
/// <see cref="DecodeScalarBlock"/> — and fall into the mismatch arm here so a mis-routed
/// tag fails loudly instead of decoding garbage from a truncated slice.
/// </remarks>
/// <param name="tag">Tag definition carrying the narrow <see cref="S7DataType"/>.</param>
/// <param name="addr">Parsed address carrying the access <see cref="S7Size"/> and (for Bool) the bit position.</param>
/// <param name="slice">The tag's slice of the block, big-endian, at least <see cref="NarrowScalarWidth"/> bytes long.</param>
/// <returns>The decoded value boxed as <see cref="object"/>, with the same box type <see cref="ReinterpretRawValue"/> produces.</returns>
internal static object DecodeNarrowScalar(S7TagDefinition tag, S7ParsedAddress addr, ReadOnlySpan<byte> slice)
{
// Each arm boxes explicitly: a bare switch expression would unify bool/byte/ushort/short/
// uint/int/float to a common type and box THAT, mis-typing every narrow tag (the same trap
// DecodeScalarBlock documents). The box type is part of the contract — the publish pipeline
// and the OPC UA variable's DataType both key off it.
switch (tag.DataType, addr.Size)
{
// DBX / M{n}.{b}: S7.Net's string read returns the already-extracted bool. From a block
// we extract it ourselves at the parsed bit position (0-7, guaranteed by the parser).
case (S7DataType.Bool, S7Size.Bit):
return (object)((slice[0] & (1 << addr.BitOffset)) != 0);
// DBB: the whole byte, unsigned (S7.Net boxes a byte).
case (S7DataType.Byte, S7Size.Byte):
return (object)slice[0];
// DBW: one big-endian word. UInt16 is the raw word; Int16 is the SAME bits reinterpreted
// unchecked (0x8000 => -32768), exactly as ReinterpretRawValue casts S7.Net's ushort.
case (S7DataType.UInt16, S7Size.Word):
return (object)System.Buffers.Binary.BinaryPrimitives.ReadUInt16BigEndian(slice);
case (S7DataType.Int16, S7Size.Word):
return (object)unchecked((short)System.Buffers.Binary.BinaryPrimitives.ReadUInt16BigEndian(slice));
// DBD: one big-endian dword. UInt32 raw; Int32 the same bits unchecked; Float32 the same
// bits reinterpreted as IEEE-754 single (never a numeric conversion).
case (S7DataType.UInt32, S7Size.DWord):
return (object)System.Buffers.Binary.BinaryPrimitives.ReadUInt32BigEndian(slice);
case (S7DataType.Int32, S7Size.DWord):
return (object)unchecked((int)System.Buffers.Binary.BinaryPrimitives.ReadUInt32BigEndian(slice));
case (S7DataType.Float32, S7Size.DWord):
return (object)BitConverter.UInt32BitsToSingle(
System.Buffers.Binary.BinaryPrimitives.ReadUInt32BigEndian(slice));
default:
throw new System.IO.InvalidDataException(
$"S7 narrow-span Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address " +
$"'{tag.Address}' parsed as Size={addr.Size}");
}
}
/// <summary>
/// Pure reinterpret step — converts the boxed value that S7.Net returns (always an
/// unsigned type: <c>bool</c>, <c>byte</c>, <c>ushort</c>, <c>uint</c>) into the
/// SEMANTIC type declared by the tag's <see cref="S7DataType"/>. No network I/O.
/// Factored out of <see cref="ReadOneAsync"/> so it can be exercised in unit tests
/// without a live PLC.
/// </summary>
/// <param name="tag">Tag definition containing type information.</param>
/// <param name="addr">Parsed tag address.</param>
/// <param name="raw">Raw value from S7.Net.</param>
/// <returns>The reinterpreted value in the target semantic type.</returns>
internal static object ReinterpretRawValue(S7TagDefinition tag, S7ParsedAddress addr, object raw) =>
(tag.DataType, addr.Size, raw) switch
{
(S7DataType.Bool, S7Size.Bit, bool b) => b,
(S7DataType.Byte, S7Size.Byte, byte by) => by,
(S7DataType.UInt16, S7Size.Word, ushort u16) => u16,
(S7DataType.Int16, S7Size.Word, ushort u16) => unchecked((short)u16),
(S7DataType.UInt32, S7Size.DWord, uint u32) => u32,
(S7DataType.Int32, S7Size.DWord, uint u32) => unchecked((int)u32),
(S7DataType.Float32, S7Size.DWord, uint u32) => BitConverter.UInt32BitsToSingle(u32),
// Unreachable defensive backstop — every wide type (Int64/UInt64/Float64/String/
// DateTime) AND every Timer/Counter tag routes through the byte-buffer path
// (IsBufferType → ReadScalarBlockAsync → DecodeScalarBlock) before ReinterpretRawValue
// is ever consulted. Kept (not deleted) so a future regression that mis-routes a wide
// type to the narrow path fails loudly here instead of silently mis-decoding.
(S7DataType.Int64, _, _) => throw new NotSupportedException("S7 Int64 is unreachable here — wide types route through the byte-buffer path (DecodeScalarBlock)"),
(S7DataType.UInt64, _, _) => throw new NotSupportedException("S7 UInt64 is unreachable here — wide types route through the byte-buffer path (DecodeScalarBlock)"),
(S7DataType.Float64, _, _) => throw new NotSupportedException("S7 Float64 (LReal) is unreachable here — wide types route through the byte-buffer path (DecodeScalarBlock)"),
(S7DataType.String, _, _) => throw new NotSupportedException("S7 STRING is unreachable here — wide types route through the byte-buffer path (DecodeScalarBlock)"),
(S7DataType.DateTime, _, _) => throw new NotSupportedException("S7 DateTime is unreachable here — wide types route through the byte-buffer path (DecodeScalarBlock)"),
_ => throw new System.IO.InvalidDataException(
$"S7 Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " +
$"parsed as Size={addr.Size}; S7.Net returned {raw.GetType().Name}"),
};
// ---- IWritable ----
/// <inheritdoc />
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
{
// 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);
RequireInitialized();
var results = new WriteResult[writes.Count];
// Health is decided ONCE, at the end of the batch, worst-state-wins — see WritePassOutcome.
var outcome = new WritePassOutcome();
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
var gen = CurrentGeneration; // see ReadAsync
try
{
RequireInitialized();
// (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);
}
// Rethrow ONLY caller cancellation — a timeout-born OCE degrades the batch below rather
// than escaping as teardown (defense-in-depth, mirrors ReadAsync).
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
catch (Exception ex)
{
for (var i = 0; i < writes.Count; i++)
results[i] = new WriteResult(StatusBadCommunicationError);
outcome.RecordFailure(DriverState.Degraded, ex.Message);
return results;
}
for (var i = 0; i < writes.Count; i++)
{
var w = writes[i];
if (!_resolver.TryResolve(w.FullReference, out var tag))
{
results[i] = new WriteResult(StatusBadNodeIdUnknown);
continue;
}
if (!tag.Writable)
{
results[i] = new WriteResult(StatusBadNotWritable);
continue;
}
// Timer/Counter write guard: an equipment tag authored writable=true (S7 mapper
// honours the "writable" key, and node-level auth governs writes) whose address is a
// Timer/Counter would bypass the !Writable gate above and reach EncodeScalarBlock,
// which throws NotSupportedException → BadNotSupported. The docs say Timer/Counter
// writes return BadNotWritable. Detect the area here before the call so it
// consistently returns BadNotWritable (init guard-d also rejects a writable
// Timer/Counter, but the mapper doesn't run that guard).
// TryParse (not Parse) so a malformed address falls through to the existing
// error path rather than throwing here.
if (ParsedByRawPath.TryGetValue(tag.Name, out var tcParsed)
? tcParsed.Area is S7Area.Timer or S7Area.Counter
: (S7AddressParser.TryParse(tag.Address, out tcParsed) && tcParsed.Area is S7Area.Timer or S7Area.Counter))
{
results[i] = new WriteResult(StatusBadNotWritable);
continue;
}
// Fail the rest of the batch fast once a write marked the handle dead (R07:P1): the
// next call reopens; this batch must not serialise one deadline per remaining write.
if (_plcDead)
{
results[i] = new WriteResult(StatusBadCommunicationError);
continue;
}
try
{
await WriteOneAsync(plc, tag, w.Value, cancellationToken).ConfigureAwait(false);
results[i] = new WriteResult(0u);
outcome.RecordSuccess();
}
catch (OperationCanceledException)
{
// Cancellation observed mid-PDU abandons a half-written ISO-on-TCP request on
// the single gated stream — the handle must be reopened, not reused.
// Mark dead (we hold _gate) and propagate: the caller cancelled, nobody consumes
// the batch, and Release() still runs in finally. Marking is unconditional on
// OCE — a false positive costs one cheap reopen versus permanent desync.
_plcDead = true;
throw;
}
catch (NotSupportedException)
{
results[i] = new WriteResult(StatusBadNotSupported);
}
catch (S7ValueRangeException)
{
// Value unrepresentable in the S7 type (string over its declared max, DATE < 1990…):
// refused with nothing sent — a client error, no health impact.
results[i] = new WriteResult(StatusBadOutOfRange);
}
catch (S7WriteValueException)
{
// Client-supplied value of the wrong shape — not a device or comms fault.
results[i] = new WriteResult(StatusBadTypeMismatch);
}
catch (Exception ex) when (IsAccessDenied(ex))
{
// PUT/GET-disabled / access-protection on write — same permanent
// configuration fault as on read. BadNotSupported +
// a config-alert health state, not a transient device failure.
results[i] = new WriteResult(StatusBadNotSupported);
// An answer (see the read ladder): advances LastSuccessfulRead. RecordAnswered sets
// AnyAnswered only, so the sticky _writeFault still clears only on a real success.
outcome.RecordAnswered();
outcome.RecordFailure(DriverState.Faulted,
"S7 access denied — enable PUT/GET communication in TIA Portal " +
$"(Protection & Security) for this CPU. PLC reported: {ex.Message}");
}
catch (Exception ex) when (IsRefusal(ex))
{
// The CPU answered and refused the item — per-tag Bad, once-per-tag Warning, no
// Degraded, and it counts as answered (R07:H4).
results[i] = new WriteResult(StatusBadDeviceFailure);
outcome.RecordAnswered();
WarnRefusalOnce(tag.Name, "write", ex);
}
catch (PlcException pex)
{
// 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). A connection-fatal PlcException also marks the handle
// dead so the next call reopens.
MarkConnectionDeadIfFatal(pex);
results[i] = new WriteResult(StatusBadDeviceFailure);
outcome.RecordFailure(DriverState.Degraded, pex.Message);
}
catch (Exception ex)
{
// Socket/timeout/conversion failure. Map to BadCommunicationError (not
// BadInternalError) for transport faults; degrade health, and mark the handle
// dead when it's a connection loss.
MarkConnectionDeadIfFatal(ex);
results[i] = new WriteResult(StatusBadCommunicationError);
outcome.RecordFailure(DriverState.Degraded, ex.Message);
}
}
}
finally
{
// THE single health write of the batch (a finally, so an OCE mid-batch still flushes the
// evidence gathered so far — a pass that observed nothing writes nothing).
ApplyWritePassHealth(gen, outcome);
_gate.Release();
}
return results;
}
/// <summary>
/// Publishes one write batch's verdict: worst state wins over every outcome the
/// batch recorded, so a Faulted access-denied item is not erased by a later Degraded or
/// successful sibling. Maintains <see cref="_writeFault"/>, the sticky Faulted a read pass
/// must not erase. Caller holds <see cref="_gate"/>.
/// </summary>
/// <param name="gen">The lifecycle generation the batch ran in; a superseded batch writes nothing.</param>
/// <param name="outcome">The batch's accumulated outcomes.</param>
private void ApplyWritePassHealth(long gen, WritePassOutcome outcome)
{
if (!outcome.HasObservations || !IsCurrentGeneration(gen)) return;
var worst = outcome.WorstFailure;
var priorWriteFault = _writeFault;
if (worst == DriverState.Faulted)
_writeFault = (DriverState.Faulted, outcome.WorstError!);
else if (outcome.AnySuccess)
_writeFault = null; // a write got through — the protection fault is gone
var answeredAt = outcome.AnyAnswered ? UtcNow() : (DateTime?)null;
if (worst == DriverState.Healthy)
{
// Clean batch (successes and/or refusals): the CPU answered, so LastSuccessfulRead
// advances, and a non-Healthy state the WRITE side owns is retired — a Faulted only by a
// real success (a refusal does not prove PUT/GET is enabled), a write-side comms Degraded
// by any answer. A state the read side owns (its own message) is the read pass's to clear.
var ownedFault = outcome.AnySuccess && _writeFault is null ? priorWriteFault?.Message : null;
var ownedDegraded = _writeOwnedError;
_writeOwnedError = null;
TryUpdateHealth(gen, h =>
{
var lsr = answeredAt ?? h.LastSuccessfulRead;
if ((h.State == DriverState.Faulted && ownedFault is not null && h.LastError == ownedFault)
|| (h.State == DriverState.Degraded && ownedDegraded is not null && h.LastError == ownedDegraded))
return new DriverHealth(DriverState.Healthy, lsr, null);
return lsr == h.LastSuccessfulRead ? null : h with { LastSuccessfulRead = lsr };
});
return;
}
var state = worst;
var error = outcome.WorstError;
// A Degraded-only batch must not downgrade a still-sticky Faulted.
if (_writeFault is { } wf && ReadPassOutcome.Rank(wf.State) > ReadPassOutcome.Rank(state))
{
state = wf.State;
error = wf.Message;
}
if (state == DriverState.Degraded) _writeOwnedError = error;
TryUpdateHealth(gen, h => new DriverHealth(state, answeredAt ?? h.LastSuccessfulRead, error));
}
/// <summary>
/// The outcomes ONE <see cref="WriteAsync"/> batch observed — the write-side sibling of
/// <see cref="ReadPassOutcome"/> (same worst-state-wins ranking and severity-pinned message),
/// accumulated so the batch writes <see cref="Health"/> once instead of per item.
/// </summary>
private sealed class WritePassOutcome
{
private readonly ReadPassOutcome _inner = new();
/// <summary>True once any item in the batch wrote successfully.</summary>
public bool AnySuccess => _inner.AnySuccess;
/// <summary>The worst failure recorded; Healthy means none.</summary>
public DriverState WorstFailure => _inner.WorstFailure;
/// <summary>The message explaining <see cref="WorstFailure"/>.</summary>
public string? WorstError => _inner.WorstError;
/// <summary>True once the batch observed a success, a refusal or a failure.</summary>
public bool HasObservations => _inner.HasObservations || AnyAnswered;
/// <summary>True once the CPU answered any item (a success or a refusal) — proof of a live device.</summary>
public bool AnyAnswered { get; private set; }
/// <summary>Records one successful write.</summary>
public void RecordSuccess()
{
_inner.RecordSuccess();
AnyAnswered = true;
}
/// <summary>Records a write the CPU answered by refusing it: not a success, not a failure.</summary>
public void RecordAnswered() => AnyAnswered = true;
/// <summary>Records one failed write.</summary>
/// <param name="state">The state the failure would have stamped alone.</param>
/// <param name="message">The operator-facing message.</param>
public void RecordFailure(DriverState state, string message) => _inner.RecordFailure(state, message);
}
private async Task WriteOneAsync(IS7Plc plc, S7TagDefinition tag, object? value, CancellationToken ct)
{
// Parse the address the same way ReadOneAsync does: every tag pre-parses at init
// (ParsedByRawPath), with an on-demand parse as a defensive fallback. Needed here so the
// wide-type write can byte-address the block (the narrow path below addresses by the raw
// address string instead).
var addr = ParsedByRawPath.TryGetValue(tag.Name, out var parsed)
? parsed
: S7AddressParser.Parse(tag.Address);
// Array path: ONE contiguous WriteBytesAsync of count × element-bytes — the inverse
// of ReadArrayAsync. Bool / wide-type arrays throw NotSupportedException (→ BadNotSupported);
// a wrong-shaped value throws S7WriteValueException (→ BadTypeMismatch, no health impact).
if (tag.ArrayCount is >= 1)
{
var arrayBytes = EncodeArrayBlock(tag, addr, value);
await plc.WriteBytesAsync(ToS7NetArea(addr.Area), addr.DbNumber, addr.ByteOffset, arrayBytes, ct)
.ConfigureAwait(false);
return;
}
// Wide/structured scalar path: encode the value to a big-endian byte block and write it
// at the start byte via S7.Net's buffer-based WriteBytesAsync. Mirrors the read seam;
// the narrow string-based write below stays unchanged for 1/2/4-byte types.
if (IsBufferType(tag, addr))
{
await WriteScalarBlockAsync(plc, tag, addr, value, ct).ConfigureAwait(false);
return;
}
// S7.Net's Plc.WriteAsync(string address, object value) expects the boxed value to
// match the address's size-suffix type: DBX=bool, DBB=byte, DBW=ushort, DBD=uint.
// Our S7DataType lets the caller pass short/int/float; convert to the unsigned
// wire representation before handing off.
var boxed = BoxValueForWrite(tag.DataType, value);
await plc.WriteAsync(tag.Address, boxed, ct).ConfigureAwait(false);
}
/// <summary>
/// Pure encode loop — the inverse of <see cref="DecodeArrayBlock"/>: turns the caller's array
/// into the contiguous big-endian block for a single <c>WriteBytesAsync</c>. No network I/O.
/// Accepts any non-string <see cref="System.Collections.IEnumerable"/> (OPC UA hands the server
/// a typed array, but a boxed <c>object[]</c> is equally fine); each element is coerced with
/// <c>Convert.To*</c> like the scalar path.
/// </summary>
/// <param name="tag">Tag definition carrying the element type and declared array length.</param>
/// <param name="addr">Parsed address carrying the access <see cref="S7Size"/>.</param>
/// <param name="value">The array to encode; must hold exactly <c>ArrayCount</c> elements.</param>
/// <returns>The big-endian block, <c>count × element-bytes</c> long.</returns>
/// <exception cref="NotSupportedException">Bool or wide-type element arrays.</exception>
/// <exception cref="S7WriteValueException">The value is not an array, has the wrong length, or an
/// element does not convert to the element type.</exception>
internal static byte[] EncodeArrayBlock(S7TagDefinition tag, S7ParsedAddress addr, object? value)
{
var count = tag.ArrayCount!.Value;
switch (tag.DataType, addr.Size)
{
case (S7DataType.Bool, _):
throw new NotSupportedException(
$"S7 Bool array writes are not supported (tag '{tag.Name}'): a byte-granular write would clobber neighbouring bits.");
case (S7DataType.Int64, _):
case (S7DataType.UInt64, _):
case (S7DataType.Float64, _):
case (S7DataType.String, _):
case (S7DataType.DateTime, _):
case (S7DataType.WString, _):
case (S7DataType.Char, _):
case (S7DataType.WChar, _):
case (S7DataType.Dtl, _):
case (S7DataType.Date, _):
case (S7DataType.Tod, _):
case (S7DataType.Time, _):
case (S7DataType.S5Time, _):
throw new NotSupportedException($"S7 array writes of {tag.DataType} are not supported (tag '{tag.Name}').");
case (S7DataType.Byte, S7Size.Byte):
case (S7DataType.UInt16, S7Size.Word):
case (S7DataType.Int16, S7Size.Word):
case (S7DataType.UInt32, S7Size.DWord):
case (S7DataType.Int32, S7Size.DWord):
case (S7DataType.Float32, S7Size.DWord):
break;
default:
throw new System.IO.InvalidDataException(
$"S7 array Write type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address " +
$"'{tag.Address}' parsed as Size={addr.Size}");
}
if (value is string || value is not System.Collections.IEnumerable items)
throw new S7WriteValueException($"S7 array tag '{tag.Name}' requires an array value.");
var elements = new List<object?>(count);
foreach (var e in items) elements.Add(e);
// Convert.ToXxx(null) is 0 — a null element must not silently write zero to the PLC.
if (elements.Contains(null))
throw new S7WriteValueException($"S7 array tag '{tag.Name}': an element is null.");
if (elements.Count != count)
throw new S7WriteValueException(
$"S7 array tag '{tag.Name}' has {count} elements but the write supplied {elements.Count}.");
var elementBytes = ElementByteSize(addr.Size);
var block = new byte[count * elementBytes];
try
{
for (var i = 0; i < count; i++)
{
var span = block.AsSpan(i * elementBytes, elementBytes);
var element = elements[i];
switch (tag.DataType)
{
case S7DataType.Byte: span[0] = Convert.ToByte(element); break;
case S7DataType.UInt16: System.Buffers.Binary.BinaryPrimitives.WriteUInt16BigEndian(span, Convert.ToUInt16(element)); break;
case S7DataType.Int16: System.Buffers.Binary.BinaryPrimitives.WriteInt16BigEndian(span, Convert.ToInt16(element)); break;
case S7DataType.UInt32: System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(span, Convert.ToUInt32(element)); break;
case S7DataType.Int32: System.Buffers.Binary.BinaryPrimitives.WriteInt32BigEndian(span, Convert.ToInt32(element)); break;
case S7DataType.Float32: System.Buffers.Binary.BinaryPrimitives.WriteSingleBigEndian(span, Convert.ToSingle(element)); break;
}
}
}
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException)
{
throw new S7WriteValueException(
$"S7 array tag '{tag.Name}': an element does not convert to {tag.DataType} ({ex.Message}).");
}
return block;
}
/// <summary>
/// A caller-supplied write value the driver cannot encode (wrong shape / length / element
/// type). A client error — maps to <c>BadTypeMismatch</c> and does not touch driver health.
/// </summary>
internal sealed class S7WriteValueException(string message) : Exception(message);
/// <summary>
/// Writes a wide/structured scalar as ONE contiguous byte block via S7.Net's
/// buffer-based <c>Plc.WriteBytesAsync(DataType, db, startByteAdr, byte[] value, ct)</c>.
/// 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(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)
.ConfigureAwait(false);
}
/// <summary>
/// Pure boxing step — converts the caller's value into the unsigned wire type that
/// S7.Net's <c>Plc.WriteAsync</c> expects for each address size (bool → bool, byte
/// → byte, short → ushort, int → uint, float → uint-bits). No network I/O.
/// Factored out of <see cref="WriteOneAsync"/> so it can be exercised in unit tests
/// without a live PLC.
/// </summary>
/// <param name="dataType">Target S7 data type.</param>
/// <param name="value">Value to box.</param>
/// <returns>The boxed value in the wire type expected by S7.Net.</returns>
internal static object BoxValueForWrite(S7DataType dataType, object? value) => dataType switch
{
S7DataType.Bool => (object)Convert.ToBoolean(value),
S7DataType.Byte => (object)Convert.ToByte(value),
S7DataType.UInt16 => (object)Convert.ToUInt16(value),
S7DataType.Int16 => (object)unchecked((ushort)Convert.ToInt16(value)),
S7DataType.UInt32 => (object)Convert.ToUInt32(value),
S7DataType.Int32 => (object)unchecked((uint)Convert.ToInt32(value)),
S7DataType.Float32 => (object)BitConverter.SingleToUInt32Bits(Convert.ToSingle(value)),
// Unreachable defensive backstop — every wide type routes through the byte-buffer write
// path (IsBufferType → WriteScalarBlockAsync → EncodeScalarBlock) before BoxValueForWrite
// is consulted. Kept (not deleted) so a mis-route fails loudly rather than mis-encoding.
S7DataType.Int64 => throw new NotSupportedException("S7 Int64 is unreachable here — wide types route through the byte-buffer path (EncodeScalarBlock)"),
S7DataType.UInt64 => throw new NotSupportedException("S7 UInt64 is unreachable here — wide types route through the byte-buffer path (EncodeScalarBlock)"),
S7DataType.Float64 => throw new NotSupportedException("S7 Float64 (LReal) is unreachable here — wide types route through the byte-buffer path (EncodeScalarBlock)"),
S7DataType.String => throw new NotSupportedException("S7 STRING is unreachable here — wide types route through the byte-buffer path (EncodeScalarBlock)"),
S7DataType.DateTime => throw new NotSupportedException("S7 DateTime is unreachable here — wide types route through the byte-buffer path (EncodeScalarBlock)"),
S7DataType.WString or S7DataType.Char or S7DataType.WChar or S7DataType.Dtl or S7DataType.Date
or S7DataType.Tod or S7DataType.Time or S7DataType.S5Time
=> throw new NotSupportedException($"S7 {dataType} is unreachable here — wide types route through the byte-buffer path (EncodeScalarBlock)"),
_ => throw new InvalidOperationException($"Unknown S7DataType {dataType}"),
};
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 (the 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.
/// <para>
/// While the PLC is down every data call and probe tick would pay a full connect attempt
/// bounded only by <c>_options.Timeout</c>. This method is the single
/// choke-point all S7 reconnects flow through (already gate-serialized), so the
/// connect-attempt throttle lives here: the shared <see cref="ConnectionBackoff"/>
/// primitive is consulted with <c>ShouldAttempt(now)</c> at the top of the slow path —
/// inside the window the call fails fast with no connect attempt, which every caller
/// already degrades on — with <c>RecordFailure</c>/<c>RecordSuccess</c> around the connect.
/// A successful connect clears the window, so recovery is never delayed past the first
/// attempt that finds the PLC back; the 30 s cap bounds the worst case.
/// </para>
/// </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;
// Inside the backoff window fail fast WITHOUT a connect attempt. A plain
// InvalidOperationException (not an OCE) so the read/write/probe catch ladders degrade on it.
if (!_connectBackoff.ShouldAttempt(BackoffClock()))
throw new InvalidOperationException(
$"S7 reconnect to {_options.Host}:{_options.Port} suppressed — backing off after a failed connect attempt.");
var plc = _plcFactory.Create(_options);
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(_options.Timeout);
await plc.OpenAsync(cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
_connectBackoff.RecordFailure(BackoffClock());
// The linked CancelAfter fired — a connect TIMEOUT, not a caller cancellation.
// Surface it as a connection FAILURE (TimeoutException) so callers route it through
// their degrade paths: an escaping OCE reads as teardown and permanently killed the
// subscription poll loops (the unreachable-host regression).
try { plc.Dispose(); } catch { /* best-effort */ }
throw new TimeoutException(
$"S7 connect to {_options.Host}:{_options.Port} timed out after " +
$"{(int)_options.Timeout.TotalMilliseconds} ms.");
}
catch (Exception ex)
{
// Caller cancellation is teardown, not a dead PLC — it must not open a window.
if (!(ex is OperationCanceledException && ct.IsCancellationRequested))
_connectBackoff.RecordFailure(BackoffClock());
try { plc.Dispose(); } catch { /* best-effort */ }
throw;
}
_connectBackoff.RecordSuccess();
Plc = plc;
Volatile.Write(ref _negotiatedPduSize, plc.MaxPduSize);
_logger.LogInformation("S7Driver reconnected. Driver={DriverInstanceId} Host={Host} CPU={CpuType} PduSize={PduSize}",
_driverInstanceId, _options.Host, _options.CpuType, _negotiatedPduSize);
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>
/// The result stamped, without any I/O, on an item a pass skipped because an earlier item had
/// already marked the connection dead (R07:P1). The pass's health already carries the failure
/// that marked it.
/// </summary>
/// <param name="now">The pass timestamp.</param>
/// <returns>A <c>BadCommunicationError</c> snapshot.</returns>
private static DataValueSnapshot DeadHandleSnapshot(DateTime now) => new(null, StatusBadCommunicationError, null, now);
/// <summary>
/// The TRANSPORT subset of <see cref="IsS7ConnectionFatal"/>: a socket / IO / disposed handle /
/// blown deadline / <see cref="ErrorCode.ConnectionError"/> anywhere in the chain. Excludes the
/// framing class, because S7.Net reports a multi-var request with one refused item as
/// <c>PlcException(ReadData, PlcException(WrongNumberReceivedBytes))</c> (live-probed on the snap7
/// fixture) over a perfectly healthy stream — the multi-var path falls back per tag for that
/// shape, and a genuine desync still surfaces on the per-tag reads.
/// </summary>
/// <param name="ex">The exception to classify.</param>
/// <returns><see langword="true"/> for a transport loss.</returns>
private static bool IsS7TransportLoss(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 or TimeoutException)
return true;
if (e is PlcException { ErrorCode: ErrorCode.ConnectionError })
return true;
}
return false;
}
/// <summary>
/// True when <paramref name="ex"/> (or any inner exception) is a socket-level / connection
/// loss OR an ISO-on-TCP framing/desync fault that a reopen can repair — a
/// <see cref="System.Net.Sockets.SocketException"/> / <see cref="System.IO.IOException"/> /
/// <see cref="ObjectDisposedException"/>; a <see cref="TimeoutException"/> from a wire op that
/// blew its <see cref="S7OperationDeadline"/> wall-clock ceiling (an unresponsive but still
/// TCP-established peer — the READ-leg sibling of the connect-timeout fix); an S7.Net framing violation
/// (<see cref="TPKTInvalidException"/> / <see cref="TPDUInvalidException"/> /
/// <see cref="WrongNumberOfBytesException"/>); or a <see cref="PlcException"/> carrying
/// <see cref="ErrorCode.ConnectionError"/> or <see cref="ErrorCode.WrongNumberReceivedBytes"/>.
/// A framing violation means the single serialized stream's position is untrustworthy — a
/// half-read PDU left by a timeout/cancellation makes every later response mis-frame, so only
/// a reopen recovers (the Modbus desync mode, rebuilt in S7).
/// <para>
/// Deliberately NOT <see cref="System.IO.InvalidDataException"/>: the driver's own decode
/// backstop (<see cref="ReinterpretRawValue"/> / <see cref="DecodeScalarBlock"/>) throws
/// it for a declared-type/address-size CONFIG mismatch on a HEALTHY socket — classifying
/// it would churn a full reopen on every read of a mis-authored tag. A genuinely desynced
/// stream's very next PDU still lands in TPKT/TPDU/WrongNumber territory, which now tears
/// down, so a real desync self-heals within one extra failed call.
/// </para>
/// 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;
// A wire op that blew its wall-clock deadline (S7OperationDeadline — the async read/write
// ceiling S7.Net's ignored socket ReadTimeout/WriteTimeout can't provide) means the
// connection is unresponsive: discard + reopen. This is the READ-leg sibling of the
// connect-timeout fix — without it a frozen-but-established peer wedges the
// poll loop forever instead of surfacing Bad + reconnecting.
if (e is TimeoutException)
return true;
// ISO-on-TCP framing/desync surface: the stream position is untrustworthy —
// a half-read PDU left by a timeout/cancellation makes every later response mis-frame.
// NOTE: deliberately NOT System.IO.InvalidDataException — ReinterpretRawValue throws it
// for a declared-type/address-size CONFIG mismatch on a healthy socket.
if (e is TPKTInvalidException or TPDUInvalidException or WrongNumberOfBytesException)
return true;
if (e is PlcException { ErrorCode: ErrorCode.ConnectionError or ErrorCode.WrongNumberReceivedBytes })
return true;
}
return false;
}
/// <summary>
/// True when <paramref name="ex"/> is a deterministic PLC REFUSAL of the item: the CPU answered
/// with an S7 item error (object does not exist, address out of range, data type inconsistent /
/// not supported, hardware fault, or an unknown status), or S7.Net raised a
/// <see cref="ErrorCode.ReadData"/> / <see cref="ErrorCode.WriteData"/> over a response it could
/// not use. Excludes the access-denied refusal (its own sticky Faulted config alert) and
/// anything carrying a socket / timeout / framing / cancellation fault in its chain — those
/// wrap a transport failure, not an answer.
/// </summary>
/// <remarks>
/// Live-probed against the snap7 fixture (2026-10-22): S7.Net's READ paths
/// (<c>ReadAsync(string)</c>, <c>ReadBytesAsync</c>) raise the item error as a BARE
/// <see cref="Exception"/> — "Received error from PLC: Object does not exist." — not wrapped in
/// a <see cref="PlcException"/>; its write paths wrap whatever they hit in a
/// <c>PlcException(WriteData)</c>. Both shapes are matched.
/// </remarks>
/// <param name="ex">The exception raised by S7.Net.</param>
/// <returns><see langword="true"/> when the CPU answered and refused.</returns>
internal static bool IsRefusal(Exception ex)
{
if (IsAccessDenied(ex) || IsS7ConnectionFatal(ex) || HasCancellationInChain(ex)) return false;
if (ex is PlcException { ErrorCode: ErrorCode.ReadData or ErrorCode.WriteData }) return true;
for (Exception? e = ex; e is not null; e = e.InnerException)
if (IsS7ItemError(e)) return true;
return false;
}
/// <summary>
/// True for the bare <see cref="Exception"/> S7.Net's response-code validator raises for an S7
/// item error ("Received error from PLC: …" / "Invalid response from PLC: statusCode=…").
/// Exact type only: a derived exception carrying similar words is not S7.Net's validator.
/// </summary>
/// <param name="e">One exception of a chain.</param>
/// <returns><see langword="true"/> for the validator's item-error shape.</returns>
private static bool IsS7ItemError(Exception e)
=> e.GetType() == typeof(Exception)
&& (e.Message.StartsWith("Received error from PLC", StringComparison.Ordinal)
|| e.Message.StartsWith("Invalid response from PLC", StringComparison.Ordinal));
/// <summary>True when a cancellation is buried in the exception chain (a half-read PDU, not a refusal).</summary>
/// <param name="ex">The exception to inspect.</param>
/// <returns><see langword="true"/> when any inner exception is an <see cref="OperationCanceledException"/>.</returns>
private static bool HasCancellationInChain(Exception ex)
{
for (var e = ex.InnerException; e is not null; e = e.InnerException)
if (e is OperationCanceledException) return true;
return false;
}
/// <summary>Logs a refusal once per tag per generation.</summary>
/// <param name="tagName">The refused tag.</param>
/// <param name="operation">"read" or "write".</param>
/// <param name="ex">The refusal.</param>
private void WarnRefusalOnce(string tagName, string operation, Exception ex)
{
if (!_refusalWarned.TryAdd(tagName, 0)) return;
_logger.LogWarning(
"S7 CPU refused the {Operation} of tag {Tag} ({Reason}); it reads Bad until the tag or the PLC program is fixed. " +
"Driver health is unaffected. Further refusals of this tag are not logged. Driver={DriverInstanceId}",
operation, tagName, ex.InnerException?.Message ?? ex.Message, _driverInstanceId);
}
/// <summary>
/// Detects an S7 PUT/GET-disabled / access-protection fault. S7.Net's response-code validator
/// throws a plain <see cref="Exception"/> for the S7 <c>AccessingObjectNotAllowed</c> status —
/// bare on the read paths, wrapped in a <see cref="PlcException"/> (<see cref="ErrorCode.WriteData"/>)
/// on the write paths. There is no typed error code for it, so the message is the only
/// discriminator S7.Net exposes. The top-level exception must be S7.Net-shaped (a
/// <see cref="PlcException"/> or the validator's bare item error), so an unrelated exception that
/// happens to say "not allowed" is not mistaken for it.
/// </summary>
/// <param name="ex">The exception raised by S7.Net.</param>
/// <returns><see langword="true"/> when the PLC refused access to the object.</returns>
internal static bool IsAccessDenied(Exception ex)
{
if (ex is not PlcException && !IsS7ItemError(ex)) return false;
// A transport fault wins over any wording in the chain: a socket error whose text happens to
// contain the phrase must reopen (Degraded), not latch a sticky config alert.
if (IsS7ConnectionFatal(ex) || HasCancellationInChain(ex)) return false;
for (Exception? e = ex; e is not null; e = e.InnerException)
{
// S7.Net's exact wording for the S7 AccessingObjectNotAllowed status — not the loose "not
// allowed", which any unrelated exception text can contain.
if (e.Message.Contains("Accessing object not allowed", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
// ---- ITagDiscovery ----
/// <inheritdoc />
// Run-once: DiscoverAsync emits the complete node set synchronously from the configured tag
// table in a single pass — nothing fills in asynchronously after connect, so a single
// discovery pass is sufficient.
public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;
/// <inheritdoc />
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(builder);
var folder = builder.Folder("S7", "S7");
foreach (var t in TagsByRawPath.Values)
{
// A tag carrying a non-null array count (>= 1) surfaces as a 1-D OPC UA array node.
// A null count stays scalar. A count of 1 IS a valid 1-element array: the foundation
// materialises a [1] OPC UA array node when isArray:true, so the driver must agree.
var isArray = t.ArrayCount is >= 1;
folder.Variable(t.Name, t.Name, new DriverAttributeInfo(
FullName: t.Name,
DriverDataType: MapDataType(t.DataType),
IsArray: isArray,
ArrayDim: isArray ? (uint)t.ArrayCount!.Value : null,
SecurityClass: t.Writable ? SecurityClassification.Operate : SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: t.WriteIdempotent));
}
return Task.CompletedTask;
}
private static DriverDataType MapDataType(S7DataType t) => t switch
{
S7DataType.Bool => DriverDataType.Boolean,
S7DataType.Byte => DriverDataType.Int32, // no 8-bit in DriverDataType yet
// UInt32 values > int.MaxValue (2^31-1) wrap negative when surfaced as
// Int32. That residual lossy note now applies ONLY to UInt32 — Int64/UInt64 map to
// their own DriverDataType members below (Phase 4d), so they are no longer lossy.
S7DataType.Int16 or S7DataType.UInt16 or S7DataType.Int32 or S7DataType.UInt32 => DriverDataType.Int32,
S7DataType.Int64 => DriverDataType.Int64,
S7DataType.UInt64 => DriverDataType.UInt64,
S7DataType.Float32 => DriverDataType.Float32,
S7DataType.Float64 => DriverDataType.Float64,
S7DataType.String or S7DataType.WString or S7DataType.Char or S7DataType.WChar => DriverDataType.String,
S7DataType.DateTime or S7DataType.Dtl or S7DataType.Date => DriverDataType.DateTime,
// TOD / TIME / S5TIME surface as Int32 milliseconds (the default arm; named for the reader).
S7DataType.Tod or S7DataType.Time or S7DataType.S5Time => DriverDataType.Int32,
_ => DriverDataType.Int32,
};
// ---- ISubscribable (polling overlay) ----
/// <inheritdoc />
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
=> Task.FromResult(_poll.Subscribe(fullReferences, publishingInterval));
/// <inheritdoc />
public async Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
// Async teardown: the engine drains the in-flight poll without blocking the caller's
// thread — this runs inside a DriverInstanceActor mailbox turn.
await _poll.UnsubscribeAsync(handle).ConfigureAwait(false);
}
/// <summary>
/// Routes a poll-loop reader failure (surfaced by the shared
/// <see cref="PollGroupEngine"/>'s onError sink) to the driver health surface: logs it and
/// degrades to <see cref="DriverState.Degraded"/> preserving <c>LastSuccessfulRead</c>. Never
/// downgrades a <see cref="DriverState.Faulted"/> state (e.g. PUT/GET-denied set by ReadAsync)
/// — Faulted is the stronger, permanent-config signal. Replaces the retired fork's
/// <c>HandlePollFailure</c>; the engine now owns the consecutive-failure count + backoff.
/// </summary>
/// <param name="ex">The exception caught by the poll engine.</param>
private void HandlePollError(Exception ex)
{
// A retained poll that ran into a teardown / re-initialize IN PROGRESS ("not initialized" while a
// lifecycle operation holds _lifecycleGate) belongs to no live generation: that operation owns
// health, and writing Degraded over its Initializing (or its final Unknown) would break the "old
// work never overwrites newer health" rule. A poll against a driver nobody is initializing (a
// subscribe before init, a failed init) still surfaces Degraded (Driver.S7-009).
if (!_initialized && _lifecycleGate.CurrentCount == 0)
{
_logger.LogDebug(ex, "S7 poll reader failed during a lifecycle operation; no health write. Driver={DriverInstanceId}", _driverInstanceId);
return;
}
_logger.LogWarning(ex, "S7 poll reader failed. Driver={DriverInstanceId}", _driverInstanceId);
TryUpdateHealth(CurrentGeneration, h => h.State == DriverState.Faulted
? null
: new DriverHealth(DriverState.Degraded, h.LastSuccessfulRead, ex.Message));
}
// ---- IHostConnectivityProbe ----
/// <summary>
/// Host identifier surfaced in <see cref="GetHostStatuses"/>. <c>host:port</c> format
/// matches the Modbus driver's convention so the Admin UI dashboard renders both
/// family's rows uniformly.
/// </summary>
public string HostName => $"{_options.Host}:{_options.Port}";
/// <inheritdoc />
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses()
{
lock (_probeLock)
return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)];
}
private async Task ProbeLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
// Running / Stopped, or null for "no verdict this tick" — which leaves the host state alone.
HostState? verdict = null;
try
{
// Probe via S7.Net's low-cost GetCpuStatus — returns the CPU state (Run/Stop)
// and is intentionally light on the comms mailbox. Single-word Plc.ReadAsync
// would also work but GetCpuStatus doubles as a "PLC actually up" check.
//
// Gate CONTENTION is not a verdict (R07:H7): a read pass or write batch holding the
// gate past the probe deadline is the connection being USED, and that pass reports
// its own outcome through health. Reporting the host Stopped for it flapped /hosts on
// every long pass against a perfectly healthy PLC.
if (!await _gate.WaitAsync(_options.Probe.Timeout, ct).ConfigureAwait(false))
{
Interlocked.Increment(ref _probeGateContentions);
}
else
{
try
{
// A tick that raced a teardown / re-initialize has nothing to probe and must
// not reopen a connection on a driver that is not initialized.
if (_initialized)
verdict = await ProbeOnceAsync(ct).ConfigureAwait(false);
}
finally { _gate.Release(); }
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
catch { verdict = HostState.Stopped; /* any other probe fault */ }
if (verdict is { } state) TransitionTo(state);
try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
}
}
/// <summary>
/// One probe round trip. Caller holds <see cref="_gate"/>. Returns the verdict; rethrows only
/// loop teardown.
/// </summary>
/// <param name="ct">The probe loop's token.</param>
/// <returns><see cref="HostState.Running"/> or <see cref="HostState.Stopped"/>.</returns>
private async Task<HostState> ProbeOnceAsync(CancellationToken ct)
{
try
{
// 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. It runs on the LOOP token, not the (shorter) probe deadline:
// the connect is bounded by _options.Timeout inside, and a timeout there must count as a
// failure so the backoff window opens — cutting it with the probe deadline looked like a
// caller cancel, recorded nothing, and let a black-holed host cost a connect attempt every
// probe tick.
var plc = await EnsureConnectedAsync(ct).ConfigureAwait(false);
// The probe deadline bounds only the CPU-status round trip.
using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
probeCts.CancelAfter(_options.Probe.Timeout);
await plc.ReadStatusAsync(probeCts.Token).ConfigureAwait(false);
return HostState.Running;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw; // loop teardown — the caller's finally releases the gate
}
catch (OperationCanceledException)
{
// Probe deadline fired mid-PDU: the CPU-status response may still be in flight, so the
// handle can't be reused (half-read ⇒ desync). The caller holds _gate, keeping _plcDead
// gate-guarded.
_plcDead = true;
return HostState.Stopped;
}
catch (Exception ex)
{
// Classify + mark while holding _gate so a wire-dead/desynced handle whose TcpClient still
// claims Connected is reopened by the NEXT tick's EnsureConnectedAsync instead of being
// re-probed forever.
MarkConnectionDeadIfFatal(ex);
return HostState.Stopped;
}
}
/// <summary>Probe ticks that found the gate held past the probe deadline and gave no verdict.</summary>
private int _probeGateContentions;
/// <summary>Count of probe ticks skipped for gate contention. Internal so a test can wait on it.</summary>
internal int ProbeGateContentions => Volatile.Read(ref _probeGateContentions);
private void TransitionTo(HostState newState)
{
HostState old;
lock (_probeLock)
{
old = _hostState;
if (old == newState) return;
_hostState = newState;
_hostStateChangedUtc = DateTime.UtcNow;
}
_logger.LogInformation("S7 probe transition. Driver={DriverInstanceId} Host={Host} {OldState} → {NewState}",
_driverInstanceId, HostName, old, newState);
OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, old, newState));
}
/// <summary>Disposes the driver and releases resources.</summary>
public void Dispose()
{
// Avoid the sync-over-async DisposeAsync().AsTask().GetAwaiter().GetResult()
// pattern (a known deadlock surface even when currently safe here). ShutdownAsync's
// body is effectively synchronous apart from waiting on probe/poll Tasks; do the same
// teardown directly, blocking only on the drain — and only with a bounded timeout so
// a wedged loop can't hang Dispose() indefinitely.
if (_disposed) return;
_disposed = true;
SynchronousTeardown();
// _gate / _lifecycleGate are deliberately NOT disposed: a Dispose can race a still-running
// ShutdownAsync (the runtime disposes right after a bounded shutdown), which would then throw
// ObjectDisposedException mid-teardown. A SemaphoreSlim whose wait handle was never requested
// holds no unmanaged resource.
}
/// <summary>Asynchronously disposes the driver and releases resources.</summary>
/// <returns>A task representing the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
// Safe while a ShutdownAsync is still running: it waits (bounded) for the lifecycle gate, and
// the teardown is idempotent. The semaphores are not disposed (see Dispose).
try { await ShutdownAsync(CancellationToken.None).ConfigureAwait(false); }
catch { /* disposal is best-effort */ }
}
/// <summary>
/// Synchronous teardown — mirrors <see cref="ShutdownAsync"/> but blocks (with a bounded
/// timeout) on the probe + poll Tasks instead of awaiting them. Used by the sync
/// <see cref="Dispose"/> path so we don't sync-over-async <see cref="DisposeAsync"/>.
/// </summary>
private void SynchronousTeardown()
{
// Its own lifecycle generation, like ShutdownCoreAsync: an initialize still running past this
// point discards what it built instead of publishing it over the torn-down driver.
var gen = BeginGeneration();
// Drain the polling overlay (drain-before-dispose).
try { _poll.DisposeAsync().AsTask().Wait(DrainTimeout); }
catch { /* timeouts/loop faults are tolerated — teardown continues */ }
var probeCts = _probeCts;
var probeTask = _probeTask;
try { probeCts?.Cancel(); } catch { }
if (probeTask is not null)
{
try { probeTask.Wait(DrainTimeout); }
catch { /* timeouts/loop faults are tolerated — teardown continues */ }
}
probeCts?.Dispose();
_probeCts = null;
_probeTask = null;
_initialized = false;
_plcDead = false;
_writeFault = null;
_writeOwnedError = 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"
TryUpdateHealth(gen, h => new DriverHealth(DriverState.Unknown, h.LastSuccessfulRead, null));
}
}