Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
T

2587 lines
143 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, IDisposable, IAsyncDisposable
{
/// <summary>Authored tags this driver could not build into its poll table (Gitea #541).</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);
// CONV-1: the polling overlay runs on the shared PollGroupEngine (structural array diff +
// drain-before-dispose + capped-exponential failure backoff + caller-token-filtered OCE),
// retiring the bespoke S7 poll fork. minInterval 100 ms matches the S7 scan-mailbox floor;
// backoffCap 30 s matches the retired fork's PollBackoffCap.
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)),
minInterval: TimeSpan.FromMilliseconds(100),
onError: HandlePollError,
backoffCap: TimeSpan.FromSeconds(30),
onBatch: (handle, items) => OnDataChangeBatch?.Invoke(this, new DataChangeBatchEventArgs(handle, items)),
// The engine owns no Meter (Core.Abstractions takes no metrics dependency), so this
// closure IS the production consumer of its overrun signal.
onOverrun: _ => OtOpcUaTelemetry.PollOverruns.Add(1,
new KeyValuePair<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;
// v3: the RawPath → definition table. Keyed by RawPath (the v3 tag identity, == def.Name),
// case-sensitive ordinal. Built in InitializeAsync from _options.RawTags via the pure factory.
private readonly Dictionary<string, S7TagDefinition> _tagsByRawPath = new(StringComparer.Ordinal);
// Pre-parsed S7 address per tag, keyed by RawPath (== def.Name). Populated alongside
// _tagsByRawPath at init so a config typo fails fast here rather than as a per-read fault.
private readonly Dictionary<string, S7ParsedAddress> _parsedByRawPath = new(StringComparer.Ordinal);
// Resolves a read/write/subscribe fullReference (a RawPath in v3) to a tag definition by a single
// authored-table hit; a miss is a miss (the driver surfaces BadNodeIdUnknown, never a parse attempt).
private readonly EquipmentTagRefResolver<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 (STAB-21): a dead PLC costs one connect attempt per
/// backoff window (1 s doubling to 30 s) instead of one per data call / poll tick / probe tick.
/// Not internally synchronized — only touched from <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>
/// 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;
private bool _disposed;
/// <inheritdoc />
public string DriverInstanceId => _driverInstanceId;
/// <inheritdoc />
public string DriverType => "S7";
/// <inheritdoc />
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
Health = new DriverHealth(DriverState.Initializing, null, null);
try
{
// Re-parse the supplied DriverConfig JSON so a config change delivered through the
// IDriver contract is honoured. An empty / placeholder document
// (e.g. the "{}" some unit tests pass) keeps the constructor-supplied options.
if (HasConfigBody(driverConfigJson))
_options = S7DriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
// v3: build the RawPath → definition table from the authored raw tags BEFORE the config
// guards run (they now iterate the mapped table). Each entry's TagConfig blob is mapped by
// the pure factory; the entry's WriteIdempotent flag is threaded onto the def (it lives on
// the RawTagEntry, not inside the blob). A mapper miss is skipped (logged), never thrown —
// one bad tag must not fail the whole driver init.
_tagsByRawPath.Clear();
_parsedByRawPath.Clear();
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
_skippedTags.BeginRebuild(_options.RawTags.Count);
foreach (var entry in _options.RawTags)
{
if (S7TagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
_tagsByRawPath[entry.RawPath] = def with { WriteIdempotent = entry.WriteIdempotent };
else
{
_skippedTags.Skip(entry.RawPath, "TagConfig did not map to a definition");
_logger.LogWarning(
"S7 tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
_driverInstanceId, entry.RawPath);
}
}
_skippedTags.Commit();
// Phase 4d combined config guard. Timer/Counter addresses and the wide/structured
// types (Int64/UInt64/Float64/String/DateTime) are now SUPPORTED, but only in
// specific shapes — this rejects the genuinely-unsupported configurations
// (wide-type arrays, non-byte-addressed wide scalars, wrong Timer/Counter DataType)
// so a config typo fails fast at init instead of as a misleading per-read fault.
RejectUnsupportedTagConfigs();
// Legacy data-type seam. The UnimplementedDataTypes set is now
// empty (Phase 4d unblocked all five wide types), so this rejects nothing — kept as
// the single grep target / future seam should a type ever need re-gating at init.
RejectUnsupportedTagDataTypes();
// Build the connection through the factory (the same seam EnsureConnectedAsync's reopen
// uses) so the read/write timeouts are baked in identically on connect and reconnect.
var plc = _plcFactory.Create(_options);
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(_options.Timeout);
await plc.OpenAsync(cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// The linked CancelAfter fired — a connect TIMEOUT, not a caller cancellation.
// Surface it as a TimeoutException so init-time and reconnect-time timeouts report
// the same exception type (STAB-14 consistency); the outer catch still faults health.
try { plc.Dispose(); } catch { }
throw new TimeoutException(
$"S7 connect to {_options.Host}:{_options.Port} timed out after " +
$"{(int)_options.Timeout.TotalMilliseconds} ms.");
}
catch
{
// Dispose the partially-constructed connection so a failed init doesn't leak it
// before the outer catch nulls Plc (Plc isn't assigned yet, so it can't).
try { plc.Dispose(); } catch { }
throw;
}
Plc = plc;
_plcDead = false;
_connectBackoff.RecordSuccess();
Volatile.Write(ref _negotiatedPduSize, plc.MaxPduSize);
// Parse every tag's address once at init so config typos fail fast here instead
// of surfacing as BadInternalError on every Read against the bad tag. The parser
// also rejects bit-offset > 7, DB 0, unknown area letters, etc. Keyed by RawPath
// (== def.Name) so Read/Write can pick up the pre-parsed address.
foreach (var t in _tagsByRawPath.Values)
{
_parsedByRawPath[t.Name] = S7AddressParser.Parse(t.Address); // throws FormatException
}
_initialized = true;
Health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
_logger.LogInformation("S7Driver connected. Driver={DriverInstanceId} Host={Host} CPU={CpuType} PduSize={PduSize} Tags={TagCount}",
_driverInstanceId, _options.Host, _options.CpuType, _negotiatedPduSize, _tagsByRawPath.Count);
// Kick off the probe loop once the connection is up. Initial HostState stays
// Unknown until the first probe tick succeeds — avoids broadcasting a premature
// Running transition before any PDU round-trip has happened.
if (_options.Probe.Enabled)
{
_probeCts = new CancellationTokenSource();
// Track the probe Task (not fire-and-forget) so ShutdownAsync can await it
// before disposing _gate / _probeCts. Pass None to Task.Run so
// the delegate always runs and the handle is always awaitable; the loop's own
// token check handles cancellation.
_probeTask = Task.Run(() => ProbeLoopAsync(_probeCts.Token), CancellationToken.None);
}
}
catch (Exception ex)
{
// Clean up a partially-constructed Plc so a retry from the caller doesn't leak
// the TcpClient. Dispose is best-effort and idempotent.
try { Plc?.Dispose(); } catch { }
Plc = null;
Volatile.Write(ref _negotiatedPduSize, 0);
_initialized = false;
_plcDead = false;
Health = new DriverHealth(DriverState.Faulted, null, ex.Message);
_logger.LogError(ex, "S7Driver connect failed. Driver={DriverInstanceId} Host={Host}", _driverInstanceId, _options.Host);
throw;
}
}
/// <inheritdoc />
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
// InitializeAsync re-parses driverConfigJson, so a config change delivered here is
// applied in place rather than silently discarded.
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
// Signal cancellation to the probe + poll loops first, collect their Task handles,
// then await all of them with a bounded timeout BEFORE disposing the shared semaphore
// and CTS objects. Without the drain, a loop iteration mid-_gate would call Release()
// on (or WaitAsync against) a disposed semaphore.
// Drain the polling overlay first — the engine cancels each loop and awaits it before
// disposing its CTS (drain-before-dispose, STAB-6-S7), so no poll iteration touches _gate
// after this returns.
await _poll.DisposeAsync().ConfigureAwait(false);
var probeCts = _probeCts;
var probeTask = _probeTask;
try { probeCts?.Cancel(); } catch { }
if (probeTask is not null)
{
try
{
await probeTask.WaitAsync(DrainTimeout, CancellationToken.None).ConfigureAwait(false);
}
catch (TimeoutException) { /* a wedged probe loop — proceed; better than leaking the teardown */ }
catch { /* loop faults are already surfaced via health; teardown continues */ }
}
// The probe loop has now observed cancellation and released _gate — safe to dispose the CTS.
probeCts?.Dispose();
_probeCts = null;
_probeTask = null;
_initialized = false;
_plcDead = false;
_writeFault = null;
_connectBackoff.RecordSuccess();
try { Plc?.Dispose(); } catch { /* best-effort — tearing down anyway */ }
Plc = null;
Volatile.Write(ref _negotiatedPduSize, 0); // a stale post-teardown reading would read as "online"
Health = new DriverHealth(DriverState.Unknown, Health.LastSuccessfulRead, null);
}
/// <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>
/// S7 data types addressed by START BYTE (the <c>B</c> suffix) rather than by an
/// access-width suffix. Phase 4d decodes these from a contiguous byte buffer
/// (<c>DB{n}.DBB{offset}</c> / <c>MB</c> / <c>IB</c> / <c>QB{offset}</c>), so a wide tag
/// authored against a non-byte suffix (DBW/DBD/…) is a config error — see
/// <see cref="RejectUnsupportedTagConfigs"/>.
/// </summary>
/// <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,
};
private static readonly HashSet<S7DataType> ByteAnchoredDataTypes = new()
{
S7DataType.Int64,
S7DataType.UInt64,
S7DataType.Float64,
S7DataType.String,
S7DataType.DateTime,
S7DataType.WString,
S7DataType.Char,
S7DataType.WChar,
S7DataType.Dtl,
S7DataType.Date,
S7DataType.Tod,
S7DataType.Time,
S7DataType.S5Time,
};
/// <summary>
/// Combined init-time config guard (Phase 4d, supersedes the old Timer/Counter-only
/// <c>RejectUnsupportedTagAddresses</c>). Timer/Counter tags and the wide/structured
/// types (Int64/UInt64/Float64/String/DateTime) are now <i>supported</i> — but only in
/// specific shapes. This guard fails fast on the genuinely-unsupported configurations so
/// a config typo throws here at init rather than as a misleading per-read fault:
/// <list type="bullet">
/// <item>(a) a wide/structured type declared as an array — wide-type arrays are out of scope this phase.</item>
/// <item>(b) a wide/structured type on a DB/M/I/Q area whose address isn't byte-anchored (<c>DBB</c>/<c>MB</c>/<c>IB</c>/<c>QB</c>).</item>
/// <item>(c) a Timer tag not typed <c>Float64</c>, or a Counter tag not typed <c>Int32</c>.</item>
/// </list>
/// Addresses that don't parse are left to the existing <see cref="S7AddressParser.Parse"/>
/// pass in <see cref="InitializeAsync"/> so the parse error isn't double-handled.
/// </summary>
private void RejectUnsupportedTagConfigs()
{
foreach (var t in _tagsByRawPath.Values)
{
var isWide = ByteAnchoredDataTypes.Contains(t.DataType);
// (a) wide-type arrays — out of scope this phase.
if (isWide && t.ArrayCount is >= 1)
{
throw new NotSupportedException(
$"S7 tag '{t.Name}' is a {t.DataType} array, which the S7 driver does not yet " +
"support. Use a scalar wide tag, or an Int16/UInt16/Int32/UInt32/Float32/Byte/Bool " +
"element type for arrays.");
}
// Parse the address; if it doesn't parse, leave it for the InitializeAsync
// S7AddressParser.Parse pass to throw the FormatException (no double-handling).
if (!S7AddressParser.TryParse(t.Address, out var parsed))
continue;
// (b) wide/structured scalar on DB/M/I/Q must be byte-anchored (Size == Byte). The
// codec decodes from a byte buffer at the start byte; a non-byte suffix (DBW/DBD/…)
// would mis-frame the value.
if (isWide
&& parsed.Area is S7Area.DataBlock or S7Area.Memory or S7Area.Input or S7Area.Output
&& parsed.Size != S7Size.Byte)
{
throw new NotSupportedException(
$"S7 tag '{t.Name}' is a {t.DataType} at '{t.Address}', which is not byte-addressed. " +
"Wide/structured types must be byte-addressed (DBB/MB/IB/QB) — e.g. 'DB1.DBB0', 'MB4'.");
}
// (c) Timer/Counter type constraints — Timer decodes to a Float64 (seconds), Counter
// to an Int32 (count); both are read-only.
if (parsed.Area is S7Area.Timer && t.DataType != S7DataType.Float64)
{
throw new NotSupportedException(
$"S7 tag '{t.Name}' is a Timer address ('{t.Address}') but is typed {t.DataType}. " +
"Timer tags must be DataType=Float64 (decoded to seconds, read-only).");
}
if (parsed.Area is S7Area.Counter && t.DataType != S7DataType.Int32)
{
throw new NotSupportedException(
$"S7 tag '{t.Name}' is a Counter address ('{t.Address}') but is typed {t.DataType}. " +
"Counter tags must be DataType=Int32 (decoded to count, read-only).");
}
// (d) Timer/Counter declared Writable — writes are read-only this phase; without this
// a node is discovered as Operate-writable but every write returns BadNotSupported.
if (parsed.Area is S7Area.Timer or S7Area.Counter && t.Writable)
{
throw new NotSupportedException(
$"S7 tag '{t.Name}' is a Timer/Counter ('{t.Address}') declared Writable — " +
"Timer/Counter are read-only this phase; set Writable=false.");
}
// (e) Bool array declared Writable — element writes are byte-granular (one byte per
// element, mirroring the read), so a write would overwrite the 7 neighbouring bits of
// every byte it touches; a safe bit-packed write needs read-modify-write and is not
// implemented. Every other supported element type (Byte/Int16/UInt16/Int32/UInt32/
// Float32) writes as ONE contiguous block (EncodeArrayBlock) — S7-016. Wide-type arrays
// are already rejected above (guard-a).
if (t.ArrayCount is >= 1 && t.Writable && t.DataType == S7DataType.Bool)
{
throw new NotSupportedException(
$"S7 tag '{t.Name}' is a Bool array (ArrayCount={t.ArrayCount}) declared Writable — " +
"Bool array writes are not supported (a byte-granular write would clobber the neighbouring " +
"bits). Set Writable=false, or write the bits as individual scalar Bool tags.");
}
}
}
/// <summary>
/// Rejects tags configured with an <see cref="S7DataType"/> still on the
/// <see cref="UnimplementedDataTypes"/> list — types <see cref="ReinterpretRawValue"/> /
/// <see cref="BoxValueForWrite"/> throw <see cref="NotSupportedException"/> for, which
/// would otherwise create live OPC UA nodes via <see cref="DiscoverAsync"/> that return
/// <c>BadNotSupported</c> on every access.
/// <b>The set is now empty</b> (Phase 4d unblocked all five wide types), so this rejects
/// nothing — kept as the seam / grep target should a type ever need re-gating at init.
/// </summary>
private void RejectUnsupportedTagDataTypes()
{
foreach (var t in _tagsByRawPath.Values)
{
if (UnimplementedDataTypes.Contains(t.DataType))
{
throw new NotSupportedException(
$"S7 tag '{t.Name}' uses data type '{t.DataType}' which is not yet " +
"supported by the S7 driver — Read/Write would return BadNotSupported. " +
"Remove the tag or use Bool/Byte/Int16/UInt16/Int32/UInt32/Float32 until " +
$"{t.DataType} is wired through.");
}
}
}
/// <summary>
/// S7DataType members that the read/write helpers throw NotSupportedException for.
/// <b>Now empty</b> — Phase 4d (S7 wide types + Timer/Counter) wired Int64, UInt64,
/// Float64, String, and DateTime through the byte-buffer codec, and Timer/Counter read
/// through it on AREA. Kept here (rather than reflecting over
/// <see cref="ReinterpretRawValue"/>) as the single grep target / future seam should a
/// data type ever need re-gating at init.
/// </summary>
private static readonly HashSet<S7DataType> UnimplementedDataTypes = new();
// ---- IReadable ----
/// <inheritdoc />
/// <remarks>
/// <para>
/// The batch is served in two passes under ONE gate acquisition. First
/// <see cref="S7ReadSpanPlanner"/> groups the resolved tags into contiguous
/// <c>(area, DB)</c> byte spans and each span is read with a single
/// <c>ReadBytesAsync</c>, 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 <see cref="PrefersPerTagRead"/>) falls to the leftover list.
/// </para>
/// <para>
/// Those leftovers get a SECOND grouping pass: the ones a
/// <see cref="S7DataItem"/> expresses cleanly — the narrow scalars, which is what a
/// demoted single-member span is — are batched into multi-var requests by
/// <see cref="ReadLeftoversByMultiVarAsync"/>, which crosses <c>(area, DB)</c>
/// 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 <see cref="ReadOneAsync"/> path.
/// </para>
/// <para>
/// Setting <c>SpanReadMaxGapBytes</c> 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 <see cref="RecordReadFailure"/> so a fault
/// stamps the same status whichever path observed it.
/// </para>
/// </remarks>
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 = DateTime.UtcNow;
var results = new DataValueSnapshot[fullReferences.Count];
// Health is decided ONCE, at the end of the pass, from every outcome the pass observed —
// never per item. See ReadPassOutcome.
var outcome = new ReadPassOutcome();
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
// (Re)open the connection under the gate. A failed (re)connect degrades the whole
// batch to BadCommunicationError rather than throwing — the poll loop / next read
// retries, and EnsureConnectedAsync repairs on the following call.
IS7Plc plc;
try
{
plc = await EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
}
// Rethrow ONLY caller cancellation. A timeout-born OCE (none expected after the
// EnsureConnectedAsync conversion, but e.g. an S7.Net-internal CTS) must degrade the
// batch below, not escape as teardown (STAB-14).
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
catch (Exception ex)
{
for (var i = 0; i < fullReferences.Count; i++)
results[i] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
Health = new DriverHealth(DriverState.Degraded, Health.LastSuccessfulRead, ex.Message);
return results;
}
// Resolve every reference first. An unresolvable one is a miss, stamped here and never
// offered to the planner — grouping must not be able to change what "unknown tag" means.
var resolved = new S7TagDefinition?[fullReferences.Count];
for (var i = 0; i < fullReferences.Count; i++)
{
if (_resolver.TryResolve(fullReferences[i], out var tag))
resolved[i] = tag;
else
results[i] = new DataValueSnapshot(null, StatusBadNodeIdUnknown, null, now);
}
// Indexes the per-tag path must serve: everything, when grouping is off; otherwise the
// planner's leftovers (Timer/Counter) plus any tag whose address won't parse.
var perTag = new List<int>();
if (_options.SpanReadMaxGapBytes >= 0)
{
List<(int OriginalIndex, S7TagDefinition Tag, S7ParsedAddress Addr)>? plannable = null;
for (var i = 0; i < fullReferences.Count; i++)
{
if (resolved[i] is not { } tag) continue;
if (!TryGetParsedAddress(tag, out var addr))
{
// Defensive: every tag pre-parses at init. A tag that somehow didn't goes
// to the per-tag path, where ReadOneAsync's Parse throws the FormatException
// the catch ladder has always mapped to BadCommunicationError.
perTag.Add(i);
continue;
}
(plannable ??= []).Add((i, tag, addr));
}
if (plannable is not null)
{
var plan = S7ReadSpanPlanner.Build(
plannable, _options.SpanReadMaxGapBytes, _options.SpanReadMaxSpanBytes);
foreach (var span in plan.Spans)
{
// A span that saves no round-trip is not worth changing the wire shape for.
if (PrefersPerTagRead(span))
{
perTag.Add(span.Members[0].OriginalIndex);
continue;
}
await ReadSpanAsync(plc, span, results, now, outcome, cancellationToken).ConfigureAwait(false);
}
perTag.AddRange(plan.LeftoverIndexes);
}
}
else
{
for (var i = 0; i < fullReferences.Count; i++)
if (resolved[i] is not null) perTag.Add(i);
}
// Sorted so a batch is served in reference order regardless of how it was partitioned —
// the per-tag tail is otherwise ordered by the planner's grouping, not by the caller's.
perTag.Sort();
// Second grouping pass: the leftovers a DataItem can express ride ONE multi-var request
// per chunk instead of a wire request each. Deliberately gated on the SAME switch as the
// span pass — SpanReadMaxGapBytes < 0 is documented to restore the pre-grouping wire
// shape exactly, and batching the (then complete) per-tag list would break that. What
// comes back is the tags still owed an individual read, still in reference order.
if (_options.SpanReadMaxGapBytes >= 0)
{
perTag = await ReadLeftoversByMultiVarAsync(
plc, resolved, perTag, results, now, outcome, cancellationToken).ConfigureAwait(false);
}
foreach (var i in perTag)
{
try
{
var value = await ReadOneAsync(plc, resolved[i]!, cancellationToken).ConfigureAwait(false);
results[i] = new DataValueSnapshot(value, 0u, now, now);
outcome.RecordSuccess();
}
catch (OperationCanceledException)
{
// Cancellation observed mid-PDU abandons a half-read ISO-on-TCP response on the
// single gated stream — the handle must be reopened, not reused (STAB-15a). Mark
// dead (we hold _gate) and propagate: the caller cancelled, nobody consumes the
// batch. Marking is unconditional on OCE (not routed through the type classifier):
// whether bytes moved is unknowable, and a false positive costs one cheap reopen
// versus permanent desync for a false negative. Kept AT the call site rather
// than inside RecordReadFailure so the rethrow preserves the original stack.
_plcDead = true;
throw;
}
catch (Exception ex)
{
results[i] = new DataValueSnapshot(null, RecordReadFailure(ex, outcome), null, now);
}
}
}
finally
{
// THE single health write of the pass — worst-state-wins over every outcome recorded
// above. In a FINALLY, so evidence the pass already gathered survives an abandoned
// batch: the three OCE arms rethrow mid-pass, and on a partially-working device a
// repeatedly-cancelled poll would otherwise freeze LastSuccessfulRead while tags were
// demonstrably still being read — tripping the comms-loss watchdog into a reconnect
// loop, the exact false positive the AnySuccess design exists to prevent.
//
// A pass that observed NOTHING reports nothing (HasObservations), which is also what
// makes flushing here safe on the two paths that reach the finally with no outcomes:
// the connect-failure early return, whose own Degraded write must NOT be overwritten,
// and an OCE that fired before any tag was stamped. Both no-op.
//
// LastSuccessfulRead advances whenever ANYTHING read, even into a Degraded/Faulted
// state — see ReadPassOutcome for why that is load-bearing.
//
// A sticky write-side Faulted (PUT/GET denied) outranks a read pass that found less
// wrong: reads can succeed against a CPU whose writes are protected, and a clean read
// must not erase the config alert. The write pass clears it on its next success.
if (outcome.HasObservations)
{
var state = outcome.WorstFailure;
var error = state == DriverState.Healthy ? null : outcome.WorstError;
if (_writeFault is { } wf && ReadPassOutcome.Rank(wf.State) > ReadPassOutcome.Rank(state))
{
state = wf.State;
error = wf.Message;
}
Health = new DriverHealth(
state,
outcome.AnySuccess ? now : Health.LastSuccessfulRead,
error);
}
_gate.Release();
}
return results;
}
/// <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 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>
/// <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)
{
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));
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 (STAB-15a).
_plcDead = true;
throw;
}
catch (Exception ex)
{
_logger.LogDebug(ex,
"S7 driver {DriverInstanceId}: multi-var read of {ItemCount} leftover tag(s) failed; " +
"falling back to per-tag reads for that chunk", _driverInstanceId, chunk.Count);
foreach (var (index, _, _) in chunk) stillPerTag.Add(index);
continue;
}
for (var k = 0; k < chunk.Count; k++)
{
var (index, tag, addr) = chunk[k];
try
{
var raw = answered[k].Value
?? throw new System.IO.InvalidDataException(
$"S7.Net returned null multi-var value for '{tag.Address}'");
results[index] = new DataValueSnapshot(ReinterpretRawValue(tag, addr, raw), 0u, now, now);
outcome.RecordSuccess();
}
catch (Exception ex)
{
// Decoding is pure and synchronous — no cancellation can surface here, so there
// is deliberately no OperationCanceledException arm to shadow the request one.
results[index] = new DataValueSnapshot(null, RecordReadFailure(ex, outcome), null, now);
}
}
}
// The fallback chunks were appended out of order behind the non-batchable tags.
stillPerTag.Sort();
return stillPerTag;
}
/// <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>A task that completes once every member of the span has been stamped.</returns>
private async Task ReadSpanAsync(
IS7Plc plc, S7ReadSpanPlanner.Span span, DataValueSnapshot[] results, DateTime now,
ReadPassOutcome outcome, CancellationToken ct)
{
byte[] block;
try
{
block = await plc.ReadBytesAsync(
ToS7NetArea(span.Area), span.DbNumber, span.StartByte, span.LengthBytes, ct)
.ConfigureAwait(false)
?? throw new System.IO.InvalidDataException(
$"S7.Net returned null block for {span.Area} db{span.DbNumber} " +
$"@{span.StartByte}+{span.LengthBytes}");
}
catch (OperationCanceledException)
{
// Same reasoning as the per-tag path: a cancelled block read leaves the single gated
// stream at an untrustworthy position, so the handle must be reopened (STAB-15a).
_plcDead = true;
throw;
}
catch (Exception ex)
{
var status = RecordReadFailure(ex, outcome);
foreach (var member in span.Members)
results[member.OriginalIndex] = new DataValueSnapshot(null, status, null, now);
return;
}
foreach (var member in span.Members)
{
try
{
var value = DecodeMember(member, block);
results[member.OriginalIndex] = new DataValueSnapshot(value, 0u, now, now);
outcome.RecordSuccess();
}
catch (Exception ex)
{
// Decoding is pure and synchronous — no cancellation can surface here, so there is
// deliberately no OperationCanceledException arm to shadow the block-read one above.
results[member.OriginalIndex] =
new DataValueSnapshot(null, RecordReadFailure(ex, outcome), null, now);
}
}
}
/// <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>
/// <returns>The OPC UA status code every affected tag must be stamped with.</returns>
private uint RecordReadFailure(Exception ex, ReadPassOutcome outcome)
{
switch (ex)
{
case NotSupportedException:
return StatusBadNotSupported;
case S7ValueRangeException:
// A PLC buffer the type codec refuses (uninitialised DTL, non-BCD S5TIME…) is a data
// problem, not a transport fault — no health change, no connection teardown.
return StatusBadOutOfRange;
case PlcException pex when IsAccessDenied(pex):
// PUT/GET-disabled (S7-1200/1500) / access-protection — a permanent configuration
// fault, NOT a transient one. Blind retry is wasted effort, so map it to
// BadNotSupported and flag the driver as a config alert (Faulted) rather than
// Degraded — per driver-specs.md §5.
outcome.RecordFailure(DriverState.Faulted,
"S7 access denied — enable PUT/GET communication in TIA Portal " +
$"(Protection & Security) for this CPU. PLC reported: {pex.Message}");
return StatusBadNotSupported;
case PlcException pex:
// A genuine device-layer fault (CPU error, hardware fault) — transient enough to
// keep retrying; report BadDeviceFailure and degrade health. A connection-fatal
// PlcException (socket drop / ConnectionError) additionally marks the handle dead
// so the next read reopens it.
MarkConnectionDeadIfFatal(pex);
outcome.RecordFailure(DriverState.Degraded, pex.Message);
return StatusBadDeviceFailure;
default:
// Socket / timeout / transport fault — mark the handle dead when it's a connection
// loss so EnsureConnectedAsync reopens on the next call.
MarkConnectionDeadIfFatal(ex);
outcome.RecordFailure(DriverState.Degraded, ex.Message);
return StatusBadCommunicationError;
}
}
private async Task<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
|| ByteAnchoredDataTypes.Contains(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}) decodes its 2-byte word to a COUNT 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)
{
// NOTE: S7.Net Counter.FromByteArray returns the RAW big-endian word ((bytes[0]<<8)|bytes[1]),
// NOT a BCD decode. On classic S7-300/400 the C-area word is BCD (0-999), so on that hardware
// this raw value can differ from the displayed count; S7-1200/1500 use IEC/DB counters (plain
// ints), where it is correct. Surfacing S7.Net's value verbatim is the faithful choice; BCD
// reinterpretation for legacy C-area counters is a live-hardware-gated follow-up.
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.
return global::S7.Net.Types.S7String.ToByteArray(Convert.ToString(value) ?? "", tag.StringLength);
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).
return global::S7.Net.Types.DateTime.ToByteArray(Convert.ToDateTime(value));
// 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);
try
{
// (Re)open under the gate; a failed (re)connect degrades every write to
// BadCommunicationError rather than throwing.
IS7Plc plc;
try
{
plc = await EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
}
// Rethrow ONLY caller cancellation — a timeout-born OCE degrades the batch below rather
// than escaping as teardown (STAB-14 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;
}
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 (STAB-15a).
// 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 (PlcException pex) when (IsAccessDenied(pex))
{
// 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);
outcome.RecordFailure(DriverState.Faulted,
"S7 access denied — enable PUT/GET communication in TIA Portal " +
$"(Protection & Security) for this CPU. PLC reported: {pex.Message}");
}
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(outcome);
_gate.Release();
}
return results;
}
/// <summary>
/// Publishes one write batch's verdict (STAB-22): 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="outcome">The batch's accumulated outcomes.</param>
private void ApplyWritePassHealth(WritePassOutcome outcome)
{
if (!outcome.HasObservations) 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
if (worst == DriverState.Healthy)
{
// Success-only batch: no health write, except to retire a Faulted that WE (the write
// side) were holding — a clean write is the only evidence the alert has cleared.
// Only a Faulted the write side owns: a read-side Faulted (e.g. access denied on another DB) is the
// read pass's to clear, and Health still carries the write fault's own message iff it is ours.
if (outcome.AnySuccess && _writeFault is null && priorWriteFault is { } owned
&& Health.State == DriverState.Faulted && Health.LastError == owned.Message)
Health = new DriverHealth(DriverState.Healthy, Health.LastSuccessfulRead, null);
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;
}
Health = new DriverHealth(state, Health.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 or a failure.</summary>
public bool HasObservations => _inner.HasObservations;
/// <summary>Records one successful write.</summary>
public void RecordSuccess() => _inner.RecordSuccess();
/// <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 (S7-016): 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> (STAB-8). This method is the single
/// choke-point all S7 reconnects flow through (already gate-serialized), so the
/// connect-attempt throttle lives here (STAB-21): 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;
// STAB-21: 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 (STAB-14, the unreachable-host regression in the Crit-3 fix).
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>
/// 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, STAB-14); 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 (STAB-15a: the Modbus STAB-3 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 (STAB-14) — 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 (STAB-15a): 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>
/// Detects an S7 PUT/GET-disabled / access-protection fault inside an S7.Net
/// <see cref="PlcException"/>. S7.Net's read/write paths wrap every PLC-side error in a
/// <c>PlcException</c> with <see cref="ErrorCode.ReadData"/> / <see cref="ErrorCode.WriteData"/>;
/// the response-code validator throws a plain <see cref="Exception"/> for the S7
/// <c>AccessingObjectNotAllowed</c> status, which lands as the inner exception. There is
/// no typed error code for it, so the inner message is the only discriminator
/// S7.Net exposes.
/// </summary>
internal static bool IsAccessDenied(PlcException pex)
{
for (Exception? e = pex; e is not null; e = e.InnerException)
{
if (e.Message.Contains("Accessing object not allowed", StringComparison.OrdinalIgnoreCase)
|| e.Message.Contains("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>
/// 05/STAB-9 — 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)
{
_logger.LogWarning(ex, "S7 poll reader failed. Driver={DriverInstanceId}", _driverInstanceId);
if (Health.State != DriverState.Faulted)
Health = new DriverHealth(DriverState.Degraded, Health.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)
{
var success = false;
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.
using var gateCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
gateCts.CancelAfter(_options.Probe.Timeout);
await _gate.WaitAsync(gateCts.Token).ConfigureAwait(false);
try
{
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 STAB-21's
// 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);
success = true;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Loop teardown — rethrow to the outer filter (after finally releases the gate).
throw;
}
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). We hold _gate
// here, keeping _plcDead gate-guarded (STAB-15b).
_plcDead = true;
}
catch (Exception ex)
{
// STAB-15b: 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);
}
}
finally { _gate.Release(); }
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
catch { /* gate-wait timeout / other — treated as Stopped below (gate contention is not a connection fault) */ }
TransitionTo(success ? HostState.Running : HostState.Stopped);
try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
}
}
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.Dispose();
}
/// <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;
try { await ShutdownAsync(CancellationToken.None).ConfigureAwait(false); }
catch { /* disposal is best-effort */ }
_gate.Dispose();
}
/// <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()
{
// Drain the polling overlay (drain-before-dispose, STAB-6-S7).
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;
_connectBackoff.RecordSuccess();
try { Plc?.Dispose(); } catch { /* best-effort — tearing down anyway */ }
Plc = null;
Volatile.Write(ref _negotiatedPduSize, 0); // a stale post-teardown reading would read as "online"
Health = new DriverHealth(DriverState.Unknown, Health.LastSuccessfulRead, null);
}
}