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