using System.Collections.Concurrent; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using S7.Net; using S7NetDataType = global::S7.Net.DataType; 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, ISubscribable, IHostConnectivityProbe, IDisposable, IAsyncDisposable { 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 => _tagsByName.TryGetValue(r, out var t) ? t : null, r => S7EquipmentTagParser.TryParse(r, out var d) ? d : 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)); } // ---- 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 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 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; private readonly Dictionary _tagsByName = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _parsedByName = new(StringComparer.OrdinalIgnoreCase); // Resolves a read/write/subscribe fullReference to a tag definition, bridging the two // authoring models: an authored tag-table entry (by name) OR an equipment tag whose // reference is its raw TagConfig JSON (parsed once via S7EquipmentTagParser, cached). 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); 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); // 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.CpuType, _options.Host, _options.Port, _options.Rack, _options.Slot, _options.Timeout); try { using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); cts.CancelAfter(_options.Timeout); await plc.OpenAsync(cts.Token).ConfigureAwait(false); } catch (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; // 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. _tagsByName.Clear(); _parsedByName.Clear(); _resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses foreach (var t in _options.Tags) { var parsed = S7AddressParser.Parse(t.Address); // throws FormatException _tagsByName[t.Name] = t; _parsedByName[t.Name] = parsed; } _initialized = true; _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null); _logger.LogInformation("S7Driver connected. Driver={DriverInstanceId} Host={Host} CPU={CpuType} Tags={TagCount}", _driverInstanceId, _options.Host, _options.CpuType, _options.Tags.Count); // 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; _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; try { Plc?.Dispose(); } catch { /* best-effort — tearing down anyway */ } Plc = null; _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); } /// public DriverHealth GetHealth() => _health; /// public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; /// /// 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 /// . /// private static readonly HashSet ByteAnchoredDataTypes = new() { S7DataType.Int64, S7DataType.UInt64, S7DataType.Float64, S7DataType.String, S7DataType.DateTime, }; /// /// 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 _options.Tags) { 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) Array tag declared Writable — WriteOneAsync has no WriteArrayAsync path yet. // Without this guard a writable array node is discovered and // accepted, then every write returns BadCommunicationError (InvalidCastException from // BoxValueForWrite receiving a typed array) instead of the informative BadNotSupported // the caller could act on. Wide-type arrays are already rejected above (guard-a); this // targets non-wide types (Int16[], Byte[], Float32[], etc.) that ARE supported for // reading but not yet for writing. if (t.ArrayCount is >= 1 && t.Writable) { throw new NotSupportedException( $"S7 tag '{t.Name}' is a {t.DataType} array (ArrayCount={t.ArrayCount}) declared Writable — " + "array writes are not yet supported by the S7 driver. Set Writable=false for read-only array tags, " + "or split into individual scalar tags until array-write support lands."); } } } /// /// 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 _options.Tags) { 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(); /// // The Plc instance + one 240-960 byte PDU buffer is under 4 KB; returns 0 because the // IDriver contract asks for a driver-attributable growth number and S7.Net doesn't expose one. public long GetMemoryFootprint() => 0; // ---- IReadable ---- /// 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]; 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; } for (var i = 0; i < fullReferences.Count; i++) { var name = fullReferences[i]; if (!_resolver.TryResolve(name, out var tag)) { results[i] = new DataValueSnapshot(null, StatusBadNodeIdUnknown, null, now); continue; } try { var value = await ReadOneAsync(plc, tag, cancellationToken).ConfigureAwait(false); results[i] = new DataValueSnapshot(value, 0u, now, now); _health = new DriverHealth(DriverState.Healthy, now, null); } 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. _plcDead = true; throw; } catch (NotSupportedException) { results[i] = new DataValueSnapshot(null, StatusBadNotSupported, null, now); } catch (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. results[i] = new DataValueSnapshot(null, StatusBadNotSupported, null, now); _health = new DriverHealth(DriverState.Faulted, _health.LastSuccessfulRead, "S7 access denied — enable PUT/GET communication in TIA Portal " + $"(Protection & Security) for this CPU. PLC reported: {pex.Message}"); } catch (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); results[i] = new DataValueSnapshot(null, StatusBadDeviceFailure, null, now); _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, pex.Message); } catch (Exception ex) { // Socket / timeout / transport fault — mark the handle dead when it's a // connection loss so EnsureConnectedAsync reopens on the next call. MarkConnectionDeadIfFatal(ex); results[i] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now); _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); } } } finally { _gate.Release(); } return results; } private async Task ReadOneAsync(IS7Plc plc, S7TagDefinition tag, CancellationToken ct) { // Authored tags pre-parse their address at init (_parsedByName); an equipment-tag ref // (resolved transiently by _resolver) has no _parsedByName entry, so parse its address // on demand. S7AddressParser.Parse throws FormatException on a bad address, which the // caller's catch maps to BadCommunicationError — the same surface a bad authored tag // would have hit at init (transient defs aren't init-validated). var addr = _parsedByName.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. private static bool IsBufferType(S7TagDefinition tag, S7ParsedAddress addr) => addr.Area is S7Area.Timer or S7Area.Counter || tag.DataType is S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64 or S7DataType.String or S7DataType.DateTime; /// /// 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, _ => 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), _ => 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)); 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, _): 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]; /// /// 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]; 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); _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, 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 transient-write guard: a transient equipment-tag ref is always // resolved with Writable=true (S7EquipmentTagParser hardcodes it, and node-level // auth governs writes), so Timer/Counter addresses bypass the !Writable gate above // and would otherwise reach EncodeScalarBlock, which throws NotSupportedException → // BadNotSupported. The docs say Timer/Counter writes return BadNotWritable. Detect // the area here before the call so BOTH authored (caught by guard-d at init) and // transient Timer/Counter writes consistently return BadNotWritable. // TryParse (not Parse) so a malformed address falls through to the existing // error path rather than throwing here. if (_parsedByName.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); } 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 (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); _health = new DriverHealth(DriverState.Faulted, _health.LastSuccessfulRead, "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); _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, pex.Message); } catch (Exception ex) { // Socket/timeout/conversion failure. Map to BadCommunicationError (not // BadInternalError) for transport faults; degrade health, and mark the handle // dead when it's a connection loss. MarkConnectionDeadIfFatal(ex); results[i] = new WriteResult(StatusBadCommunicationError); _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); } } } finally { _gate.Release(); } return results; } private async Task WriteOneAsync(IS7Plc plc, S7TagDefinition tag, object? value, CancellationToken ct) { // Defence-in-depth guard: authored array tags are rejected at init // (RejectUnsupportedTagConfigs guard-e), but a transient equipment-tag ref resolved by // _resolver bypasses that path. Both should fail with NotSupportedException → BadNotSupported, // not InvalidCastException → BadCommunicationError from BoxValueForWrite receiving an array. if (tag.ArrayCount is >= 1) throw new NotSupportedException( $"S7 array writes are not yet supported (tag '{tag.Name}'). " + "Use individual scalar tags or set Writable=false for read-only arrays."); // Parse the address the same way ReadOneAsync does: authored tags pre-parse at init // (_parsedByName); an equipment-tag ref (resolved transiently) parses on demand. 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 = _parsedByName.TryGetValue(tag.Name, out var parsed) ? parsed : S7AddressParser.Parse(tag.Address); // 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); } /// /// 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)"), _ => 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 pays 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 /// fleet-wide connect-attempt throttle plugs in here. R2-09 (sub-batch B3/B4) shipped the /// shared primitive /// (src/Core/…/Core.Abstractions/ConnectionBackoff.cs) — the S7 wiring is a /// per-driver ConnectionBackoff field consulted with ShouldAttempt(now) at /// the top of the slow path (fail fast + degrade inside the window) plus /// RecordFailure/RecordSuccess around the connect, all under _gate /// (which satisfies the primitive's caller-synchronized contract; TwinCAT/FOCAS/AbCip /// already wire it this way). Deliberately NOT implemented driver-locally here — owned by /// archreview/plans/R2-01-s7-fault-hardening-plan.md Finding 3. /// /// /// 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; var plc = _plcFactory.Create( _options.CpuType, _options.Host, _options.Port, _options.Rack, _options.Slot, _options.Timeout); try { using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(_options.Timeout); await plc.OpenAsync(cts.Token).ConfigureAwait(false); } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { // 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 { try { plc.Dispose(); } catch { /* best-effort */ } throw; } Plc = plc; _logger.LogInformation("S7Driver reconnected. Driver={DriverInstanceId} Host={Host} CPU={CpuType}", _driverInstanceId, _options.Host, _options.CpuType); return plc; } /// /// 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. /// private 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 _options.Tags) { // 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 => DriverDataType.String, S7DataType.DateTime => DriverDataType.DateTime, _ => DriverDataType.Int32, }; // ---- ISubscribable (polling overlay) ---- /// public Task SubscribeAsync( IReadOnlyList fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) => Task.FromResult(_poll.Subscribe(fullReferences, publishingInterval)); /// public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) { _poll.Unsubscribe(handle); return Task.CompletedTask; } /// /// 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 probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct); probeCts.CancelAfter(_options.Probe.Timeout); await _gate.WaitAsync(probeCts.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. var plc = await EnsureConnectedAsync(probeCts.Token).ConfigureAwait(false); 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; try { Plc?.Dispose(); } catch { /* best-effort — tearing down anyway */ } Plc = null; _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); } }