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