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

1666 lines
88 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using S7.Net;
using S7NetDataType = global::S7.Net.DataType;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
/// <summary>
/// Siemens S7 native driver — speaks S7comm over ISO-on-TCP (port 102) via the S7netplus
/// library. First implementation of <see cref="IDriver"/> for an in-process .NET Standard
/// PLC protocol that is NOT Modbus, validating that the v2 driver-capability interfaces
/// generalize beyond Modbus + Galaxy.
/// </summary>
/// <remarks>
/// <para>
/// PR 62 ships the scaffold: <see cref="IDriver"/> only (Initialize / Reinitialize /
/// Shutdown / GetHealth). <see cref="ITagDiscovery"/>, <see cref="IReadable"/>,
/// <see cref="IWritable"/>, <see cref="ISubscribable"/>, <see cref="IHostConnectivityProbe"/>
/// land in PRs 63-65 once the address parser (PR 63) is in place.
/// </para>
/// <para>
/// <b>Single-connection policy</b>: S7netplus documented pattern is one
/// <c>Plc</c> instance per PLC, serialized with a <see cref="SemaphoreSlim"/>.
/// Parallelising reads against a single S7 CPU doesn't help — the CPU scans the
/// communication mailbox at most once per cycle (2-10 ms) and queues concurrent
/// requests wire-side anyway. Multiple client-side connections just waste the CPU's
/// 8-64 connection-resource budget.
/// </para>
/// </remarks>
public sealed class S7Driver
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IDisposable, IAsyncDisposable
{
private readonly string _driverInstanceId;
private readonly ILogger<S7Driver> _logger;
private readonly IS7PlcFactory _plcFactory;
/// <summary>Initializes a new instance of the <see cref="S7Driver"/> class.</summary>
/// <param name="options">Driver configuration (the constructor-supplied fallback used when
/// Initialize/Reinitialize receive an empty config body).</param>
/// <param name="driverInstanceId">Unique driver instance identifier.</param>
/// <param name="plcFactory">Optional S7 connection factory; defaults to <see cref="S7PlcFactory"/>
/// (real S7.Net). Tests inject a fake to exercise the reconnect path without a live PLC.</param>
/// <param name="logger">Optional logger; a null logger is used when not supplied.</param>
public S7Driver(S7DriverOptions options, string driverInstanceId,
IS7PlcFactory? plcFactory = null, ILogger<S7Driver>? logger = null)
{
_options = options;
_driverInstanceId = driverInstanceId;
_plcFactory = plcFactory ?? new S7PlcFactory();
_logger = logger ?? NullLogger<S7Driver>.Instance;
_resolver = new EquipmentTagRefResolver<S7TagDefinition>(
r => _tagsByName.TryGetValue(r, out var t) ? t : null,
r => S7EquipmentTagParser.TryParse(r, out var d) ? d : null);
}
// ---- ISubscribable + IHostConnectivityProbe state ----
private readonly ConcurrentDictionary<long, SubscriptionState> _subscriptions = new();
private long _nextSubscriptionId;
private readonly object _probeLock = new();
private HostState _hostState = HostState.Unknown;
private DateTime _hostStateChangedUtc = DateTime.UtcNow;
private CancellationTokenSource? _probeCts;
/// <summary>
/// Handle to the in-flight probe loop. Tracked (rather than fire-and-forget) so
/// <see cref="ShutdownAsync"/> can await it after cancelling — otherwise a probe
/// iteration still inside the <see cref="_gate"/> would race a disposed semaphore.
/// </summary>
private Task? _probeTask;
/// <summary>
/// Bounded grace window <see cref="ShutdownAsync"/> waits for the probe + poll loops to
/// observe cancellation and exit before it disposes the shared semaphore / CTS objects.
/// </summary>
private static readonly TimeSpan DrainTimeout = TimeSpan.FromSeconds(5);
/// <summary>Occurs when a subscribed tag value or status code changes.</summary>
public event EventHandler<DataChangeEventArgs>? OnDataChange;
/// <summary>Occurs when host connectivity status changes.</summary>
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
/// <summary>OPC UA StatusCode used when the tag name isn't in the driver's tag map.</summary>
private const uint StatusBadNodeIdUnknown = 0x80340000u;
/// <summary>OPC UA StatusCode used when the tag's data type isn't implemented yet.</summary>
private const uint StatusBadNotSupported = 0x803D0000u;
/// <summary>OPC UA StatusCode used when the tag is declared read-only.</summary>
private const uint StatusBadNotWritable = 0x803B0000u;
/// <summary>OPC UA StatusCode used when write fails validation (e.g. out-of-range value).</summary>
private const uint StatusBadInternalError = 0x80020000u;
/// <summary>OPC UA StatusCode used for socket / timeout / protocol-layer faults.</summary>
private const uint StatusBadCommunicationError = 0x80050000u;
/// <summary>OPC UA StatusCode used for a genuine device fault (CPU error, hardware fault).</summary>
private const uint StatusBadDeviceFailure = 0x808B0000u;
private readonly Dictionary<string, S7TagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, S7ParsedAddress> _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<S7TagDefinition> _resolver;
/// <summary>
/// Active driver configuration. Seeded from the constructor argument, then replaced by
/// whatever <see cref="InitializeAsync"/> / <see cref="ReinitializeAsync"/> parse out of
/// the supplied <c>driverConfigJson</c>. The
/// constructor value is the fallback used when the caller passes an empty / placeholder
/// JSON document (e.g. the <c>"{}"</c> some unit tests pass).
/// </summary>
private S7DriverOptions _options;
private readonly SemaphoreSlim _gate = new(1, 1);
/// <summary>
/// Per-connection gate. Internal so PRs 63-65 (read/write/subscribe) can serialize on
/// the same semaphore without exposing it publicly. Single-connection-per-PLC is a
/// hard requirement of S7netplus — see class remarks.
/// </summary>
internal SemaphoreSlim Gate => _gate;
/// <summary>
/// Active S7 PLC connection. Null until <see cref="InitializeAsync"/> returns; null
/// after <see cref="ShutdownAsync"/>. Also (re)opened lazily by
/// <see cref="EnsureConnectedAsync"/> after a transient drop. Read-only outside this class;
/// Read/Write/probe take the <see cref="_gate"/> before touching it.
/// </summary>
internal IS7Plc? Plc { get; private set; }
/// <summary>
/// True once <see cref="InitializeAsync"/> has parsed the tag table + opened the first
/// connection, false after <see cref="ShutdownAsync"/>. Distinct from <c>Plc != null</c>
/// because the reconnect path can leave <see cref="Plc"/> transiently null / dead while the
/// driver is still initialized — a data call then repairs the connection rather than
/// throwing "not initialized". Guarded by <see cref="_gate"/>.
/// </summary>
private bool _initialized;
/// <summary>
/// Set when a connection-fatal fault (socket drop / <see cref="ErrorCode.ConnectionError"/>)
/// is observed on a read/write, so the next <see cref="EnsureConnectedAsync"/> disposes the
/// dead handle and re-opens. Kept as a flag (rather than nulling <see cref="Plc"/> inline) so
/// the dead handle is disposed exactly once, in <see cref="EnsureConnectedAsync"/>. Guarded
/// by <see cref="_gate"/>.
/// </summary>
private bool _plcDead;
private DriverHealth _health = new(DriverState.Unknown, null, null);
private bool _disposed;
/// <inheritdoc />
public string DriverInstanceId => _driverInstanceId;
/// <inheritdoc />
public string DriverType => "S7";
/// <inheritdoc />
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
_health = new DriverHealth(DriverState.Initializing, null, null);
try
{
// Re-parse the supplied DriverConfig JSON so a config change delivered through the
// IDriver contract is honoured. An empty / placeholder document
// (e.g. the "{}" some unit tests pass) keeps the constructor-supplied options.
if (HasConfigBody(driverConfigJson))
_options = S7DriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
// 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;
}
}
/// <inheritdoc />
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
// InitializeAsync re-parses driverConfigJson, so a config change delivered here is
// applied in place rather than silently discarded.
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
// Signal cancellation to the probe + poll loops first, collect their Task handles,
// then await all of them with a bounded timeout BEFORE disposing the shared semaphore
// and CTS objects. Without the drain, a loop iteration mid-_gate would call Release()
// on (or WaitAsync against) a disposed semaphore.
var drain = new List<Task>();
var probeCts = _probeCts;
var probeTask = _probeTask;
try { probeCts?.Cancel(); } catch { }
if (probeTask is not null) drain.Add(probeTask);
var subscriptions = _subscriptions.Values.ToArray();
_subscriptions.Clear();
foreach (var state in subscriptions)
{
try { state.Cts.Cancel(); } catch { }
drain.Add(state.PollTask);
}
if (drain.Count > 0)
{
try
{
await Task.WhenAll(drain).WaitAsync(DrainTimeout, CancellationToken.None)
.ConfigureAwait(false);
}
catch (TimeoutException) { /* a wedged loop — proceed; better than leaking the teardown */ }
catch { /* loop faults are already surfaced via health; teardown continues */ }
}
// Loops have now observed cancellation and released _gate — safe to dispose the CTSs.
probeCts?.Dispose();
_probeCts = null;
_probeTask = null;
foreach (var state in subscriptions)
state.Cts.Dispose();
_initialized = false;
_plcDead = false;
try { Plc?.Dispose(); } catch { /* best-effort — tearing down anyway */ }
Plc = null;
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
}
/// <inheritdoc />
public DriverHealth GetHealth() => _health;
/// <inheritdoc />
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <summary>
/// True when <paramref name="driverConfigJson"/> carries a real config body. The
/// bootstrapper always passes a populated document; some unit tests pass <c>"{}"</c> or
/// an empty string to exercise lifecycle shape without a config — those keep the
/// constructor-supplied <see cref="_options"/>.
/// </summary>
private static bool HasConfigBody(string? driverConfigJson)
{
if (string.IsNullOrWhiteSpace(driverConfigJson)) return false;
var trimmed = driverConfigJson.Trim();
return trimmed is not "{}" and not "[]";
}
/// <summary>
/// S7 data types addressed by START BYTE (the <c>B</c> suffix) rather than by an
/// access-width suffix. Phase 4d decodes these from a contiguous byte buffer
/// (<c>DB{n}.DBB{offset}</c> / <c>MB</c> / <c>IB</c> / <c>QB{offset}</c>), so a wide tag
/// authored against a non-byte suffix (DBW/DBD/…) is a config error — see
/// <see cref="RejectUnsupportedTagConfigs"/>.
/// </summary>
private static readonly HashSet<S7DataType> ByteAnchoredDataTypes = new()
{
S7DataType.Int64,
S7DataType.UInt64,
S7DataType.Float64,
S7DataType.String,
S7DataType.DateTime,
};
/// <summary>
/// Combined init-time config guard (Phase 4d, supersedes the old Timer/Counter-only
/// <c>RejectUnsupportedTagAddresses</c>). Timer/Counter tags and the wide/structured
/// types (Int64/UInt64/Float64/String/DateTime) are now <i>supported</i> — but only in
/// specific shapes. This guard fails fast on the genuinely-unsupported configurations so
/// a config typo throws here at init rather than as a misleading per-read fault:
/// <list type="bullet">
/// <item>(a) a wide/structured type declared as an array — wide-type arrays are out of scope this phase.</item>
/// <item>(b) a wide/structured type on a DB/M/I/Q area whose address isn't byte-anchored (<c>DBB</c>/<c>MB</c>/<c>IB</c>/<c>QB</c>).</item>
/// <item>(c) a Timer tag not typed <c>Float64</c>, or a Counter tag not typed <c>Int32</c>.</item>
/// </list>
/// Addresses that don't parse are left to the existing <see cref="S7AddressParser.Parse"/>
/// pass in <see cref="InitializeAsync"/> so the parse error isn't double-handled.
/// </summary>
private void RejectUnsupportedTagConfigs()
{
foreach (var t in _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.");
}
}
}
/// <summary>
/// Rejects tags configured with an <see cref="S7DataType"/> still on the
/// <see cref="UnimplementedDataTypes"/> list — types <see cref="ReinterpretRawValue"/> /
/// <see cref="BoxValueForWrite"/> throw <see cref="NotSupportedException"/> for, which
/// would otherwise create live OPC UA nodes via <see cref="DiscoverAsync"/> that return
/// <c>BadNotSupported</c> on every access.
/// <b>The set is now empty</b> (Phase 4d unblocked all five wide types), so this rejects
/// nothing — kept as the seam / grep target should a type ever need re-gating at init.
/// </summary>
private void RejectUnsupportedTagDataTypes()
{
foreach (var t in _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.");
}
}
}
/// <summary>
/// S7DataType members that the read/write helpers throw NotSupportedException for.
/// <b>Now empty</b> — Phase 4d (S7 wide types + Timer/Counter) wired Int64, UInt64,
/// Float64, String, and DateTime through the byte-buffer codec, and Timer/Counter read
/// through it on AREA. Kept here (rather than reflecting over
/// <see cref="ReinterpretRawValue"/>) as the single grep target / future seam should a
/// data type ever need re-gating at init.
/// </summary>
private static readonly HashSet<S7DataType> UnimplementedDataTypes = new();
/// <inheritdoc />
// 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 ----
/// <inheritdoc />
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
// Validate the list before the init check so a null argument produces an
// ArgumentNullException (consistent with DiscoverAsync) rather than an
// InvalidOperationException from the not-initialized check.
ArgumentNullException.ThrowIfNull(fullReferences);
RequireInitialized();
var now = 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 (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<object> 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);
}
/// <summary>
/// Reads a 1-D array tag as ONE contiguous block (<c>count × element-bytes</c>) via
/// S7.Net's buffer-based <c>Plc.ReadBytesAsync(DataType, db, startByteAdr, count, ct)</c>
/// — a single PLC round-trip, NOT <c>N</c> string reads — then hands the raw byte block
/// to the pure <see cref="DecodeArrayBlock"/> decode loop. Timer/Counter areas are
/// already rejected at init, so only DB/M/I/Q reach here.
/// </summary>
private async Task<object> ReadArrayAsync(IS7Plc plc, S7TagDefinition tag, S7ParsedAddress addr, CancellationToken ct)
{
var count = tag.ArrayCount!.Value;
var elementBytes = ElementByteSize(addr.Size);
var totalBytes = count * elementBytes;
// ReadBytesAsync addresses by (area, db, startByteOffset, byteCount). The parser already
// normalised the start to a BYTE offset (ByteOffset) for DB/M/I/Q; a Bit array starts at
// its byte and consumes one byte per element (byte-granular contiguous bit access). S7.Net
// transparently splits a > PDU-sized block into multiple wire requests, so the driver
// doesn't have to chunk.
var area = ToS7NetArea(addr.Area);
var block = await plc.ReadBytesAsync(area, addr.DbNumber, addr.ByteOffset, totalBytes, ct)
.ConfigureAwait(false)
?? throw new System.IO.InvalidDataException($"S7.Net returned null block for '{tag.Address}'");
return DecodeArrayBlock(tag, addr, block);
}
/// <summary>
/// True when this tag must be read/written through the byte-buffer codec rather than
/// S7.Net's string path. Two families route here:
/// <list type="bullet">
/// <item>the wide/structured types (Int64/UInt64/Float64/String/DateTime), which are
/// byte-anchored (<c>DBB</c>/<c>MB</c>/<c>IB</c>/<c>QB</c>) and decoded from a
/// contiguous block — S7.Net's string ReadAsync only understands 1/2/4-byte size
/// suffixes, so they can't use the narrow path;</item>
/// <item>the Timer/Counter AREAS — a Timer (<c>T{n}</c>) decodes a 2-byte S5TIME word
/// to a duration in seconds (<c>double</c>) and a Counter (<c>C{n}</c>) decodes a
/// 2-byte word to a count (<c>int</c>); both are read-only this phase. Routing them
/// here (rather than through the narrow path, which would hit the dead Float64 stub and
/// throw <c>BadNotSupported</c> on every read) closes the interim correctness hole.</item>
/// </list>
/// The init guard already enforces byte-addressing + the Timer→Float64 / Counter→Int32
/// type constraints, so by the time a tag reaches here it is well-formed.
/// </summary>
/// <param name="tag">Tag definition carrying the <see cref="S7DataType"/>.</param>
/// <param name="addr">Parsed address — its <see cref="S7Area"/> routes Timer/Counter into the seam regardless of <see cref="S7DataType"/>.</param>
/// <returns><c>true</c> if the tag routes through the byte-buffer codec.</returns>
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;
/// <summary>
/// Reads a wide/structured scalar as ONE contiguous byte block via S7.Net's
/// buffer-based <c>Plc.ReadBytesAsync(DataType, db, startByteAdr, count, ct)</c> (a single
/// PLC round-trip), then hands the raw block to the pure <see cref="DecodeScalarBlock"/>
/// decode. Mirrors <see cref="ReadArrayAsync"/>'s shape.
/// </summary>
private async Task<object> ReadScalarBlockAsync(IS7Plc plc, S7TagDefinition tag, S7ParsedAddress addr, CancellationToken ct)
{
var width = ScalarByteWidth(tag, addr);
// For DB/M/I/Q, addr.ByteOffset is the start BYTE and `width` the byte count. For
// Timer/Counter, addr.ByteOffset is the timer/counter NUMBER (db stays 0) and width is 2:
// S7.Net's Timer/Counter transport size makes the request "count" word a per-2-byte unit,
// so passing 2 reads exactly one timer/counter's 2-byte block (matching S7.Net's own
// VarType.Timer/Counter path, which sets byteLength = varCount × 2). See ScalarByteWidth.
var block = await plc.ReadBytesAsync(ToS7NetArea(addr.Area), addr.DbNumber, addr.ByteOffset, width, ct)
.ConfigureAwait(false)
?? throw new System.IO.InvalidDataException($"S7.Net returned null block for '{tag.Address}'");
return DecodeScalarBlock(tag, addr, block);
}
/// <summary>
/// Byte width of one wide/structured scalar (or one Timer/Counter word).
/// <b>The parsed AREA takes precedence over the tag's <see cref="S7DataType"/></b>: a
/// Timer/Counter address is always 2 bytes (one S5TIME / counter word), even though a
/// Timer tag is typed <see cref="S7DataType.Float64"/> (which would otherwise yield 8) and
/// a Counter tag <see cref="S7DataType.Int32"/>. For DB/M/I/Q areas the width follows the
/// DataType: Int64/UInt64/Float64/DateTime are 8 bytes; an S7 STRING occupies
/// <c>StringLength + 2</c> bytes (the two-byte header carries the declared max length and
/// the current actual length). Throws for any non-buffer type — defensive;
/// <see cref="IsBufferType"/> gates this so a non-buffer type never reaches here.
/// </summary>
/// <param name="tag">Tag definition carrying the <see cref="S7DataType"/> and (for strings) <c>StringLength</c>.</param>
/// <param name="addr">Parsed address — Timer/Counter areas force a 2-byte width regardless of <see cref="S7DataType"/>.</param>
/// <returns>The byte width to read for this scalar.</returns>
internal static int ScalarByteWidth(S7TagDefinition tag, S7ParsedAddress addr)
{
// Area precedence: Timer (S5TIME) and Counter are a single 16-bit word on the wire. The
// Timer tag's Float64 DataType describes the DECODED value (seconds), not the read width,
// so it must NOT drive an 8-byte read — that would mis-frame the timer word.
if (addr.Area is S7Area.Timer or S7Area.Counter)
return 2;
return tag.DataType switch
{
S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64 => 8,
S7DataType.DateTime => 8, // 8 = S7 DATE_AND_TIME/DT BCD (S7.Net.Types.DateTime; the 8-byte DT, not 12-byte DTL).
S7DataType.String => tag.StringLength + 2,
_ => throw new InvalidOperationException(
$"S7 ScalarByteWidth called for non-buffer type {tag.DataType} (tag '{tag.Name}')"),
};
}
/// <summary>
/// Pure decode step — turns a raw S7 (big-endian) byte block into the wide scalar's CLR
/// value (<c>long</c> / <c>ulong</c> / <c>double</c>), boxed as <see cref="object"/>. No
/// network I/O — factored out of <see cref="ReadScalarBlockAsync"/> so the codec is
/// unit-testable against a known block without a live PLC (S7.Net ships no in-process
/// fake). Mirrors <see cref="DecodeArrayBlock"/>.
/// </summary>
/// <param name="tag">Tag definition carrying the wide <see cref="S7DataType"/>.</param>
/// <param name="addr">Parsed address (carried for the error surface + the Timer/Counter seam).</param>
/// <param name="block">Raw contiguous byte block read from the PLC (length == <see cref="ScalarByteWidth"/>).</param>
/// <returns>The decoded scalar value boxed as <see cref="object"/>.</returns>
internal static object DecodeScalarBlock(S7TagDefinition tag, S7ParsedAddress addr, byte[] block)
{
// AREA precedence first (before the DataType switch): a Timer (T{n}) decodes its 2-byte
// S5TIME word to a duration in SECONDS via S7.Net's Timer.FromByteArray (returns double);
// a Counter (C{n}) decodes its 2-byte word to a COUNT via Counter.FromByteArray (returns
// ushort, surfaced as a non-negative int to match the tag's Int32 type). Routing on Area
// here is essential: a Timer tag is typed Float64, so letting it fall to the Float64 arm
// below would ReadDoubleBigEndian over a 2-byte block = garbage/throw. Boxed explicitly so
// the box type is exactly double / int (not the switch-unified common type).
if (addr.Area is S7Area.Timer)
return (object)global::S7.Net.Types.Timer.FromByteArray(block);
if (addr.Area is S7Area.Counter)
{
// NOTE: S7.Net Counter.FromByteArray returns the RAW big-endian word ((bytes[0]<<8)|bytes[1]),
// NOT a BCD decode. On classic S7-300/400 the C-area word is BCD (0-999), so on that hardware
// this raw value can differ from the displayed count; S7-1200/1500 use IEC/DB counters (plain
// ints), where it is correct. Surfacing S7.Net's value verbatim is the faithful choice; BCD
// reinterpretation for legacy C-area counters is a live-hardware-gated follow-up.
return (object)(int)global::S7.Net.Types.Counter.FromByteArray(block);
}
// Each numeric arm is boxed to object explicitly: a bare switch expression would unify
// long/ulong/double to their common type (double) and box THAT, mis-typing Int64/UInt64.
return tag.DataType switch
{
S7DataType.Int64 => (object)System.Buffers.Binary.BinaryPrimitives.ReadInt64BigEndian(block),
S7DataType.UInt64 => (object)System.Buffers.Binary.BinaryPrimitives.ReadUInt64BigEndian(block),
S7DataType.Float64 => System.Buffers.Binary.BinaryPrimitives.ReadDoubleBigEndian(block),
// S7 classic STRING: [maxLen byte][curLen byte][chars…]. S7.Net's S7String.FromByteArray
// reads the two-byte header and returns exactly curLen ASCII chars, ignoring the reserved
// padding past curLen — it validates curLen against the block, so no extra guard is needed.
S7DataType.String => global::S7.Net.Types.S7String.FromByteArray(block),
// S7 classic DATE_AND_TIME (DT): 8-byte BCD value (year/month/day/hour/min/sec + ms +
// day-of-week nibble). S7.Net's DateTime.FromByteArray reads the BCD fields and returns a
// System.DateTime; the DT range is 19902089 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}"),
};
}
/// <summary>
/// Pure encode step — turns the caller's value into the raw S7 (big-endian) byte block
/// for a wide scalar write. No network I/O — factored out of
/// <see cref="WriteScalarBlockAsync"/> so the codec is unit-testable (mirrors
/// <see cref="BoxValueForWrite"/>).
/// </summary>
/// <param name="tag">Tag definition carrying the wide <see cref="S7DataType"/>.</param>
/// <param name="value">Value to encode (coerced via <c>Convert.To*</c>).</param>
/// <returns>The big-endian byte block to write.</returns>
internal static byte[] EncodeScalarBlock(S7TagDefinition tag, object? value)
{
// Timer/Counter are READ-ONLY this phase. A Timer tag is typed Float64 and a Counter tag
// Int32, so the DataType switch below would otherwise hand them a (wrong, 8/wide-byte)
// encode arm — guard on the parsed AREA first and refuse the write. The address parses to
// an S7Area without I/O; TryParse keeps a malformed address from masking the read-only
// contract (a bad address falls through to the DataType arms and is caught upstream).
if (S7AddressParser.TryParse(tag.Address, out var addr)
&& addr.Area is S7Area.Timer or S7Area.Counter)
{
throw new NotSupportedException(
$"S7 Timer/Counter writes are read-only this phase (tag '{tag.Name}' at '{tag.Address}').");
}
switch (tag.DataType)
{
case S7DataType.Int64:
{
var b = new byte[8];
System.Buffers.Binary.BinaryPrimitives.WriteInt64BigEndian(b, Convert.ToInt64(value));
return b;
}
case S7DataType.UInt64:
{
var b = new byte[8];
System.Buffers.Binary.BinaryPrimitives.WriteUInt64BigEndian(b, Convert.ToUInt64(value));
return b;
}
case S7DataType.Float64:
{
var b = new byte[8];
System.Buffers.Binary.BinaryPrimitives.WriteDoubleBigEndian(b, Convert.ToDouble(value));
return b;
}
case S7DataType.String:
// S7.Net's S7String.ToByteArray builds [maxLen=StringLength][curLen][chars…] and pads
// the result to the full reserved field (StringLength + 2 bytes) — exactly the width
// ReadScalarBlockAsync read, so WriteBytesAsync writes the whole reserved STRING. A value
// longer than StringLength throws ArgumentException (S7.Net rejects overflow; we do NOT
// silently truncate). A null value encodes as the empty string.
return global::S7.Net.Types.S7String.ToByteArray(Convert.ToString(value) ?? "", tag.StringLength);
case S7DataType.DateTime:
// S7.Net's DateTime.ToByteArray builds the 8-byte DT BCD block. It validates the
// 19902089 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}')");
}
}
/// <summary>Width in bytes of one array element for the given access size. Bit elements are
/// byte-granular over the wire (one byte per bool), so they cost 1 byte each.</summary>
/// <param name="size">The parsed access width.</param>
/// <returns>Element byte size: Bit/Byte = 1, Word = 2, DWord = 4.</returns>
internal static int ElementByteSize(S7Size size) => size switch
{
S7Size.Bit => 1,
S7Size.Byte => 1,
S7Size.Word => 2,
S7Size.DWord => 4,
_ => throw new InvalidOperationException($"Unknown S7Size {size}"),
};
/// <summary>
/// Maps the driver's <see cref="S7Area"/> to S7.Net's <c>DataType</c> for the
/// buffer-based block read. Timer/Counter map to S7.Net's <c>Timer</c>/<c>Counter</c>
/// transport types — the <c>ScalarByteWidth</c> 2-byte read uses these to read one
/// timer/counter word (the array path never reaches Timer/Counter: wide-type arrays are
/// rejected at init and Timer/Counter are always scalar).
/// </summary>
private static S7NetDataType ToS7NetArea(S7Area area) => area switch
{
S7Area.DataBlock => S7NetDataType.DataBlock,
S7Area.Memory => S7NetDataType.Memory,
S7Area.Input => S7NetDataType.Input,
S7Area.Output => S7NetDataType.Output,
S7Area.Timer => S7NetDataType.Timer,
S7Area.Counter => S7NetDataType.Counter,
_ => throw new NotSupportedException($"S7 area {area} has no S7.Net DataType mapping"),
};
/// <summary>
/// Pure decode loop — turns a raw S7 (big-endian) byte block into an element-typed CLR
/// array (<c>short[]</c> / <c>ushort[]</c> / <c>int[]</c> / <c>uint[]</c> / <c>float[]</c>
/// / <c>byte[]</c> / <c>bool[]</c>), boxed as <see cref="object"/>. No network I/O —
/// factored out of <see cref="ReadArrayAsync"/> so the block-decode is unit-testable
/// against a known byte block without a live PLC (S7.Net ships no in-process fake).
/// Each element is read from its <c>i × element-bytes</c> slice using S7 big-endian byte
/// order, identical to the per-element semantics of <see cref="ReinterpretRawValue"/>.
/// </summary>
/// <param name="tag">Tag definition carrying the element <see cref="S7DataType"/> and array count.</param>
/// <param name="addr">Parsed address carrying the access <see cref="S7Size"/>.</param>
/// <param name="block">Raw contiguous byte block read from the PLC (length == count × element-bytes).</param>
/// <returns>An element-typed CLR array boxed as <see cref="object"/>.</returns>
internal static object DecodeArrayBlock(S7TagDefinition tag, S7ParsedAddress addr, byte[] block)
{
var count = tag.ArrayCount!.Value;
var elementBytes = ElementByteSize(addr.Size);
switch (tag.DataType, addr.Size)
{
case (S7DataType.Bool, S7Size.Bit):
{
var a = new bool[count];
for (var i = 0; i < count; i++)
a[i] = (block[i] & 0x01) != 0;
return a;
}
case (S7DataType.Byte, S7Size.Byte):
{
var a = new byte[count];
for (var i = 0; i < count; i++)
a[i] = block[i];
return a;
}
case (S7DataType.UInt16, S7Size.Word):
{
var a = new ushort[count];
for (var i = 0; i < count; i++)
a[i] = ReadBeUInt16(block, i * elementBytes);
return a;
}
case (S7DataType.Int16, S7Size.Word):
{
var a = new short[count];
for (var i = 0; i < count; i++)
a[i] = unchecked((short)ReadBeUInt16(block, i * elementBytes));
return a;
}
case (S7DataType.UInt32, S7Size.DWord):
{
var a = new uint[count];
for (var i = 0; i < count; i++)
a[i] = ReadBeUInt32(block, i * elementBytes);
return a;
}
case (S7DataType.Int32, S7Size.DWord):
{
var a = new int[count];
for (var i = 0; i < count; i++)
a[i] = unchecked((int)ReadBeUInt32(block, i * elementBytes));
return a;
}
case (S7DataType.Float32, S7Size.DWord):
{
var a = new float[count];
for (var i = 0; i < count; i++)
a[i] = BitConverter.UInt32BitsToSingle(ReadBeUInt32(block, i * elementBytes));
return a;
}
case (S7DataType.Int64, _):
case (S7DataType.UInt64, _):
case (S7DataType.Float64, _):
case (S7DataType.String, _):
case (S7DataType.DateTime, _):
throw new NotSupportedException(
$"S7 array reads of {tag.DataType} land in a follow-up PR");
default:
throw new System.IO.InvalidDataException(
$"S7 array Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address " +
$"'{tag.Address}' parsed as Size={addr.Size}");
}
}
/// <summary>Reads a big-endian 16-bit word from <paramref name="block"/> at <paramref name="offset"/>.</summary>
private static ushort ReadBeUInt16(byte[] block, int offset) =>
(ushort)((block[offset] << 8) | block[offset + 1]);
/// <summary>Reads a big-endian 32-bit dword from <paramref name="block"/> at <paramref name="offset"/>.</summary>
private static uint ReadBeUInt32(byte[] block, int offset) =>
((uint)block[offset] << 24) | ((uint)block[offset + 1] << 16)
| ((uint)block[offset + 2] << 8) | block[offset + 3];
/// <summary>
/// Pure reinterpret step — converts the boxed value that S7.Net returns (always an
/// unsigned type: <c>bool</c>, <c>byte</c>, <c>ushort</c>, <c>uint</c>) into the
/// SEMANTIC type declared by the tag's <see cref="S7DataType"/>. No network I/O.
/// Factored out of <see cref="ReadOneAsync"/> so it can be exercised in unit tests
/// without a live PLC.
/// </summary>
/// <param name="tag">Tag definition containing type information.</param>
/// <param name="addr">Parsed tag address.</param>
/// <param name="raw">Raw value from S7.Net.</param>
/// <returns>The reinterpreted value in the target semantic type.</returns>
internal static object ReinterpretRawValue(S7TagDefinition tag, S7ParsedAddress addr, object raw) =>
(tag.DataType, addr.Size, raw) switch
{
(S7DataType.Bool, S7Size.Bit, bool b) => b,
(S7DataType.Byte, S7Size.Byte, byte by) => by,
(S7DataType.UInt16, S7Size.Word, ushort u16) => u16,
(S7DataType.Int16, S7Size.Word, ushort u16) => unchecked((short)u16),
(S7DataType.UInt32, S7Size.DWord, uint u32) => u32,
(S7DataType.Int32, S7Size.DWord, uint u32) => unchecked((int)u32),
(S7DataType.Float32, S7Size.DWord, uint u32) => BitConverter.UInt32BitsToSingle(u32),
// Unreachable defensive backstop — every wide type (Int64/UInt64/Float64/String/
// DateTime) AND every Timer/Counter tag routes through the byte-buffer path
// (IsBufferType → ReadScalarBlockAsync → DecodeScalarBlock) before ReinterpretRawValue
// is ever consulted. Kept (not deleted) so a future regression that mis-routes a wide
// type to the narrow path fails loudly here instead of silently mis-decoding.
(S7DataType.Int64, _, _) => throw new NotSupportedException("S7 Int64 is unreachable here — wide types route through the byte-buffer path (DecodeScalarBlock)"),
(S7DataType.UInt64, _, _) => throw new NotSupportedException("S7 UInt64 is unreachable here — wide types route through the byte-buffer path (DecodeScalarBlock)"),
(S7DataType.Float64, _, _) => throw new NotSupportedException("S7 Float64 (LReal) is unreachable here — wide types route through the byte-buffer path (DecodeScalarBlock)"),
(S7DataType.String, _, _) => throw new NotSupportedException("S7 STRING is unreachable here — wide types route through the byte-buffer path (DecodeScalarBlock)"),
(S7DataType.DateTime, _, _) => throw new NotSupportedException("S7 DateTime is unreachable here — wide types route through the byte-buffer path (DecodeScalarBlock)"),
_ => throw new System.IO.InvalidDataException(
$"S7 Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " +
$"parsed as Size={addr.Size}; S7.Net returned {raw.GetType().Name}"),
};
// ---- IWritable ----
/// <inheritdoc />
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
{
// Same as ReadAsync — validate before the init check so a null argument is a
// typed argument error, not the "not initialized" surface.
ArgumentNullException.ThrowIfNull(writes);
RequireInitialized();
var results = new WriteResult[writes.Count];
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)
{
// Let cancellation propagate rather than turning it into
// a status code — the gate is still held so Release() runs in finally.
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);
}
/// <summary>
/// Writes a wide/structured scalar as ONE contiguous byte block via S7.Net's
/// buffer-based <c>Plc.WriteBytesAsync(DataType, db, startByteAdr, byte[] value, ct)</c>.
/// The pure <see cref="EncodeScalarBlock"/> produces the big-endian bytes; this method
/// owns only the network I/O (mirrors <see cref="ReadScalarBlockAsync"/>).
/// </summary>
private async Task WriteScalarBlockAsync(IS7Plc plc, S7TagDefinition tag, S7ParsedAddress addr, object? value, CancellationToken ct)
{
var bytes = EncodeScalarBlock(tag, value);
await plc.WriteBytesAsync(ToS7NetArea(addr.Area), addr.DbNumber, addr.ByteOffset, bytes, ct)
.ConfigureAwait(false);
}
/// <summary>
/// Pure boxing step — converts the caller's value into the unsigned wire type that
/// S7.Net's <c>Plc.WriteAsync</c> expects for each address size (bool → bool, byte
/// → byte, short → ushort, int → uint, float → uint-bits). No network I/O.
/// Factored out of <see cref="WriteOneAsync"/> so it can be exercised in unit tests
/// without a live PLC.
/// </summary>
/// <param name="dataType">Target S7 data type.</param>
/// <param name="value">Value to box.</param>
/// <returns>The boxed value in the wire type expected by S7.Net.</returns>
internal static object BoxValueForWrite(S7DataType dataType, object? value) => dataType switch
{
S7DataType.Bool => (object)Convert.ToBoolean(value),
S7DataType.Byte => (object)Convert.ToByte(value),
S7DataType.UInt16 => (object)Convert.ToUInt16(value),
S7DataType.Int16 => (object)unchecked((ushort)Convert.ToInt16(value)),
S7DataType.UInt32 => (object)Convert.ToUInt32(value),
S7DataType.Int32 => (object)unchecked((uint)Convert.ToInt32(value)),
S7DataType.Float32 => (object)BitConverter.SingleToUInt32Bits(Convert.ToSingle(value)),
// Unreachable defensive backstop — every wide type routes through the byte-buffer write
// path (IsBufferType → WriteScalarBlockAsync → EncodeScalarBlock) before BoxValueForWrite
// is consulted. Kept (not deleted) so a mis-route fails loudly rather than mis-encoding.
S7DataType.Int64 => throw new NotSupportedException("S7 Int64 is unreachable here — wide types route through the byte-buffer path (EncodeScalarBlock)"),
S7DataType.UInt64 => throw new NotSupportedException("S7 UInt64 is unreachable here — wide types route through the byte-buffer path (EncodeScalarBlock)"),
S7DataType.Float64 => throw new NotSupportedException("S7 Float64 (LReal) is unreachable here — wide types route through the byte-buffer path (EncodeScalarBlock)"),
S7DataType.String => throw new NotSupportedException("S7 STRING is unreachable here — wide types route through the byte-buffer path (EncodeScalarBlock)"),
S7DataType.DateTime => throw new NotSupportedException("S7 DateTime is unreachable here — wide types route through the byte-buffer path (EncodeScalarBlock)"),
_ => throw new InvalidOperationException($"Unknown S7DataType {dataType}"),
};
private void RequireInitialized()
{
if (!_initialized) throw new InvalidOperationException("S7Driver not initialized");
}
/// <summary>
/// Returns a live S7 connection, lazily disposing a dead handle and re-opening a fresh one
/// when the previous connection dropped (the reconnect path). The fast path returns the
/// current <see cref="Plc"/> when it is still connected and not marked dead; otherwise it
/// builds + opens a new connection via <see cref="_plcFactory"/> with the same
/// timeout-linked CTS the initial connect uses.
/// </summary>
/// <remarks>
/// MUST be called while holding <see cref="_gate"/> — it mutates <see cref="Plc"/> /
/// <see cref="_plcDead"/> and reuses the single-connection-per-PLC invariant the gate
/// enforces. The read / write / probe paths all take the gate before calling it.
/// </remarks>
/// <param name="ct">Cancellation token for the (re)connect attempt.</param>
/// <returns>A live <see cref="IS7Plc"/>.</returns>
private async Task<IS7Plc> EnsureConnectedAsync(CancellationToken ct)
{
// Fast path — the current connection is live and not flagged dead.
if (!_plcDead && Plc is { IsConnected: true } live) return live;
// Dispose the stale / dead handle before making a fresh one (exactly once, here).
if (Plc is not null)
{
try { Plc.Dispose(); } catch { /* best-effort */ }
Plc = null;
}
_plcDead = false;
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;
}
/// <summary>
/// Flags the current connection dead when <paramref name="ex"/> is a connection-level loss,
/// so the next <see cref="EnsureConnectedAsync"/> reopens. A no-op for data-address / type
/// faults — those don't warrant tearing down a healthy socket.
/// </summary>
/// <param name="ex">The exception observed on a read / write.</param>
private void MarkConnectionDeadIfFatal(Exception ex)
{
if (IsS7ConnectionFatal(ex)) _plcDead = true;
}
/// <summary>
/// True when <paramref name="ex"/> (or any inner exception) is a socket-level / connection
/// loss that a reopen can repair — a <see cref="System.Net.Sockets.SocketException"/> /
/// <see cref="System.IO.IOException"/> / <see cref="ObjectDisposedException"/>, or an S7.Net
/// <see cref="PlcException"/> carrying <see cref="ErrorCode.ConnectionError"/>. A
/// data-address / type error (S7.Net's <see cref="ErrorCode.ReadData"/> /
/// <see cref="ErrorCode.WriteData"/> for a bad address, PUT/GET-denied, etc.) is deliberately
/// NOT treated as fatal — reopening wouldn't help and would churn a healthy connection.
/// </summary>
/// <param name="ex">The exception to classify.</param>
/// <returns><c>true</c> when a reconnect is warranted.</returns>
private static bool IsS7ConnectionFatal(Exception ex)
{
for (Exception? e = ex; e is not null; e = e.InnerException)
{
if (e is System.Net.Sockets.SocketException or System.IO.IOException or ObjectDisposedException)
return true;
if (e is PlcException { ErrorCode: ErrorCode.ConnectionError })
return true;
}
return false;
}
/// <summary>
/// Detects an S7 PUT/GET-disabled / access-protection fault inside an S7.Net
/// <see cref="PlcException"/>. S7.Net's read/write paths wrap every PLC-side error in a
/// <c>PlcException</c> with <see cref="ErrorCode.ReadData"/> / <see cref="ErrorCode.WriteData"/>;
/// the response-code validator throws a plain <see cref="Exception"/> for the S7
/// <c>AccessingObjectNotAllowed</c> status, which lands as the inner exception. There is
/// no typed error code for it, so the inner message is the only discriminator
/// S7.Net exposes.
/// </summary>
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 ----
/// <inheritdoc />
// Run-once: DiscoverAsync emits the complete node set synchronously from the configured tag
// table in a single pass — nothing fills in asynchronously after connect, so a single
// discovery pass is sufficient.
public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;
/// <inheritdoc />
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(builder);
var folder = builder.Folder("S7", "S7");
foreach (var t in _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) ----
/// <inheritdoc />
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
{
var id = Interlocked.Increment(ref _nextSubscriptionId);
var cts = new CancellationTokenSource();
// Floor at 100 ms — S7 CPUs scan 2-10 ms but the comms mailbox is processed at most
// once per scan; sub-100 ms polling just queues wire-side with worse latency.
var interval = publishingInterval < TimeSpan.FromMilliseconds(100)
? TimeSpan.FromMilliseconds(100)
: publishingInterval;
var handle = new S7SubscriptionHandle(id);
var state = new SubscriptionState(handle, [.. fullReferences], interval, cts);
_subscriptions[id] = state;
// Track the poll Task so ShutdownAsync can await it after cancelling — a poll
// iteration mid-_gate would otherwise race the semaphore's disposal.
state.PollTask = Task.Run(() => PollLoopAsync(state, cts.Token), CancellationToken.None);
return Task.FromResult<ISubscriptionHandle>(handle);
}
/// <inheritdoc />
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
if (handle is S7SubscriptionHandle h && _subscriptions.TryRemove(h.Id, out var state))
{
state.Cts.Cancel();
state.Cts.Dispose();
}
return Task.CompletedTask;
}
/// <summary>
/// Upper bound on the poll-loop backoff window. After enough consecutive failures the
/// loop waits this long between retries instead of <see cref="SubscriptionState.Interval"/>,
/// so a subscription against a dropped / uninitialised driver doesn't spin.
/// </summary>
private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
/// <summary>
/// Number of consecutive poll failures before the loop transitions the driver's
/// health to <see cref="DriverState.Degraded"/>. One stray failure can be transient;
/// a sustained run indicates the operator should see it. Threshold of 1 because the
/// first failure already lives in the LastError surface.
/// </summary>
private const int PollFailureHealthThreshold = 1;
private async Task PollLoopAsync(SubscriptionState state, CancellationToken ct)
{
var consecutiveFailures = 0;
// Initial-data push per OPC UA Part 4 convention.
try
{
await PollOnceAsync(state, forceRaise: true, ct).ConfigureAwait(false);
consecutiveFailures = 0;
}
// Only a teardown OCE (the loop's own token) exits the loop; any other OCE (e.g. a
// connect-timeout that slipped past the wrapper filter) falls to the backoff path below
// so the loop lives (STAB-14 defense-in-depth).
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
catch (Exception ex)
{
// First-read error — polling continues; log so the operator has an event trail.
consecutiveFailures++;
HandlePollFailure(ex, consecutiveFailures, initial: true);
}
while (!ct.IsCancellationRequested)
{
// Capped exponential backoff: Interval, 2×, 4×, ... up to PollBackoffCap. Healthy
// ticks reset consecutiveFailures back to 0 so the cadence snaps back to Interval.
var delay = ComputeBackoffDelay(state.Interval, consecutiveFailures);
try { await Task.Delay(delay, ct).ConfigureAwait(false); }
// Task.Delay can only observe ct-triggered OCE, so this filter is purely for uniformity.
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
try
{
await PollOnceAsync(state, forceRaise: false, ct).ConfigureAwait(false);
consecutiveFailures = 0;
}
// Only teardown exits; any non-teardown OCE (e.g. a slipped-through connect timeout)
// backs off instead of killing the loop (STAB-14 defense-in-depth).
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
catch (Exception ex)
{
// Sustained polling error — loop continues with backoff; log + update health.
consecutiveFailures++;
HandlePollFailure(ex, consecutiveFailures, initial: false);
}
}
}
/// <summary>
/// Logs the swallowed poll exception and, once <see cref="PollFailureHealthThreshold"/>
/// consecutive failures have accumulated, degrades the driver health so the failure
/// surfaces on the dashboard. The probe loop owns Running/Stopped
/// transitions for the host-connectivity surface, so we touch <see cref="_health"/>
/// rather than the probe state.
/// </summary>
private void HandlePollFailure(Exception ex, int consecutiveFailures, bool initial)
{
if (initial)
_logger.LogWarning(ex, "S7 poll initial-read failed. Driver={DriverInstanceId} ConsecutiveFailures={Count}",
_driverInstanceId, consecutiveFailures);
else
_logger.LogWarning(ex, "S7 poll tick failed. Driver={DriverInstanceId} ConsecutiveFailures={Count}",
_driverInstanceId, consecutiveFailures);
if (consecutiveFailures >= PollFailureHealthThreshold)
{
// Don't downgrade a Faulted state (e.g. PUT/GET-denied set by ReadAsync) — Faulted
// is a stronger signal than Degraded and is reserved for permanent config faults.
if (_health.State != DriverState.Faulted)
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
}
}
/// <summary>
/// Capped exponential backoff. <c>consecutiveFailures == 0</c> returns the configured
/// <paramref name="interval"/>; each subsequent failure doubles the wait up to
/// <see cref="PollBackoffCap"/>. Computed in ticks to avoid overflow at large counts.
/// </summary>
/// <param name="interval">Base polling interval.</param>
/// <param name="consecutiveFailures">Number of consecutive failures.</param>
/// <returns>The computed backoff delay.</returns>
internal static TimeSpan ComputeBackoffDelay(TimeSpan interval, int consecutiveFailures)
{
if (consecutiveFailures <= 0) return interval;
// Cap the shift to avoid overflow — at 30 the result already saturates PollBackoffCap
// for any reasonable Interval.
var shift = Math.Min(consecutiveFailures - 1, 30);
var ticks = interval.Ticks << shift;
if (ticks <= 0 || ticks > PollBackoffCap.Ticks) return PollBackoffCap;
return TimeSpan.FromTicks(ticks);
}
private async Task PollOnceAsync(SubscriptionState state, bool forceRaise, CancellationToken ct)
{
#pragma warning disable OTOPCUA0001 // Driver-INTERNAL self-call: S7Driver's own poll loop reading via its own ReadAsync; CapabilityInvoker wraps the driver from the dispatch layer OUTWARD, so the driver's internal poll must NOT re-wrap. Intentional, not a dispatch gap.
var snapshots = await ReadAsync(state.TagReferences, ct).ConfigureAwait(false);
#pragma warning restore OTOPCUA0001
for (var i = 0; i < state.TagReferences.Count; i++)
{
var tagRef = state.TagReferences[i];
var current = snapshots[i];
var lastSeen = state.LastValues.TryGetValue(tagRef, out var prev) ? prev : default;
if (forceRaise || !Equals(lastSeen?.Value, current.Value) || lastSeen?.StatusCode != current.StatusCode)
{
state.LastValues[tagRef] = current;
OnDataChange?.Invoke(this, new DataChangeEventArgs(state.Handle, tagRef, current));
}
}
}
private sealed record SubscriptionState(
S7SubscriptionHandle Handle,
IReadOnlyList<string> TagReferences,
TimeSpan Interval,
CancellationTokenSource Cts)
{
/// <summary>Gets the last known values for subscribed tags.</summary>
public ConcurrentDictionary<string, DataValueSnapshot> LastValues { get; }
= new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Handle to this subscription's poll loop. Tracked so <see cref="ShutdownAsync"/>
/// can await it after cancelling.
/// </summary>
public Task PollTask { get; set; } = Task.CompletedTask;
}
private sealed record S7SubscriptionHandle(long Id) : ISubscriptionHandle
{
/// <inheritdoc />
public string DiagnosticId => $"s7-sub-{Id}";
}
// ---- IHostConnectivityProbe ----
/// <summary>
/// Host identifier surfaced in <see cref="GetHostStatuses"/>. <c>host:port</c> format
/// matches the Modbus driver's convention so the Admin UI dashboard renders both
/// family's rows uniformly.
/// </summary>
public string HostName => $"{_options.Host}:{_options.Port}";
/// <inheritdoc />
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses()
{
lock (_probeLock)
return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)];
}
private async Task ProbeLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
var success = false;
try
{
// Probe via S7.Net's low-cost GetCpuStatus — returns the CPU state (Run/Stop)
// and is intentionally light on the comms mailbox. Single-word Plc.ReadAsync
// would also work but GetCpuStatus doubles as a "PLC actually up" check.
using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
probeCts.CancelAfter(_options.Probe.Timeout);
await _gate.WaitAsync(probeCts.Token).ConfigureAwait(false);
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;
}
finally { _gate.Release(); }
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
catch { /* transport/timeout/exception — treated as Stopped below */ }
TransitionTo(success ? HostState.Running : HostState.Stopped);
try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
}
}
private void TransitionTo(HostState newState)
{
HostState old;
lock (_probeLock)
{
old = _hostState;
if (old == newState) return;
_hostState = newState;
_hostStateChangedUtc = DateTime.UtcNow;
}
_logger.LogInformation("S7 probe transition. Driver={DriverInstanceId} Host={Host} {OldState} → {NewState}",
_driverInstanceId, HostName, old, newState);
OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, old, newState));
}
/// <summary>Disposes the driver and releases resources.</summary>
public void Dispose()
{
// Avoid the sync-over-async DisposeAsync().AsTask().GetAwaiter().GetResult()
// pattern (a known deadlock surface even when currently safe here). ShutdownAsync's
// body is effectively synchronous apart from waiting on probe/poll Tasks; do the same
// teardown directly, blocking only on the drain — and only with a bounded timeout so
// a wedged loop can't hang Dispose() indefinitely.
if (_disposed) return;
_disposed = true;
SynchronousTeardown();
_gate.Dispose();
}
/// <summary>Asynchronously disposes the driver and releases resources.</summary>
/// <returns>A task representing the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
try { await ShutdownAsync(CancellationToken.None).ConfigureAwait(false); }
catch { /* disposal is best-effort */ }
_gate.Dispose();
}
/// <summary>
/// Synchronous teardown — mirrors <see cref="ShutdownAsync"/> but blocks (with a bounded
/// timeout) on the probe + poll Tasks instead of awaiting them. Used by the sync
/// <see cref="Dispose"/> path so we don't sync-over-async <see cref="DisposeAsync"/>.
/// </summary>
private void SynchronousTeardown()
{
var drain = new List<Task>();
var probeCts = _probeCts;
var probeTask = _probeTask;
try { probeCts?.Cancel(); } catch { }
if (probeTask is not null) drain.Add(probeTask);
var subscriptions = _subscriptions.Values.ToArray();
_subscriptions.Clear();
foreach (var state in subscriptions)
{
try { state.Cts.Cancel(); } catch { }
drain.Add(state.PollTask);
}
if (drain.Count > 0)
{
try { Task.WhenAll(drain).Wait(DrainTimeout); }
catch { /* timeouts/loop faults are tolerated — teardown continues */ }
}
probeCts?.Dispose();
_probeCts = null;
_probeTask = null;
foreach (var state in subscriptions)
{
try { state.Cts.Dispose(); } catch { }
}
_initialized = false;
_plcDead = false;
try { Plc?.Dispose(); } catch { /* best-effort — tearing down anyway */ }
Plc = null;
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
}
}