ff055ebd79
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2587 lines
143 KiB
C#
2587 lines
143 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, IDisposable, IAsyncDisposable
|
||
{
|
||
/// <summary>Authored tags this driver could not build into its poll table (Gitea #541).</summary>
|
||
private readonly SkippedTagLedger _skippedTags = new();
|
||
|
||
/// <inheritdoc />
|
||
public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot();
|
||
|
||
private readonly string _driverInstanceId;
|
||
private readonly ILogger<S7Driver> _logger;
|
||
private readonly IS7PlcFactory _plcFactory;
|
||
|
||
/// <summary>Initializes a new instance of the <see cref="S7Driver"/> class.</summary>
|
||
/// <param name="options">Driver configuration (the constructor-supplied fallback used when
|
||
/// Initialize/Reinitialize receive an empty config body).</param>
|
||
/// <param name="driverInstanceId">Unique driver instance identifier.</param>
|
||
/// <param name="plcFactory">Optional S7 connection factory; defaults to <see cref="S7PlcFactory"/>
|
||
/// (real S7.Net). Tests inject a fake to exercise the reconnect path without a live PLC.</param>
|
||
/// <param name="logger">Optional logger; a null logger is used when not supplied.</param>
|
||
public S7Driver(S7DriverOptions options, string driverInstanceId,
|
||
IS7PlcFactory? plcFactory = null, ILogger<S7Driver>? logger = null)
|
||
{
|
||
_options = options;
|
||
_driverInstanceId = driverInstanceId;
|
||
_plcFactory = plcFactory ?? new S7PlcFactory();
|
||
_logger = logger ?? NullLogger<S7Driver>.Instance;
|
||
_resolver = new EquipmentTagRefResolver<S7TagDefinition>(
|
||
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
|
||
// CONV-1: the polling overlay runs on the shared PollGroupEngine (structural array diff +
|
||
// drain-before-dispose + capped-exponential failure backoff + caller-token-filtered OCE),
|
||
// retiring the bespoke S7 poll fork. minInterval 100 ms matches the S7 scan-mailbox floor;
|
||
// backoffCap 30 s matches the retired fork's PollBackoffCap.
|
||
_poll = new PollGroupEngine(
|
||
reader: ReadAsync,
|
||
onChange: (handle, tagRef, snapshot) =>
|
||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)),
|
||
minInterval: TimeSpan.FromMilliseconds(100),
|
||
onError: HandlePollError,
|
||
backoffCap: TimeSpan.FromSeconds(30),
|
||
onBatch: (handle, items) => OnDataChangeBatch?.Invoke(this, new DataChangeBatchEventArgs(handle, items)),
|
||
// The engine owns no Meter (Core.Abstractions takes no metrics dependency), so this
|
||
// closure IS the production consumer of its overrun signal.
|
||
onOverrun: _ => OtOpcUaTelemetry.PollOverruns.Add(1,
|
||
new KeyValuePair<string, object?>(OtOpcUaTelemetry.PollOverrunDriverTypeTag, DriverTypeNames.S7),
|
||
new KeyValuePair<string, object?>(OtOpcUaTelemetry.PollOverrunDriverInstanceTag, driverInstanceId)));
|
||
}
|
||
// ---- ISubscribable + IHostConnectivityProbe state ----
|
||
|
||
private readonly PollGroupEngine _poll;
|
||
private readonly object _probeLock = new();
|
||
private HostState _hostState = HostState.Unknown;
|
||
private DateTime _hostStateChangedUtc = DateTime.UtcNow;
|
||
private CancellationTokenSource? _probeCts;
|
||
|
||
/// <summary>
|
||
/// Handle to the in-flight probe loop. Tracked (rather than fire-and-forget) so
|
||
/// <see cref="ShutdownAsync"/> can await it after cancelling — otherwise a probe
|
||
/// iteration still inside the <see cref="_gate"/> would race a disposed semaphore.
|
||
/// </summary>
|
||
private Task? _probeTask;
|
||
|
||
/// <summary>
|
||
/// Bounded grace window <see cref="ShutdownAsync"/> waits for the probe + poll loops to
|
||
/// observe cancellation and exit before it disposes the shared semaphore / CTS objects.
|
||
/// </summary>
|
||
private static readonly TimeSpan DrainTimeout = TimeSpan.FromSeconds(5);
|
||
|
||
/// <summary>Occurs when a subscribed tag value or status code changes.</summary>
|
||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||
/// <summary>
|
||
/// Occurs once per poll cycle that produced at least one change, carrying that cycle's
|
||
/// changed tags. Both data-change events fire by design — this one once per cycle,
|
||
/// <see cref="OnDataChange"/> once per changed tag — so a consumer subscribes to EXACTLY
|
||
/// ONE of the two; subscribing both double-delivers.
|
||
/// </summary>
|
||
public event EventHandler<DataChangeBatchEventArgs>? OnDataChangeBatch;
|
||
/// <summary>Occurs when host connectivity status changes.</summary>
|
||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||
|
||
/// <summary>OPC UA StatusCode used when the tag name isn't in the driver's tag map.</summary>
|
||
private const uint StatusBadNodeIdUnknown = 0x80340000u;
|
||
/// <summary>OPC UA StatusCode used when the tag's data type isn't implemented yet.</summary>
|
||
private const uint StatusBadNotSupported = 0x803D0000u;
|
||
/// <summary>OPC UA StatusCode used when a written value has the wrong shape / length / element type.</summary>
|
||
private const uint StatusBadTypeMismatch = 0x80740000u;
|
||
/// <summary>OPC UA StatusCode used when a value is unrepresentable in the S7 type (write) or a PLC buffer does not decode (read).</summary>
|
||
private const uint StatusBadOutOfRange = 0x803C0000u;
|
||
/// <summary>OPC UA StatusCode used when the tag is declared read-only.</summary>
|
||
private const uint StatusBadNotWritable = 0x803B0000u;
|
||
/// <summary>OPC UA StatusCode used when write fails validation (e.g. out-of-range value).</summary>
|
||
private const uint StatusBadInternalError = 0x80020000u;
|
||
/// <summary>OPC UA StatusCode used for socket / timeout / protocol-layer faults.</summary>
|
||
private const uint StatusBadCommunicationError = 0x80050000u;
|
||
/// <summary>OPC UA StatusCode used for a genuine device fault (CPU error, hardware fault).</summary>
|
||
private const uint StatusBadDeviceFailure = 0x808B0000u;
|
||
|
||
// v3: the RawPath → definition table. Keyed by RawPath (the v3 tag identity, == def.Name),
|
||
// case-sensitive ordinal. Built in InitializeAsync from _options.RawTags via the pure factory.
|
||
private readonly Dictionary<string, S7TagDefinition> _tagsByRawPath = new(StringComparer.Ordinal);
|
||
// Pre-parsed S7 address per tag, keyed by RawPath (== def.Name). Populated alongside
|
||
// _tagsByRawPath at init so a config typo fails fast here rather than as a per-read fault.
|
||
private readonly Dictionary<string, S7ParsedAddress> _parsedByRawPath = new(StringComparer.Ordinal);
|
||
|
||
// Resolves a read/write/subscribe fullReference (a RawPath in v3) to a tag definition by a single
|
||
// authored-table hit; a miss is a miss (the driver surfaces BadNodeIdUnknown, never a parse attempt).
|
||
private readonly EquipmentTagRefResolver<S7TagDefinition> _resolver;
|
||
|
||
/// <summary>
|
||
/// Active driver configuration. Seeded from the constructor argument, then replaced by
|
||
/// whatever <see cref="InitializeAsync"/> / <see cref="ReinitializeAsync"/> parse out of
|
||
/// the supplied <c>driverConfigJson</c>. The
|
||
/// constructor value is the fallback used when the caller passes an empty / placeholder
|
||
/// JSON document (e.g. the <c>"{}"</c> some unit tests pass).
|
||
/// </summary>
|
||
private S7DriverOptions _options;
|
||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||
|
||
/// <summary>
|
||
/// Per-connection gate. Internal so PRs 63-65 (read/write/subscribe) can serialize on
|
||
/// the same semaphore without exposing it publicly. Single-connection-per-PLC is a
|
||
/// hard requirement of S7netplus — see class remarks.
|
||
/// </summary>
|
||
internal SemaphoreSlim Gate => _gate;
|
||
|
||
/// <summary>
|
||
/// Active S7 PLC connection. Null until <see cref="InitializeAsync"/> returns; null
|
||
/// after <see cref="ShutdownAsync"/>. Also (re)opened lazily by
|
||
/// <see cref="EnsureConnectedAsync"/> after a transient drop. Read-only outside this class;
|
||
/// Read/Write/probe take the <see cref="_gate"/> before touching it.
|
||
/// </summary>
|
||
internal IS7Plc? Plc { get; private set; }
|
||
|
||
/// <summary>
|
||
/// True once <see cref="InitializeAsync"/> has parsed the tag table + opened the first
|
||
/// connection, false after <see cref="ShutdownAsync"/>. Distinct from <c>Plc != null</c>
|
||
/// because the reconnect path can leave <see cref="Plc"/> transiently null / dead while the
|
||
/// driver is still initialized — a data call then repairs the connection rather than
|
||
/// throwing "not initialized". Guarded by <see cref="_gate"/>.
|
||
/// </summary>
|
||
private bool _initialized;
|
||
|
||
/// <summary>
|
||
/// Set when a connection-fatal fault (socket drop / <see cref="ErrorCode.ConnectionError"/>)
|
||
/// is observed on a read/write, so the next <see cref="EnsureConnectedAsync"/> disposes the
|
||
/// dead handle and re-opens. Kept as a flag (rather than nulling <see cref="Plc"/> inline) so
|
||
/// the dead handle is disposed exactly once, in <see cref="EnsureConnectedAsync"/>. Guarded
|
||
/// by <see cref="_gate"/>.
|
||
/// </summary>
|
||
private bool _plcDead;
|
||
|
||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||
|
||
/// <summary>
|
||
/// The health snapshot, accessed with volatile semantics: it is written from the gate-holding
|
||
/// read/write passes, the poll engine's error sink and the lifecycle methods, and read lock-free
|
||
/// by <see cref="GetHealth"/> (the health tick) — the same Volatile idiom <c>ModbusDriver</c> uses.
|
||
/// <see cref="DriverHealth"/> is an immutable record, so a reference swap is a complete update.
|
||
/// </summary>
|
||
private DriverHealth Health
|
||
{
|
||
get => Volatile.Read(ref _health);
|
||
set => Volatile.Write(ref _health, value);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Per-device connect-attempt throttle (STAB-21): a dead PLC costs one connect attempt per
|
||
/// backoff window (1 s doubling to 30 s) instead of one per data call / poll tick / probe tick.
|
||
/// Not internally synchronized — only touched from <see cref="EnsureConnectedAsync"/> (under
|
||
/// <see cref="_gate"/>) and from init/shutdown, which run with every loop drained.
|
||
/// </summary>
|
||
private readonly ConnectionBackoff _connectBackoff =
|
||
new(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(30));
|
||
|
||
/// <summary>Clock the connect backoff is measured on. Internal so tests step it deterministically.</summary>
|
||
internal Func<DateTime> BackoffClock { get; set; } = static () => DateTime.UtcNow;
|
||
|
||
/// <summary>
|
||
/// The sticky write-side config fault (access denied / PUT-GET disabled), or null. A read
|
||
/// pass that finds everything readable must NOT erase it (reads can succeed while writes are
|
||
/// protected); it is cleared by the next write pass that writes something successfully.
|
||
/// Guarded by <see cref="_gate"/>.
|
||
/// </summary>
|
||
private (DriverState State, string Message)? _writeFault;
|
||
private bool _disposed;
|
||
|
||
/// <inheritdoc />
|
||
public string DriverInstanceId => _driverInstanceId;
|
||
/// <inheritdoc />
|
||
public string DriverType => "S7";
|
||
|
||
/// <inheritdoc />
|
||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||
{
|
||
Health = new DriverHealth(DriverState.Initializing, null, null);
|
||
try
|
||
{
|
||
// Re-parse the supplied DriverConfig JSON so a config change delivered through the
|
||
// IDriver contract is honoured. An empty / placeholder document
|
||
// (e.g. the "{}" some unit tests pass) keeps the constructor-supplied options.
|
||
if (HasConfigBody(driverConfigJson))
|
||
_options = S7DriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
|
||
|
||
// v3: build the RawPath → definition table from the authored raw tags BEFORE the config
|
||
// guards run (they now iterate the mapped table). Each entry's TagConfig blob is mapped by
|
||
// the pure factory; the entry's WriteIdempotent flag is threaded onto the def (it lives on
|
||
// the RawTagEntry, not inside the blob). A mapper miss is skipped (logged), never thrown —
|
||
// one bad tag must not fail the whole driver init.
|
||
_tagsByRawPath.Clear();
|
||
_parsedByRawPath.Clear();
|
||
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
|
||
_skippedTags.BeginRebuild(_options.RawTags.Count);
|
||
foreach (var entry in _options.RawTags)
|
||
{
|
||
if (S7TagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
|
||
_tagsByRawPath[entry.RawPath] = def with { WriteIdempotent = entry.WriteIdempotent };
|
||
else
|
||
{
|
||
_skippedTags.Skip(entry.RawPath, "TagConfig did not map to a definition");
|
||
_logger.LogWarning(
|
||
"S7 tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
|
||
_driverInstanceId, entry.RawPath);
|
||
}
|
||
}
|
||
_skippedTags.Commit();
|
||
|
||
// Phase 4d combined config guard. Timer/Counter addresses and the wide/structured
|
||
// types (Int64/UInt64/Float64/String/DateTime) are now SUPPORTED, but only in
|
||
// specific shapes — this rejects the genuinely-unsupported configurations
|
||
// (wide-type arrays, non-byte-addressed wide scalars, wrong Timer/Counter DataType)
|
||
// so a config typo fails fast at init instead of as a misleading per-read fault.
|
||
RejectUnsupportedTagConfigs();
|
||
|
||
// Legacy data-type seam. The UnimplementedDataTypes set is now
|
||
// empty (Phase 4d unblocked all five wide types), so this rejects nothing — kept as
|
||
// the single grep target / future seam should a type ever need re-gating at init.
|
||
RejectUnsupportedTagDataTypes();
|
||
|
||
// Build the connection through the factory (the same seam EnsureConnectedAsync's reopen
|
||
// uses) so the read/write timeouts are baked in identically on connect and reconnect.
|
||
var plc = _plcFactory.Create(_options);
|
||
try
|
||
{
|
||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||
cts.CancelAfter(_options.Timeout);
|
||
await plc.OpenAsync(cts.Token).ConfigureAwait(false);
|
||
}
|
||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||
{
|
||
// The linked CancelAfter fired — a connect TIMEOUT, not a caller cancellation.
|
||
// Surface it as a TimeoutException so init-time and reconnect-time timeouts report
|
||
// the same exception type (STAB-14 consistency); the outer catch still faults health.
|
||
try { plc.Dispose(); } catch { }
|
||
throw new TimeoutException(
|
||
$"S7 connect to {_options.Host}:{_options.Port} timed out after " +
|
||
$"{(int)_options.Timeout.TotalMilliseconds} ms.");
|
||
}
|
||
catch
|
||
{
|
||
// Dispose the partially-constructed connection so a failed init doesn't leak it
|
||
// before the outer catch nulls Plc (Plc isn't assigned yet, so it can't).
|
||
try { plc.Dispose(); } catch { }
|
||
throw;
|
||
}
|
||
|
||
Plc = plc;
|
||
_plcDead = false;
|
||
_connectBackoff.RecordSuccess();
|
||
Volatile.Write(ref _negotiatedPduSize, plc.MaxPduSize);
|
||
|
||
// Parse every tag's address once at init so config typos fail fast here instead
|
||
// of surfacing as BadInternalError on every Read against the bad tag. The parser
|
||
// also rejects bit-offset > 7, DB 0, unknown area letters, etc. Keyed by RawPath
|
||
// (== def.Name) so Read/Write can pick up the pre-parsed address.
|
||
foreach (var t in _tagsByRawPath.Values)
|
||
{
|
||
_parsedByRawPath[t.Name] = S7AddressParser.Parse(t.Address); // throws FormatException
|
||
}
|
||
|
||
_initialized = true;
|
||
Health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||
_logger.LogInformation("S7Driver connected. Driver={DriverInstanceId} Host={Host} CPU={CpuType} PduSize={PduSize} Tags={TagCount}",
|
||
_driverInstanceId, _options.Host, _options.CpuType, _negotiatedPduSize, _tagsByRawPath.Count);
|
||
|
||
// Kick off the probe loop once the connection is up. Initial HostState stays
|
||
// Unknown until the first probe tick succeeds — avoids broadcasting a premature
|
||
// Running transition before any PDU round-trip has happened.
|
||
if (_options.Probe.Enabled)
|
||
{
|
||
_probeCts = new CancellationTokenSource();
|
||
// Track the probe Task (not fire-and-forget) so ShutdownAsync can await it
|
||
// before disposing _gate / _probeCts. Pass None to Task.Run so
|
||
// the delegate always runs and the handle is always awaitable; the loop's own
|
||
// token check handles cancellation.
|
||
_probeTask = Task.Run(() => ProbeLoopAsync(_probeCts.Token), CancellationToken.None);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// Clean up a partially-constructed Plc so a retry from the caller doesn't leak
|
||
// the TcpClient. Dispose is best-effort and idempotent.
|
||
try { Plc?.Dispose(); } catch { }
|
||
Plc = null;
|
||
Volatile.Write(ref _negotiatedPduSize, 0);
|
||
_initialized = false;
|
||
_plcDead = false;
|
||
Health = new DriverHealth(DriverState.Faulted, null, ex.Message);
|
||
_logger.LogError(ex, "S7Driver connect failed. Driver={DriverInstanceId} Host={Host}", _driverInstanceId, _options.Host);
|
||
throw;
|
||
}
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||
{
|
||
// InitializeAsync re-parses driverConfigJson, so a config change delivered here is
|
||
// applied in place rather than silently discarded.
|
||
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
|
||
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||
{
|
||
// Signal cancellation to the probe + poll loops first, collect their Task handles,
|
||
// then await all of them with a bounded timeout BEFORE disposing the shared semaphore
|
||
// and CTS objects. Without the drain, a loop iteration mid-_gate would call Release()
|
||
// on (or WaitAsync against) a disposed semaphore.
|
||
// Drain the polling overlay first — the engine cancels each loop and awaits it before
|
||
// disposing its CTS (drain-before-dispose, STAB-6-S7), so no poll iteration touches _gate
|
||
// after this returns.
|
||
await _poll.DisposeAsync().ConfigureAwait(false);
|
||
|
||
var probeCts = _probeCts;
|
||
var probeTask = _probeTask;
|
||
try { probeCts?.Cancel(); } catch { }
|
||
if (probeTask is not null)
|
||
{
|
||
try
|
||
{
|
||
await probeTask.WaitAsync(DrainTimeout, CancellationToken.None).ConfigureAwait(false);
|
||
}
|
||
catch (TimeoutException) { /* a wedged probe loop — proceed; better than leaking the teardown */ }
|
||
catch { /* loop faults are already surfaced via health; teardown continues */ }
|
||
}
|
||
|
||
// The probe loop has now observed cancellation and released _gate — safe to dispose the CTS.
|
||
probeCts?.Dispose();
|
||
_probeCts = null;
|
||
_probeTask = null;
|
||
|
||
_initialized = false;
|
||
_plcDead = false;
|
||
_writeFault = null;
|
||
_connectBackoff.RecordSuccess();
|
||
try { Plc?.Dispose(); } catch { /* best-effort — tearing down anyway */ }
|
||
Plc = null;
|
||
Volatile.Write(ref _negotiatedPduSize, 0); // a stale post-teardown reading would read as "online"
|
||
Health = new DriverHealth(DriverState.Unknown, Health.LastSuccessfulRead, null);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public DriverHealth GetHealth() => Health;
|
||
|
||
/// <summary>
|
||
/// True when <paramref name="driverConfigJson"/> carries a real config body. The
|
||
/// bootstrapper always passes a populated document; some unit tests pass <c>"{}"</c> or
|
||
/// an empty string to exercise lifecycle shape without a config — those keep the
|
||
/// constructor-supplied <see cref="_options"/>.
|
||
/// </summary>
|
||
private static bool HasConfigBody(string? driverConfigJson)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(driverConfigJson)) return false;
|
||
var trimmed = driverConfigJson.Trim();
|
||
return trimmed is not "{}" and not "[]";
|
||
}
|
||
|
||
/// <summary>
|
||
/// S7 data types addressed by START BYTE (the <c>B</c> suffix) rather than by an
|
||
/// access-width suffix. Phase 4d decodes these from a contiguous byte buffer
|
||
/// (<c>DB{n}.DBB{offset}</c> / <c>MB</c> / <c>IB</c> / <c>QB{offset}</c>), so a wide tag
|
||
/// authored against a non-byte suffix (DBW/DBD/…) is a config error — see
|
||
/// <see cref="RejectUnsupportedTagConfigs"/>.
|
||
/// </summary>
|
||
/// <summary>
|
||
/// PDU size the CPU negotiated on the most recent successful connect, in bytes; 0 before the first
|
||
/// connect and after shutdown. Snapshotted at connect so it stays readable while the handle is
|
||
/// being replaced. Surfaced through <see cref="GetDiagnostics"/> and the connect log line.
|
||
/// </summary>
|
||
public int NegotiatedPduSize => Volatile.Read(ref _negotiatedPduSize);
|
||
|
||
private int _negotiatedPduSize;
|
||
|
||
/// <summary>
|
||
/// Driver-specific diagnostics key/value pairs (currently <c>S7.NegotiatedPduSize</c>). The core
|
||
/// <see cref="DriverHealth"/> record carries no diagnostics bag, so this is the surface until one exists.
|
||
/// </summary>
|
||
/// <returns>A snapshot of the diagnostics.</returns>
|
||
public IReadOnlyDictionary<string, object> GetDiagnostics() => new Dictionary<string, object>
|
||
{
|
||
["S7.NegotiatedPduSize"] = NegotiatedPduSize,
|
||
};
|
||
|
||
private static readonly HashSet<S7DataType> ByteAnchoredDataTypes = new()
|
||
{
|
||
S7DataType.Int64,
|
||
S7DataType.UInt64,
|
||
S7DataType.Float64,
|
||
S7DataType.String,
|
||
S7DataType.DateTime,
|
||
S7DataType.WString,
|
||
S7DataType.Char,
|
||
S7DataType.WChar,
|
||
S7DataType.Dtl,
|
||
S7DataType.Date,
|
||
S7DataType.Tod,
|
||
S7DataType.Time,
|
||
S7DataType.S5Time,
|
||
};
|
||
|
||
/// <summary>
|
||
/// Combined init-time config guard (Phase 4d, supersedes the old Timer/Counter-only
|
||
/// <c>RejectUnsupportedTagAddresses</c>). Timer/Counter tags and the wide/structured
|
||
/// types (Int64/UInt64/Float64/String/DateTime) are now <i>supported</i> — but only in
|
||
/// specific shapes. This guard fails fast on the genuinely-unsupported configurations so
|
||
/// a config typo throws here at init rather than as a misleading per-read fault:
|
||
/// <list type="bullet">
|
||
/// <item>(a) a wide/structured type declared as an array — wide-type arrays are out of scope this phase.</item>
|
||
/// <item>(b) a wide/structured type on a DB/M/I/Q area whose address isn't byte-anchored (<c>DBB</c>/<c>MB</c>/<c>IB</c>/<c>QB</c>).</item>
|
||
/// <item>(c) a Timer tag not typed <c>Float64</c>, or a Counter tag not typed <c>Int32</c>.</item>
|
||
/// </list>
|
||
/// Addresses that don't parse are left to the existing <see cref="S7AddressParser.Parse"/>
|
||
/// pass in <see cref="InitializeAsync"/> so the parse error isn't double-handled.
|
||
/// </summary>
|
||
private void RejectUnsupportedTagConfigs()
|
||
{
|
||
foreach (var t in _tagsByRawPath.Values)
|
||
{
|
||
var isWide = ByteAnchoredDataTypes.Contains(t.DataType);
|
||
|
||
// (a) wide-type arrays — out of scope this phase.
|
||
if (isWide && t.ArrayCount is >= 1)
|
||
{
|
||
throw new NotSupportedException(
|
||
$"S7 tag '{t.Name}' is a {t.DataType} array, which the S7 driver does not yet " +
|
||
"support. Use a scalar wide tag, or an Int16/UInt16/Int32/UInt32/Float32/Byte/Bool " +
|
||
"element type for arrays.");
|
||
}
|
||
|
||
// Parse the address; if it doesn't parse, leave it for the InitializeAsync
|
||
// S7AddressParser.Parse pass to throw the FormatException (no double-handling).
|
||
if (!S7AddressParser.TryParse(t.Address, out var parsed))
|
||
continue;
|
||
|
||
// (b) wide/structured scalar on DB/M/I/Q must be byte-anchored (Size == Byte). The
|
||
// codec decodes from a byte buffer at the start byte; a non-byte suffix (DBW/DBD/…)
|
||
// would mis-frame the value.
|
||
if (isWide
|
||
&& parsed.Area is S7Area.DataBlock or S7Area.Memory or S7Area.Input or S7Area.Output
|
||
&& parsed.Size != S7Size.Byte)
|
||
{
|
||
throw new NotSupportedException(
|
||
$"S7 tag '{t.Name}' is a {t.DataType} at '{t.Address}', which is not byte-addressed. " +
|
||
"Wide/structured types must be byte-addressed (DBB/MB/IB/QB) — e.g. 'DB1.DBB0', 'MB4'.");
|
||
}
|
||
|
||
// (c) Timer/Counter type constraints — Timer decodes to a Float64 (seconds), Counter
|
||
// to an Int32 (count); both are read-only.
|
||
if (parsed.Area is S7Area.Timer && t.DataType != S7DataType.Float64)
|
||
{
|
||
throw new NotSupportedException(
|
||
$"S7 tag '{t.Name}' is a Timer address ('{t.Address}') but is typed {t.DataType}. " +
|
||
"Timer tags must be DataType=Float64 (decoded to seconds, read-only).");
|
||
}
|
||
if (parsed.Area is S7Area.Counter && t.DataType != S7DataType.Int32)
|
||
{
|
||
throw new NotSupportedException(
|
||
$"S7 tag '{t.Name}' is a Counter address ('{t.Address}') but is typed {t.DataType}. " +
|
||
"Counter tags must be DataType=Int32 (decoded to count, read-only).");
|
||
}
|
||
|
||
// (d) Timer/Counter declared Writable — writes are read-only this phase; without this
|
||
// a node is discovered as Operate-writable but every write returns BadNotSupported.
|
||
if (parsed.Area is S7Area.Timer or S7Area.Counter && t.Writable)
|
||
{
|
||
throw new NotSupportedException(
|
||
$"S7 tag '{t.Name}' is a Timer/Counter ('{t.Address}') declared Writable — " +
|
||
"Timer/Counter are read-only this phase; set Writable=false.");
|
||
}
|
||
|
||
// (e) Bool array declared Writable — element writes are byte-granular (one byte per
|
||
// element, mirroring the read), so a write would overwrite the 7 neighbouring bits of
|
||
// every byte it touches; a safe bit-packed write needs read-modify-write and is not
|
||
// implemented. Every other supported element type (Byte/Int16/UInt16/Int32/UInt32/
|
||
// Float32) writes as ONE contiguous block (EncodeArrayBlock) — S7-016. Wide-type arrays
|
||
// are already rejected above (guard-a).
|
||
if (t.ArrayCount is >= 1 && t.Writable && t.DataType == S7DataType.Bool)
|
||
{
|
||
throw new NotSupportedException(
|
||
$"S7 tag '{t.Name}' is a Bool array (ArrayCount={t.ArrayCount}) declared Writable — " +
|
||
"Bool array writes are not supported (a byte-granular write would clobber the neighbouring " +
|
||
"bits). Set Writable=false, or write the bits as individual scalar Bool tags.");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Rejects tags configured with an <see cref="S7DataType"/> still on the
|
||
/// <see cref="UnimplementedDataTypes"/> list — types <see cref="ReinterpretRawValue"/> /
|
||
/// <see cref="BoxValueForWrite"/> throw <see cref="NotSupportedException"/> for, which
|
||
/// would otherwise create live OPC UA nodes via <see cref="DiscoverAsync"/> that return
|
||
/// <c>BadNotSupported</c> on every access.
|
||
/// <b>The set is now empty</b> (Phase 4d unblocked all five wide types), so this rejects
|
||
/// nothing — kept as the seam / grep target should a type ever need re-gating at init.
|
||
/// </summary>
|
||
private void RejectUnsupportedTagDataTypes()
|
||
{
|
||
foreach (var t in _tagsByRawPath.Values)
|
||
{
|
||
if (UnimplementedDataTypes.Contains(t.DataType))
|
||
{
|
||
throw new NotSupportedException(
|
||
$"S7 tag '{t.Name}' uses data type '{t.DataType}' which is not yet " +
|
||
"supported by the S7 driver — Read/Write would return BadNotSupported. " +
|
||
"Remove the tag or use Bool/Byte/Int16/UInt16/Int32/UInt32/Float32 until " +
|
||
$"{t.DataType} is wired through.");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// S7DataType members that the read/write helpers throw NotSupportedException for.
|
||
/// <b>Now empty</b> — Phase 4d (S7 wide types + Timer/Counter) wired Int64, UInt64,
|
||
/// Float64, String, and DateTime through the byte-buffer codec, and Timer/Counter read
|
||
/// through it on AREA. Kept here (rather than reflecting over
|
||
/// <see cref="ReinterpretRawValue"/>) as the single grep target / future seam should a
|
||
/// data type ever need re-gating at init.
|
||
/// </summary>
|
||
private static readonly HashSet<S7DataType> UnimplementedDataTypes = new();
|
||
|
||
// ---- IReadable ----
|
||
|
||
/// <inheritdoc />
|
||
/// <remarks>
|
||
/// <para>
|
||
/// The batch is served in two passes under ONE gate acquisition. First
|
||
/// <see cref="S7ReadSpanPlanner"/> groups the resolved tags into contiguous
|
||
/// <c>(area, DB)</c> byte spans and each span is read with a single
|
||
/// <c>ReadBytesAsync</c>, its members decoded by slicing the returned block — this is
|
||
/// what turns one wire request per tag into one per contiguous block. Whatever the
|
||
/// planner declines (Timer/Counter, whose offsets are device numbers) plus any span not
|
||
/// worth issuing (see <see cref="PrefersPerTagRead"/>) falls to the leftover list.
|
||
/// </para>
|
||
/// <para>
|
||
/// Those leftovers get a SECOND grouping pass: the ones a
|
||
/// <see cref="S7DataItem"/> expresses cleanly — the narrow scalars, which is what a
|
||
/// demoted single-member span is — are batched into multi-var requests by
|
||
/// <see cref="ReadLeftoversByMultiVarAsync"/>, which crosses <c>(area, DB)</c>
|
||
/// boundaries a block read cannot. Whatever remains (Timer/Counter, unparseable
|
||
/// addresses, and any chunk whose multi-var request faulted) goes through the unchanged
|
||
/// per-tag <see cref="ReadOneAsync"/> path.
|
||
/// </para>
|
||
/// <para>
|
||
/// Setting <c>SpanReadMaxGapBytes</c> below zero skips BOTH passes, which
|
||
/// restores the pre-grouping behaviour exactly. Either way the result array is written
|
||
/// by original index, so the caller sees its own ordering regardless of how the batch
|
||
/// was partitioned, and both passes share <see cref="RecordReadFailure"/> so a fault
|
||
/// stamps the same status whichever path observed it.
|
||
/// </para>
|
||
/// </remarks>
|
||
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||
{
|
||
// Validate the list before the init check so a null argument produces an
|
||
// ArgumentNullException (consistent with DiscoverAsync) rather than an
|
||
// InvalidOperationException from the not-initialized check.
|
||
ArgumentNullException.ThrowIfNull(fullReferences);
|
||
RequireInitialized();
|
||
var now = DateTime.UtcNow;
|
||
var results = new DataValueSnapshot[fullReferences.Count];
|
||
// Health is decided ONCE, at the end of the pass, from every outcome the pass observed —
|
||
// never per item. See ReadPassOutcome.
|
||
var outcome = new ReadPassOutcome();
|
||
|
||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||
try
|
||
{
|
||
// (Re)open the connection under the gate. A failed (re)connect degrades the whole
|
||
// batch to BadCommunicationError rather than throwing — the poll loop / next read
|
||
// retries, and EnsureConnectedAsync repairs on the following call.
|
||
IS7Plc plc;
|
||
try
|
||
{
|
||
plc = await EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
|
||
}
|
||
// Rethrow ONLY caller cancellation. A timeout-born OCE (none expected after the
|
||
// EnsureConnectedAsync conversion, but e.g. an S7.Net-internal CTS) must degrade the
|
||
// batch below, not escape as teardown (STAB-14).
|
||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
|
||
catch (Exception ex)
|
||
{
|
||
for (var i = 0; i < fullReferences.Count; i++)
|
||
results[i] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
|
||
Health = new DriverHealth(DriverState.Degraded, Health.LastSuccessfulRead, ex.Message);
|
||
return results;
|
||
}
|
||
|
||
// Resolve every reference first. An unresolvable one is a miss, stamped here and never
|
||
// offered to the planner — grouping must not be able to change what "unknown tag" means.
|
||
var resolved = new S7TagDefinition?[fullReferences.Count];
|
||
for (var i = 0; i < fullReferences.Count; i++)
|
||
{
|
||
if (_resolver.TryResolve(fullReferences[i], out var tag))
|
||
resolved[i] = tag;
|
||
else
|
||
results[i] = new DataValueSnapshot(null, StatusBadNodeIdUnknown, null, now);
|
||
}
|
||
|
||
// Indexes the per-tag path must serve: everything, when grouping is off; otherwise the
|
||
// planner's leftovers (Timer/Counter) plus any tag whose address won't parse.
|
||
var perTag = new List<int>();
|
||
|
||
if (_options.SpanReadMaxGapBytes >= 0)
|
||
{
|
||
List<(int OriginalIndex, S7TagDefinition Tag, S7ParsedAddress Addr)>? plannable = null;
|
||
for (var i = 0; i < fullReferences.Count; i++)
|
||
{
|
||
if (resolved[i] is not { } tag) continue;
|
||
if (!TryGetParsedAddress(tag, out var addr))
|
||
{
|
||
// Defensive: every tag pre-parses at init. A tag that somehow didn't goes
|
||
// to the per-tag path, where ReadOneAsync's Parse throws the FormatException
|
||
// the catch ladder has always mapped to BadCommunicationError.
|
||
perTag.Add(i);
|
||
continue;
|
||
}
|
||
(plannable ??= []).Add((i, tag, addr));
|
||
}
|
||
|
||
if (plannable is not null)
|
||
{
|
||
var plan = S7ReadSpanPlanner.Build(
|
||
plannable, _options.SpanReadMaxGapBytes, _options.SpanReadMaxSpanBytes);
|
||
foreach (var span in plan.Spans)
|
||
{
|
||
// A span that saves no round-trip is not worth changing the wire shape for.
|
||
if (PrefersPerTagRead(span))
|
||
{
|
||
perTag.Add(span.Members[0].OriginalIndex);
|
||
continue;
|
||
}
|
||
await ReadSpanAsync(plc, span, results, now, outcome, cancellationToken).ConfigureAwait(false);
|
||
}
|
||
perTag.AddRange(plan.LeftoverIndexes);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
for (var i = 0; i < fullReferences.Count; i++)
|
||
if (resolved[i] is not null) perTag.Add(i);
|
||
}
|
||
|
||
// Sorted so a batch is served in reference order regardless of how it was partitioned —
|
||
// the per-tag tail is otherwise ordered by the planner's grouping, not by the caller's.
|
||
perTag.Sort();
|
||
|
||
// Second grouping pass: the leftovers a DataItem can express ride ONE multi-var request
|
||
// per chunk instead of a wire request each. Deliberately gated on the SAME switch as the
|
||
// span pass — SpanReadMaxGapBytes < 0 is documented to restore the pre-grouping wire
|
||
// shape exactly, and batching the (then complete) per-tag list would break that. What
|
||
// comes back is the tags still owed an individual read, still in reference order.
|
||
if (_options.SpanReadMaxGapBytes >= 0)
|
||
{
|
||
perTag = await ReadLeftoversByMultiVarAsync(
|
||
plc, resolved, perTag, results, now, outcome, cancellationToken).ConfigureAwait(false);
|
||
}
|
||
|
||
foreach (var i in perTag)
|
||
{
|
||
try
|
||
{
|
||
var value = await ReadOneAsync(plc, resolved[i]!, cancellationToken).ConfigureAwait(false);
|
||
results[i] = new DataValueSnapshot(value, 0u, now, now);
|
||
outcome.RecordSuccess();
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
// Cancellation observed mid-PDU abandons a half-read ISO-on-TCP response on the
|
||
// single gated stream — the handle must be reopened, not reused (STAB-15a). Mark
|
||
// dead (we hold _gate) and propagate: the caller cancelled, nobody consumes the
|
||
// batch. Marking is unconditional on OCE (not routed through the type classifier):
|
||
// whether bytes moved is unknowable, and a false positive costs one cheap reopen
|
||
// versus permanent desync for a false negative. Kept AT the call site rather
|
||
// than inside RecordReadFailure so the rethrow preserves the original stack.
|
||
_plcDead = true;
|
||
throw;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
results[i] = new DataValueSnapshot(null, RecordReadFailure(ex, outcome), null, now);
|
||
}
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
// THE single health write of the pass — worst-state-wins over every outcome recorded
|
||
// above. In a FINALLY, so evidence the pass already gathered survives an abandoned
|
||
// batch: the three OCE arms rethrow mid-pass, and on a partially-working device a
|
||
// repeatedly-cancelled poll would otherwise freeze LastSuccessfulRead while tags were
|
||
// demonstrably still being read — tripping the comms-loss watchdog into a reconnect
|
||
// loop, the exact false positive the AnySuccess design exists to prevent.
|
||
//
|
||
// A pass that observed NOTHING reports nothing (HasObservations), which is also what
|
||
// makes flushing here safe on the two paths that reach the finally with no outcomes:
|
||
// the connect-failure early return, whose own Degraded write must NOT be overwritten,
|
||
// and an OCE that fired before any tag was stamped. Both no-op.
|
||
//
|
||
// LastSuccessfulRead advances whenever ANYTHING read, even into a Degraded/Faulted
|
||
// state — see ReadPassOutcome for why that is load-bearing.
|
||
//
|
||
// A sticky write-side Faulted (PUT/GET denied) outranks a read pass that found less
|
||
// wrong: reads can succeed against a CPU whose writes are protected, and a clean read
|
||
// must not erase the config alert. The write pass clears it on its next success.
|
||
if (outcome.HasObservations)
|
||
{
|
||
var state = outcome.WorstFailure;
|
||
var error = state == DriverState.Healthy ? null : outcome.WorstError;
|
||
if (_writeFault is { } wf && ReadPassOutcome.Rank(wf.State) > ReadPassOutcome.Rank(state))
|
||
{
|
||
state = wf.State;
|
||
error = wf.Message;
|
||
}
|
||
Health = new DriverHealth(
|
||
state,
|
||
outcome.AnySuccess ? now : Health.LastSuccessfulRead,
|
||
error);
|
||
}
|
||
|
||
_gate.Release();
|
||
}
|
||
return results;
|
||
}
|
||
|
||
/// <summary>
|
||
/// The outcomes ONE <see cref="ReadAsync"/> pass observed, accumulated so the pass writes
|
||
/// <see cref="Health"/> exactly once — <b>worst state wins</b>.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// Health used to be stamped per item, in three separate read loops, so the pass
|
||
/// reported whatever its LAST item happened to be: a mixed pass read <c>Healthy</c>, and
|
||
/// the access-denied <c>Faulted</c> config alert — usually a minority of the batch — was
|
||
/// erased by any later good tag. Ranking (Healthy < 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 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>
|
||
/// <returns>The indexes still owed an individual read, ascending.</returns>
|
||
private async Task<List<int>> ReadLeftoversByMultiVarAsync(
|
||
IS7Plc plc,
|
||
S7TagDefinition?[] resolved,
|
||
List<int> leftovers,
|
||
DataValueSnapshot[] results,
|
||
DateTime now,
|
||
ReadPassOutcome outcome,
|
||
CancellationToken ct)
|
||
{
|
||
var stillPerTag = new List<int>();
|
||
List<(int Index, S7TagDefinition Tag, S7ParsedAddress Addr)>? batchable = null;
|
||
|
||
foreach (var i in leftovers)
|
||
{
|
||
var tag = resolved[i]!;
|
||
if (tag.ArrayCount is >= 1
|
||
|| !TryGetParsedAddress(tag, out var addr)
|
||
|| IsBufferType(tag, addr))
|
||
{
|
||
stillPerTag.Add(i);
|
||
continue;
|
||
}
|
||
(batchable ??= []).Add((i, tag, addr));
|
||
}
|
||
|
||
// Nothing to gain: leave the (at most one) batchable tag exactly where it was.
|
||
if (batchable is null || batchable.Count < MultiVarReadMinItems)
|
||
{
|
||
if (batchable is not null)
|
||
foreach (var (index, _, _) in batchable) stillPerTag.Add(index);
|
||
stillPerTag.Sort();
|
||
return stillPerTag;
|
||
}
|
||
|
||
for (var start = 0; start < batchable.Count; start += MultiVarReadMaxItems)
|
||
{
|
||
var chunk = batchable.GetRange(start, Math.Min(MultiVarReadMaxItems, batchable.Count - start));
|
||
|
||
IReadOnlyList<S7DataItem> answered;
|
||
try
|
||
{
|
||
var items = new List<S7DataItem>(chunk.Count);
|
||
foreach (var (_, _, addr) in chunk) items.Add(ToDataItem(addr));
|
||
|
||
answered = await plc.ReadMultipleVarsAsync(items, ct).ConfigureAwait(false);
|
||
|
||
// The contract is "the same items back, in the supplied order". Anything else is
|
||
// unmappable to the batch's indexes, so treat it as a failed chunk rather than
|
||
// guessing which tag got which value.
|
||
if (answered is null || answered.Count != chunk.Count)
|
||
{
|
||
throw new System.IO.InvalidDataException(
|
||
$"S7.Net returned {answered?.Count.ToString() ?? "no"} multi-var item(s) " +
|
||
$"for a {chunk.Count}-item request");
|
||
}
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
// Same reasoning as the per-tag and span paths: a cancelled read abandons a half-read
|
||
// response on the single gated stream, so the handle must be reopened (STAB-15a).
|
||
_plcDead = true;
|
||
throw;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogDebug(ex,
|
||
"S7 driver {DriverInstanceId}: multi-var read of {ItemCount} leftover tag(s) failed; " +
|
||
"falling back to per-tag reads for that chunk", _driverInstanceId, chunk.Count);
|
||
foreach (var (index, _, _) in chunk) stillPerTag.Add(index);
|
||
continue;
|
||
}
|
||
|
||
for (var k = 0; k < chunk.Count; k++)
|
||
{
|
||
var (index, tag, addr) = chunk[k];
|
||
try
|
||
{
|
||
var raw = answered[k].Value
|
||
?? throw new System.IO.InvalidDataException(
|
||
$"S7.Net returned null multi-var value for '{tag.Address}'");
|
||
results[index] = new DataValueSnapshot(ReinterpretRawValue(tag, addr, raw), 0u, now, now);
|
||
outcome.RecordSuccess();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// Decoding is pure and synchronous — no cancellation can surface here, so there
|
||
// is deliberately no OperationCanceledException arm to shadow the request one.
|
||
results[index] = new DataValueSnapshot(null, RecordReadFailure(ex, outcome), null, now);
|
||
}
|
||
}
|
||
}
|
||
|
||
// The fallback chunks were appended out of order behind the non-batchable tags.
|
||
stillPerTag.Sort();
|
||
return stillPerTag;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Builds the multi-var request item for one narrow scalar — the exact
|
||
/// <c>(DataType, DB, StartByteAdr, BitAdr, VarType, Count)</c> tuple S7.Net's own address
|
||
/// parser derives from the equivalent address string, so the item reads and decodes
|
||
/// identically to <see cref="ReadOneAsync"/>'s per-address read.
|
||
/// </summary>
|
||
/// <param name="addr">The tag's parsed address; its <see cref="S7Size"/> picks the var type.</param>
|
||
/// <returns>The request item, with no value yet.</returns>
|
||
private static S7DataItem ToDataItem(S7ParsedAddress addr) => new()
|
||
{
|
||
DataType = ToS7NetArea(addr.Area),
|
||
DB = addr.DbNumber,
|
||
StartByteAdr = addr.ByteOffset,
|
||
// Non-Bit sizes parse to a bit offset of 0, which S7.Net ignores for those var types.
|
||
BitAdr = (byte)addr.BitOffset,
|
||
VarType = addr.Size switch
|
||
{
|
||
S7Size.Bit => VarType.Bit,
|
||
S7Size.Byte => VarType.Byte,
|
||
S7Size.Word => VarType.Word,
|
||
S7Size.DWord => VarType.DWord,
|
||
_ => throw new NotSupportedException($"S7 size {addr.Size} has no multi-var VarType mapping"),
|
||
},
|
||
Count = 1,
|
||
};
|
||
|
||
/// <summary>
|
||
/// True when a planned span is better served by the per-tag path: it holds exactly ONE
|
||
/// narrow scalar, so issuing it as a block read saves no round-trip and buys nothing.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// The point of grouping is to replace N wire requests with one. A lone narrow tag is one
|
||
/// wire request either way — but a block read reaches the CPU differently from S7.Net's
|
||
/// per-address string read, so converting it would change the observable wire shape of every
|
||
/// single-tag read on the server for zero benefit. A lone ARRAY or wide/buffer scalar is NOT
|
||
/// demoted: those already read a block on the per-tag path, so serving them as a
|
||
/// single-member span is byte-for-byte the request they always issued.
|
||
/// </remarks>
|
||
/// <param name="span">The planned span to judge.</param>
|
||
/// <returns><c>true</c> when the span's single member should go through <see cref="ReadOneAsync"/>.</returns>
|
||
private static bool PrefersPerTagRead(S7ReadSpanPlanner.Span span)
|
||
=> span.Members.Count == 1
|
||
&& span.Members[0].Tag.ArrayCount is not >= 1
|
||
&& !IsBufferType(span.Members[0].Tag, span.Members[0].Addr);
|
||
|
||
/// <summary>
|
||
/// Reads one planned span — a SINGLE <c>ReadBytesAsync</c> serving every member — and
|
||
/// decodes each member from its slice of the returned block.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// A span read is one wire request standing in for many per-tag requests, so a transport
|
||
/// fault that used to spoil one tag now spoils all of them: every member is stamped with
|
||
/// the SAME status <see cref="RecordReadFailure"/> would have produced for that exception on
|
||
/// the per-tag path, and health degrades identically. Decoding, by contrast, is per member
|
||
/// inside its own guard — a single mis-authored tag (declared type vs. address size) must
|
||
/// not spoil the neighbours that shared its block.
|
||
/// </remarks>
|
||
/// <param name="plc">The live connection (caller holds <see cref="_gate"/>).</param>
|
||
/// <param name="span">The planned block read.</param>
|
||
/// <param name="results">The batch's result array, written in place at each member's original index.</param>
|
||
/// <param name="now">The batch timestamp, shared by every tag in the batch.</param>
|
||
/// <param name="outcome">The pass's outcome accumulator; every member records into it.</param>
|
||
/// <param name="ct">Cancellation token for the read.</param>
|
||
/// <returns>A task that completes once every member of the span has been stamped.</returns>
|
||
private async Task ReadSpanAsync(
|
||
IS7Plc plc, S7ReadSpanPlanner.Span span, DataValueSnapshot[] results, DateTime now,
|
||
ReadPassOutcome outcome, CancellationToken ct)
|
||
{
|
||
byte[] block;
|
||
try
|
||
{
|
||
block = await plc.ReadBytesAsync(
|
||
ToS7NetArea(span.Area), span.DbNumber, span.StartByte, span.LengthBytes, ct)
|
||
.ConfigureAwait(false)
|
||
?? throw new System.IO.InvalidDataException(
|
||
$"S7.Net returned null block for {span.Area} db{span.DbNumber} " +
|
||
$"@{span.StartByte}+{span.LengthBytes}");
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
// Same reasoning as the per-tag path: a cancelled block read leaves the single gated
|
||
// stream at an untrustworthy position, so the handle must be reopened (STAB-15a).
|
||
_plcDead = true;
|
||
throw;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
var status = RecordReadFailure(ex, outcome);
|
||
foreach (var member in span.Members)
|
||
results[member.OriginalIndex] = new DataValueSnapshot(null, status, null, now);
|
||
return;
|
||
}
|
||
|
||
foreach (var member in span.Members)
|
||
{
|
||
try
|
||
{
|
||
var value = DecodeMember(member, block);
|
||
results[member.OriginalIndex] = new DataValueSnapshot(value, 0u, now, now);
|
||
outcome.RecordSuccess();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// Decoding is pure and synchronous — no cancellation can surface here, so there is
|
||
// deliberately no OperationCanceledException arm to shadow the block-read one above.
|
||
results[member.OriginalIndex] =
|
||
new DataValueSnapshot(null, RecordReadFailure(ex, outcome), null, now);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Decodes one span member from its slice of the block, routing to exactly the decoder
|
||
/// <see cref="ReadOneAsync"/> would have chosen for that tag, in the same order.
|
||
/// </summary>
|
||
/// <param name="member">The member to decode, carrying its slice position and width.</param>
|
||
/// <param name="block">The block the span read.</param>
|
||
/// <returns>The decoded value, boxed identically to the per-tag path's.</returns>
|
||
private static object DecodeMember(S7ReadSpanPlanner.Member member, byte[] block)
|
||
{
|
||
// The two block decoders take byte[] (they hand the buffer to S7.Net's own
|
||
// S7String/DateTime/Timer/Counter FromByteArray helpers, which are byte[]-only), so a
|
||
// member that is not the whole block is copied out. Narrow scalars need no copy at all —
|
||
// DecodeNarrowScalar reads straight from a ReadOnlySpan positioned at the tag's own byte.
|
||
if (member.Tag.ArrayCount is >= 1)
|
||
return DecodeArrayBlock(member.Tag, member.Addr, SliceForMember(member, block));
|
||
if (IsBufferType(member.Tag, member.Addr))
|
||
return DecodeScalarBlock(member.Tag, member.Addr, SliceForMember(member, block));
|
||
return DecodeNarrowScalar(member.Tag, member.Addr, block.AsSpan(member.RelativeOffset, member.WidthBytes));
|
||
}
|
||
|
||
/// <summary>
|
||
/// The member's own bytes as a <c>byte[]</c> for the block decoders. Returns the block
|
||
/// itself — no copy — when the member IS the whole block, which is the single-member span
|
||
/// every array / wide scalar produces today.
|
||
/// </summary>
|
||
/// <param name="member">The member whose bytes are wanted.</param>
|
||
/// <param name="block">The block the span read.</param>
|
||
/// <returns>The member's bytes, starting at its own first byte.</returns>
|
||
private static byte[] SliceForMember(S7ReadSpanPlanner.Member member, byte[] block)
|
||
=> member.RelativeOffset == 0 && member.WidthBytes == block.Length
|
||
? block
|
||
: block[member.RelativeOffset..(member.RelativeOffset + member.WidthBytes)];
|
||
|
||
/// <summary>
|
||
/// The tag's parsed address: the table built at init, with an on-demand parse as the
|
||
/// defensive fallback for a tag that isn't in it. Mirrors <see cref="ReadOneAsync"/>'s
|
||
/// lookup, but never throws — an unparseable address returns <c>false</c> so the caller can
|
||
/// route the tag to the per-tag path, where the <see cref="FormatException"/> surfaces
|
||
/// through the ladder exactly as it always has.
|
||
/// </summary>
|
||
/// <param name="tag">The tag whose address is wanted.</param>
|
||
/// <param name="addr">The parsed address, valid only when this returns <c>true</c>.</param>
|
||
/// <returns><c>true</c> when the address is known or parses.</returns>
|
||
private bool TryGetParsedAddress(S7TagDefinition tag, out S7ParsedAddress addr)
|
||
=> _parsedByRawPath.TryGetValue(tag.Name, out addr) || S7AddressParser.TryParse(tag.Address, out addr);
|
||
|
||
/// <summary>
|
||
/// The read-failure classification ladder, shared by the per-tag path and the span path so
|
||
/// a failed span stamps every member with exactly the status the same exception would have
|
||
/// produced on a single tag. Records the failure into the pass's outcome, applies the
|
||
/// dead-handle side effect, and returns the status code the caller must stamp.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>The health state is recorded, not applied.</b> It goes into
|
||
/// <paramref name="outcome"/> so the pass can write <see cref="Health"/> once at the
|
||
/// end, worst-state-wins — writing it here is what let a later good tag erase this
|
||
/// failure. The <c>_plcDead</c> / <see cref="MarkConnectionDeadIfFatal"/> side effects
|
||
/// stay IMMEDIATE: they govern whether the very next read reopens the handle, which
|
||
/// cannot wait for the pass to finish.
|
||
/// </para>
|
||
/// <para>
|
||
/// <see cref="OperationCanceledException"/> is deliberately NOT handled here: it is
|
||
/// control flow rather than a per-tag status (it marks the handle dead and propagates),
|
||
/// and rethrowing it from a helper would rewrite the stack. Both call sites catch it
|
||
/// first.
|
||
/// </para>
|
||
/// </remarks>
|
||
/// <param name="ex">The exception observed on a read.</param>
|
||
/// <param name="outcome">The pass's outcome accumulator this failure is recorded into.</param>
|
||
/// <returns>The OPC UA status code every affected tag must be stamped with.</returns>
|
||
private uint RecordReadFailure(Exception ex, ReadPassOutcome outcome)
|
||
{
|
||
switch (ex)
|
||
{
|
||
case NotSupportedException:
|
||
return StatusBadNotSupported;
|
||
|
||
case S7ValueRangeException:
|
||
// A PLC buffer the type codec refuses (uninitialised DTL, non-BCD S5TIME…) is a data
|
||
// problem, not a transport fault — no health change, no connection teardown.
|
||
return StatusBadOutOfRange;
|
||
|
||
case PlcException pex when IsAccessDenied(pex):
|
||
// PUT/GET-disabled (S7-1200/1500) / access-protection — a permanent configuration
|
||
// fault, NOT a transient one. Blind retry is wasted effort, so map it to
|
||
// BadNotSupported and flag the driver as a config alert (Faulted) rather than
|
||
// Degraded — per driver-specs.md §5.
|
||
outcome.RecordFailure(DriverState.Faulted,
|
||
"S7 access denied — enable PUT/GET communication in TIA Portal " +
|
||
$"(Protection & Security) for this CPU. PLC reported: {pex.Message}");
|
||
return StatusBadNotSupported;
|
||
|
||
case PlcException pex:
|
||
// A genuine device-layer fault (CPU error, hardware fault) — transient enough to
|
||
// keep retrying; report BadDeviceFailure and degrade health. A connection-fatal
|
||
// PlcException (socket drop / ConnectionError) additionally marks the handle dead
|
||
// so the next read reopens it.
|
||
MarkConnectionDeadIfFatal(pex);
|
||
outcome.RecordFailure(DriverState.Degraded, pex.Message);
|
||
return StatusBadDeviceFailure;
|
||
|
||
default:
|
||
// Socket / timeout / transport fault — mark the handle dead when it's a connection
|
||
// loss so EnsureConnectedAsync reopens on the next call.
|
||
MarkConnectionDeadIfFatal(ex);
|
||
outcome.RecordFailure(DriverState.Degraded, ex.Message);
|
||
return StatusBadCommunicationError;
|
||
}
|
||
}
|
||
|
||
private async Task<object> ReadOneAsync(IS7Plc plc, S7TagDefinition tag, CancellationToken ct)
|
||
{
|
||
// Every tag pre-parses its address at init (_parsedByRawPath); the on-demand
|
||
// S7AddressParser.Parse is a defensive fallback for a tag not in that table. Parse throws
|
||
// FormatException on a bad address, which the caller's catch maps to BadCommunicationError.
|
||
var addr = _parsedByRawPath.TryGetValue(tag.Name, out var parsed)
|
||
? parsed
|
||
: S7AddressParser.Parse(tag.Address);
|
||
|
||
// Array path: a tag with a declared count >= 1 reads a CONTIGUOUS block of
|
||
// count × element-bytes in a SINGLE round-trip (Plc.ReadBytesAsync), then decodes each
|
||
// element from its big-endian slice into an element-typed CLR array. The scalar path
|
||
// (count null) is left byte-for-byte unchanged below. A count of 1 IS a valid 1-element
|
||
// array (the foundation materialises a [1] OPC UA array node when isArray:true).
|
||
if (tag.ArrayCount is >= 1)
|
||
return await ReadArrayAsync(plc, tag, addr, ct).ConfigureAwait(false);
|
||
|
||
// Wide/structured scalar path (Int64/UInt64/Float64/String/DateTime) AND the Timer/Counter
|
||
// areas: S7.Net's string ReadAsync only decodes 1/2/4-byte size suffixes, so neither family
|
||
// can go through the narrow path below. Read a contiguous byte block and decode it —
|
||
// mirrors the array path's buffer read. Timer/Counter route here on AREA (IsBufferType),
|
||
// so a Timer/Float64 tag reads its 2-byte S5TIME word here rather than falling through to
|
||
// the dead Float64 stub (which would surface BadNotSupported on every read).
|
||
if (IsBufferType(tag, addr))
|
||
return await ReadScalarBlockAsync(plc, tag, addr, ct).ConfigureAwait(false);
|
||
|
||
// S7.Net's string-based ReadAsync returns object where the boxed .NET type depends on
|
||
// the size suffix: DBX=bool, DBB=byte, DBW=ushort, DBD=uint. Our S7DataType enum
|
||
// specifies the SEMANTIC type (Int16 vs UInt16 vs Float32 etc.); the reinterpret below
|
||
// converts the raw unsigned boxed value into the requested type without issuing an
|
||
// extra PLC round-trip.
|
||
var raw = await plc.ReadAsync(tag.Address, ct).ConfigureAwait(false)
|
||
?? throw new System.IO.InvalidDataException($"S7.Net returned null for '{tag.Address}'");
|
||
|
||
return ReinterpretRawValue(tag, addr, raw);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Reads a 1-D array tag as ONE contiguous block (<c>count × element-bytes</c>) via
|
||
/// S7.Net's buffer-based <c>Plc.ReadBytesAsync(DataType, db, startByteAdr, count, ct)</c>
|
||
/// — a single PLC round-trip, NOT <c>N</c> string reads — then hands the raw byte block
|
||
/// to the pure <see cref="DecodeArrayBlock"/> decode loop. Timer/Counter areas are
|
||
/// already rejected at init, so only DB/M/I/Q reach here.
|
||
/// </summary>
|
||
private async Task<object> ReadArrayAsync(IS7Plc plc, S7TagDefinition tag, S7ParsedAddress addr, CancellationToken ct)
|
||
{
|
||
var count = tag.ArrayCount!.Value;
|
||
var elementBytes = ElementByteSize(addr.Size);
|
||
var totalBytes = count * elementBytes;
|
||
|
||
// ReadBytesAsync addresses by (area, db, startByteOffset, byteCount). The parser already
|
||
// normalised the start to a BYTE offset (ByteOffset) for DB/M/I/Q; a Bit array starts at
|
||
// its byte and consumes one byte per element (byte-granular contiguous bit access). S7.Net
|
||
// transparently splits a > PDU-sized block into multiple wire requests, so the driver
|
||
// doesn't have to chunk.
|
||
var area = ToS7NetArea(addr.Area);
|
||
var block = await plc.ReadBytesAsync(area, addr.DbNumber, addr.ByteOffset, totalBytes, ct)
|
||
.ConfigureAwait(false)
|
||
?? throw new System.IO.InvalidDataException($"S7.Net returned null block for '{tag.Address}'");
|
||
|
||
return DecodeArrayBlock(tag, addr, block);
|
||
}
|
||
|
||
/// <summary>
|
||
/// True when this tag must be read/written through the byte-buffer codec rather than
|
||
/// S7.Net's string path. Two families route here:
|
||
/// <list type="bullet">
|
||
/// <item>the wide/structured types (Int64/UInt64/Float64/String/DateTime), which are
|
||
/// byte-anchored (<c>DBB</c>/<c>MB</c>/<c>IB</c>/<c>QB</c>) and decoded from a
|
||
/// contiguous block — S7.Net's string ReadAsync only understands 1/2/4-byte size
|
||
/// suffixes, so they can't use the narrow path;</item>
|
||
/// <item>the Timer/Counter AREAS — a Timer (<c>T{n}</c>) decodes a 2-byte S5TIME word
|
||
/// to a duration in seconds (<c>double</c>) and a Counter (<c>C{n}</c>) decodes a
|
||
/// 2-byte word to a count (<c>int</c>); both are read-only this phase. Routing them
|
||
/// here (rather than through the narrow path, which would hit the dead Float64 stub and
|
||
/// throw <c>BadNotSupported</c> on every read) closes the interim correctness hole.</item>
|
||
/// </list>
|
||
/// The init guard already enforces byte-addressing + the Timer→Float64 / Counter→Int32
|
||
/// type constraints, so by the time a tag reaches here it is well-formed.
|
||
/// </summary>
|
||
/// <param name="tag">Tag definition carrying the <see cref="S7DataType"/>.</param>
|
||
/// <param name="addr">Parsed address — its <see cref="S7Area"/> routes Timer/Counter into the seam regardless of <see cref="S7DataType"/>.</param>
|
||
/// <returns><c>true</c> if the tag routes through the byte-buffer codec.</returns>
|
||
internal static bool IsBufferType(S7TagDefinition tag, S7ParsedAddress addr) =>
|
||
addr.Area is S7Area.Timer or S7Area.Counter
|
||
|| ByteAnchoredDataTypes.Contains(tag.DataType);
|
||
|
||
/// <summary>
|
||
/// Reads a wide/structured scalar as ONE contiguous byte block via S7.Net's
|
||
/// buffer-based <c>Plc.ReadBytesAsync(DataType, db, startByteAdr, count, ct)</c> (a single
|
||
/// PLC round-trip), then hands the raw block to the pure <see cref="DecodeScalarBlock"/>
|
||
/// decode. Mirrors <see cref="ReadArrayAsync"/>'s shape.
|
||
/// </summary>
|
||
private async Task<object> ReadScalarBlockAsync(IS7Plc plc, S7TagDefinition tag, S7ParsedAddress addr, CancellationToken ct)
|
||
{
|
||
var width = ScalarByteWidth(tag, addr);
|
||
// For DB/M/I/Q, addr.ByteOffset is the start BYTE and `width` the byte count. For
|
||
// Timer/Counter, addr.ByteOffset is the timer/counter NUMBER (db stays 0) and width is 2:
|
||
// S7.Net's Timer/Counter transport size makes the request "count" word a per-2-byte unit,
|
||
// so passing 2 reads exactly one timer/counter's 2-byte block (matching S7.Net's own
|
||
// VarType.Timer/Counter path, which sets byteLength = varCount × 2). See ScalarByteWidth.
|
||
var block = await plc.ReadBytesAsync(ToS7NetArea(addr.Area), addr.DbNumber, addr.ByteOffset, width, ct)
|
||
.ConfigureAwait(false)
|
||
?? throw new System.IO.InvalidDataException($"S7.Net returned null block for '{tag.Address}'");
|
||
|
||
return DecodeScalarBlock(tag, addr, block);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Byte width of one wide/structured scalar (or one Timer/Counter word).
|
||
/// <b>The parsed AREA takes precedence over the tag's <see cref="S7DataType"/></b>: a
|
||
/// Timer/Counter address is always 2 bytes (one S5TIME / counter word), even though a
|
||
/// Timer tag is typed <see cref="S7DataType.Float64"/> (which would otherwise yield 8) and
|
||
/// a Counter tag <see cref="S7DataType.Int32"/>. For DB/M/I/Q areas the width follows the
|
||
/// DataType: Int64/UInt64/Float64/DateTime are 8 bytes; an S7 STRING occupies
|
||
/// <c>StringLength + 2</c> bytes (the two-byte header carries the declared max length and
|
||
/// the current actual length). Throws for any non-buffer type — defensive;
|
||
/// <see cref="IsBufferType"/> gates this so a non-buffer type never reaches here.
|
||
/// </summary>
|
||
/// <param name="tag">Tag definition carrying the <see cref="S7DataType"/> and (for strings) <c>StringLength</c>.</param>
|
||
/// <param name="addr">Parsed address — Timer/Counter areas force a 2-byte width regardless of <see cref="S7DataType"/>.</param>
|
||
/// <returns>The byte width to read for this scalar.</returns>
|
||
internal static int ScalarByteWidth(S7TagDefinition tag, S7ParsedAddress addr)
|
||
{
|
||
// Area precedence: Timer (S5TIME) and Counter are a single 16-bit word on the wire. The
|
||
// Timer tag's Float64 DataType describes the DECODED value (seconds), not the read width,
|
||
// so it must NOT drive an 8-byte read — that would mis-frame the timer word.
|
||
if (addr.Area is S7Area.Timer or S7Area.Counter)
|
||
return 2;
|
||
|
||
return tag.DataType switch
|
||
{
|
||
S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64 => 8,
|
||
S7DataType.DateTime => 8, // 8 = S7 DATE_AND_TIME/DT BCD (S7.Net.Types.DateTime; the 8-byte DT, not 12-byte DTL).
|
||
S7DataType.String => tag.StringLength + 2,
|
||
S7DataType.WString => S7ValueCodecs.WStringWidth(tag.StringLength),
|
||
S7DataType.Char => 1,
|
||
S7DataType.WChar or S7DataType.Date or S7DataType.S5Time => 2,
|
||
S7DataType.Tod or S7DataType.Time => 4,
|
||
S7DataType.Dtl => 12,
|
||
_ => throw new InvalidOperationException(
|
||
$"S7 ScalarByteWidth called for non-buffer type {tag.DataType} (tag '{tag.Name}')"),
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Pure decode step — turns a raw S7 (big-endian) byte block into the wide scalar's CLR
|
||
/// value (<c>long</c> / <c>ulong</c> / <c>double</c>), boxed as <see cref="object"/>. No
|
||
/// network I/O — factored out of <see cref="ReadScalarBlockAsync"/> so the codec is
|
||
/// unit-testable against a known block without a live PLC (S7.Net ships no in-process
|
||
/// fake). Mirrors <see cref="DecodeArrayBlock"/>.
|
||
/// </summary>
|
||
/// <param name="tag">Tag definition carrying the wide <see cref="S7DataType"/>.</param>
|
||
/// <param name="addr">Parsed address (carried for the error surface + the Timer/Counter seam).</param>
|
||
/// <param name="block">Raw contiguous byte block read from the PLC (length == <see cref="ScalarByteWidth"/>).</param>
|
||
/// <returns>The decoded scalar value boxed as <see cref="object"/>.</returns>
|
||
internal static object DecodeScalarBlock(S7TagDefinition tag, S7ParsedAddress addr, byte[] block)
|
||
{
|
||
// AREA precedence first (before the DataType switch): a Timer (T{n}) decodes its 2-byte
|
||
// S5TIME word to a duration in SECONDS via S7.Net's Timer.FromByteArray (returns double);
|
||
// a Counter (C{n}) decodes its 2-byte word to a COUNT via Counter.FromByteArray (returns
|
||
// ushort, surfaced as a non-negative int to match the tag's Int32 type). Routing on Area
|
||
// here is essential: a Timer tag is typed Float64, so letting it fall to the Float64 arm
|
||
// below would ReadDoubleBigEndian over a 2-byte block = garbage/throw. Boxed explicitly so
|
||
// the box type is exactly double / int (not the switch-unified common type).
|
||
if (addr.Area is S7Area.Timer)
|
||
return (object)global::S7.Net.Types.Timer.FromByteArray(block);
|
||
if (addr.Area is S7Area.Counter)
|
||
{
|
||
// NOTE: S7.Net Counter.FromByteArray returns the RAW big-endian word ((bytes[0]<<8)|bytes[1]),
|
||
// NOT a BCD decode. On classic S7-300/400 the C-area word is BCD (0-999), so on that hardware
|
||
// this raw value can differ from the displayed count; S7-1200/1500 use IEC/DB counters (plain
|
||
// ints), where it is correct. Surfacing S7.Net's value verbatim is the faithful choice; BCD
|
||
// reinterpretation for legacy C-area counters is a live-hardware-gated follow-up.
|
||
return (object)(int)global::S7.Net.Types.Counter.FromByteArray(block);
|
||
}
|
||
|
||
// Each numeric arm is boxed to object explicitly: a bare switch expression would unify
|
||
// long/ulong/double to their common type (double) and box THAT, mis-typing Int64/UInt64.
|
||
return tag.DataType switch
|
||
{
|
||
S7DataType.Int64 => (object)System.Buffers.Binary.BinaryPrimitives.ReadInt64BigEndian(block),
|
||
S7DataType.UInt64 => (object)System.Buffers.Binary.BinaryPrimitives.ReadUInt64BigEndian(block),
|
||
S7DataType.Float64 => System.Buffers.Binary.BinaryPrimitives.ReadDoubleBigEndian(block),
|
||
|
||
// S7 classic STRING: [maxLen byte][curLen byte][chars…]. S7.Net's S7String.FromByteArray
|
||
// reads the two-byte header and returns exactly curLen ASCII chars, ignoring the reserved
|
||
// padding past curLen — it validates curLen against the block, so no extra guard is needed.
|
||
S7DataType.String => global::S7.Net.Types.S7String.FromByteArray(block),
|
||
|
||
// S7 classic DATE_AND_TIME (DT): 8-byte BCD value (year/month/day/hour/min/sec + ms +
|
||
// day-of-week nibble). S7.Net's DateTime.FromByteArray reads the BCD fields and returns a
|
||
// System.DateTime; the DT range is 1990–2089 and round-trips to the millisecond. Boxed as
|
||
// System.DateTime explicitly (the cast pins the box type, consistent with the numeric arms).
|
||
S7DataType.DateTime => (object)global::S7.Net.Types.DateTime.FromByteArray(block),
|
||
|
||
// The codecs below live in S7ValueCodecs (pure, big-endian); a malformed PLC buffer throws
|
||
// S7ValueRangeException → BadOutOfRange on the read. TOD/TIME/S5TIME surface as Int32 ms.
|
||
S7DataType.WString => S7ValueCodecs.DecodeWString(block),
|
||
S7DataType.Char => S7ValueCodecs.DecodeChar(block),
|
||
S7DataType.WChar => S7ValueCodecs.DecodeWChar(block),
|
||
S7DataType.Dtl => (object)S7ValueCodecs.DecodeDtl(block),
|
||
S7DataType.Date => (object)S7ValueCodecs.DecodeDate(block),
|
||
S7DataType.Tod => (object)S7ValueCodecs.DecodeTod(block),
|
||
S7DataType.Time => (object)S7ValueCodecs.DecodeTime(block),
|
||
S7DataType.S5Time => (object)S7ValueCodecs.DecodeS5Time(block),
|
||
|
||
_ => throw new System.IO.InvalidDataException(
|
||
$"S7 scalar Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address " +
|
||
$"'{tag.Address}' parsed as Size={addr.Size}"),
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Pure encode step — turns the caller's value into the raw S7 (big-endian) byte block
|
||
/// for a wide scalar write. No network I/O — factored out of
|
||
/// <see cref="WriteScalarBlockAsync"/> so the codec is unit-testable (mirrors
|
||
/// <see cref="BoxValueForWrite"/>).
|
||
/// </summary>
|
||
/// <param name="tag">Tag definition carrying the wide <see cref="S7DataType"/>.</param>
|
||
/// <param name="value">Value to encode (coerced via <c>Convert.To*</c>).</param>
|
||
/// <returns>The big-endian byte block to write.</returns>
|
||
internal static byte[] EncodeScalarBlock(S7TagDefinition tag, object? value)
|
||
{
|
||
// Timer/Counter are READ-ONLY this phase. A Timer tag is typed Float64 and a Counter tag
|
||
// Int32, so the DataType switch below would otherwise hand them a (wrong, 8/wide-byte)
|
||
// encode arm — guard on the parsed AREA first and refuse the write. The address parses to
|
||
// an S7Area without I/O; TryParse keeps a malformed address from masking the read-only
|
||
// contract (a bad address falls through to the DataType arms and is caught upstream).
|
||
if (S7AddressParser.TryParse(tag.Address, out var addr)
|
||
&& addr.Area is S7Area.Timer or S7Area.Counter)
|
||
{
|
||
throw new NotSupportedException(
|
||
$"S7 Timer/Counter writes are read-only this phase (tag '{tag.Name}' at '{tag.Address}').");
|
||
}
|
||
|
||
switch (tag.DataType)
|
||
{
|
||
case S7DataType.Int64:
|
||
{
|
||
var b = new byte[8];
|
||
System.Buffers.Binary.BinaryPrimitives.WriteInt64BigEndian(b, Convert.ToInt64(value));
|
||
return b;
|
||
}
|
||
case S7DataType.UInt64:
|
||
{
|
||
var b = new byte[8];
|
||
System.Buffers.Binary.BinaryPrimitives.WriteUInt64BigEndian(b, Convert.ToUInt64(value));
|
||
return b;
|
||
}
|
||
case S7DataType.Float64:
|
||
{
|
||
var b = new byte[8];
|
||
System.Buffers.Binary.BinaryPrimitives.WriteDoubleBigEndian(b, Convert.ToDouble(value));
|
||
return b;
|
||
}
|
||
|
||
case S7DataType.String:
|
||
// S7.Net's S7String.ToByteArray builds [maxLen=StringLength][curLen][chars…] and pads
|
||
// the result to the full reserved field (StringLength + 2 bytes) — exactly the width
|
||
// ReadScalarBlockAsync read, so WriteBytesAsync writes the whole reserved STRING. A value
|
||
// longer than StringLength throws ArgumentException (S7.Net rejects overflow; we do NOT
|
||
// silently truncate). A null value encodes as the empty string.
|
||
return global::S7.Net.Types.S7String.ToByteArray(Convert.ToString(value) ?? "", tag.StringLength);
|
||
|
||
case S7DataType.DateTime:
|
||
// S7.Net's DateTime.ToByteArray builds the 8-byte DT BCD block. It validates the
|
||
// 1990–2089 DT range and throws ArgumentOutOfRangeException for an out-of-range year —
|
||
// we surface that (no silent clamp). Value coerced via Convert.ToDateTime (accepts a
|
||
// System.DateTime or a parseable timestamp string).
|
||
return global::S7.Net.Types.DateTime.ToByteArray(Convert.ToDateTime(value));
|
||
|
||
// Writes refuse (S7ValueRangeException → BadOutOfRange) rather than truncate or clamp.
|
||
case S7DataType.WString: return S7ValueCodecs.EncodeWString(value, tag.StringLength);
|
||
case S7DataType.Char: return S7ValueCodecs.EncodeChar(value);
|
||
case S7DataType.WChar: return S7ValueCodecs.EncodeWChar(value);
|
||
case S7DataType.Dtl: return S7ValueCodecs.EncodeDtl(value);
|
||
case S7DataType.Date: return S7ValueCodecs.EncodeDate(value);
|
||
case S7DataType.Tod: return S7ValueCodecs.EncodeTod(value);
|
||
case S7DataType.Time: return S7ValueCodecs.EncodeTime(value);
|
||
case S7DataType.S5Time: return S7ValueCodecs.EncodeS5Time(value);
|
||
|
||
default:
|
||
throw new InvalidOperationException(
|
||
$"S7 EncodeScalarBlock called for non-buffer type {tag.DataType} (tag '{tag.Name}')");
|
||
}
|
||
}
|
||
|
||
/// <summary>Width in bytes of one array element for the given access size. Bit elements are
|
||
/// byte-granular over the wire (one byte per bool), so they cost 1 byte each.</summary>
|
||
/// <param name="size">The parsed access width.</param>
|
||
/// <returns>Element byte size: Bit/Byte = 1, Word = 2, DWord = 4.</returns>
|
||
internal static int ElementByteSize(S7Size size) => size switch
|
||
{
|
||
S7Size.Bit => 1,
|
||
S7Size.Byte => 1,
|
||
S7Size.Word => 2,
|
||
S7Size.DWord => 4,
|
||
_ => throw new InvalidOperationException($"Unknown S7Size {size}"),
|
||
};
|
||
|
||
/// <summary>
|
||
/// Maps the driver's <see cref="S7Area"/> to S7.Net's <c>DataType</c> for the
|
||
/// buffer-based block read. Timer/Counter map to S7.Net's <c>Timer</c>/<c>Counter</c>
|
||
/// transport types — the <c>ScalarByteWidth</c> 2-byte read uses these to read one
|
||
/// timer/counter word (the array path never reaches Timer/Counter: wide-type arrays are
|
||
/// rejected at init and Timer/Counter are always scalar).
|
||
/// </summary>
|
||
private static S7NetDataType ToS7NetArea(S7Area area) => area switch
|
||
{
|
||
S7Area.DataBlock => S7NetDataType.DataBlock,
|
||
S7Area.Memory => S7NetDataType.Memory,
|
||
S7Area.Input => S7NetDataType.Input,
|
||
S7Area.Output => S7NetDataType.Output,
|
||
S7Area.Timer => S7NetDataType.Timer,
|
||
S7Area.Counter => S7NetDataType.Counter,
|
||
_ => throw new NotSupportedException($"S7 area {area} has no S7.Net DataType mapping"),
|
||
};
|
||
|
||
/// <summary>
|
||
/// Pure decode loop — turns a raw S7 (big-endian) byte block into an element-typed CLR
|
||
/// array (<c>short[]</c> / <c>ushort[]</c> / <c>int[]</c> / <c>uint[]</c> / <c>float[]</c>
|
||
/// / <c>byte[]</c> / <c>bool[]</c>), boxed as <see cref="object"/>. No network I/O —
|
||
/// factored out of <see cref="ReadArrayAsync"/> so the block-decode is unit-testable
|
||
/// against a known byte block without a live PLC (S7.Net ships no in-process fake).
|
||
/// Each element is read from its <c>i × element-bytes</c> slice using S7 big-endian byte
|
||
/// order, identical to the per-element semantics of <see cref="ReinterpretRawValue"/>.
|
||
/// </summary>
|
||
/// <param name="tag">Tag definition carrying the element <see cref="S7DataType"/> and array count.</param>
|
||
/// <param name="addr">Parsed address carrying the access <see cref="S7Size"/>.</param>
|
||
/// <param name="block">Raw contiguous byte block read from the PLC (length == count × element-bytes).</param>
|
||
/// <returns>An element-typed CLR array boxed as <see cref="object"/>.</returns>
|
||
internal static object DecodeArrayBlock(S7TagDefinition tag, S7ParsedAddress addr, byte[] block)
|
||
{
|
||
var count = tag.ArrayCount!.Value;
|
||
var elementBytes = ElementByteSize(addr.Size);
|
||
|
||
switch (tag.DataType, addr.Size)
|
||
{
|
||
case (S7DataType.Bool, S7Size.Bit):
|
||
{
|
||
var a = new bool[count];
|
||
for (var i = 0; i < count; i++)
|
||
a[i] = (block[i] & 0x01) != 0;
|
||
return a;
|
||
}
|
||
case (S7DataType.Byte, S7Size.Byte):
|
||
{
|
||
var a = new byte[count];
|
||
for (var i = 0; i < count; i++)
|
||
a[i] = block[i];
|
||
return a;
|
||
}
|
||
case (S7DataType.UInt16, S7Size.Word):
|
||
{
|
||
var a = new ushort[count];
|
||
for (var i = 0; i < count; i++)
|
||
a[i] = ReadBeUInt16(block, i * elementBytes);
|
||
return a;
|
||
}
|
||
case (S7DataType.Int16, S7Size.Word):
|
||
{
|
||
var a = new short[count];
|
||
for (var i = 0; i < count; i++)
|
||
a[i] = unchecked((short)ReadBeUInt16(block, i * elementBytes));
|
||
return a;
|
||
}
|
||
case (S7DataType.UInt32, S7Size.DWord):
|
||
{
|
||
var a = new uint[count];
|
||
for (var i = 0; i < count; i++)
|
||
a[i] = ReadBeUInt32(block, i * elementBytes);
|
||
return a;
|
||
}
|
||
case (S7DataType.Int32, S7Size.DWord):
|
||
{
|
||
var a = new int[count];
|
||
for (var i = 0; i < count; i++)
|
||
a[i] = unchecked((int)ReadBeUInt32(block, i * elementBytes));
|
||
return a;
|
||
}
|
||
case (S7DataType.Float32, S7Size.DWord):
|
||
{
|
||
var a = new float[count];
|
||
for (var i = 0; i < count; i++)
|
||
a[i] = BitConverter.UInt32BitsToSingle(ReadBeUInt32(block, i * elementBytes));
|
||
return a;
|
||
}
|
||
|
||
case (S7DataType.Int64, _):
|
||
case (S7DataType.UInt64, _):
|
||
case (S7DataType.Float64, _):
|
||
case (S7DataType.String, _):
|
||
case (S7DataType.DateTime, _):
|
||
case (S7DataType.WString, _):
|
||
case (S7DataType.Char, _):
|
||
case (S7DataType.WChar, _):
|
||
case (S7DataType.Dtl, _):
|
||
case (S7DataType.Date, _):
|
||
case (S7DataType.Tod, _):
|
||
case (S7DataType.Time, _):
|
||
case (S7DataType.S5Time, _):
|
||
throw new NotSupportedException(
|
||
$"S7 array reads of {tag.DataType} land in a follow-up PR");
|
||
|
||
default:
|
||
throw new System.IO.InvalidDataException(
|
||
$"S7 array Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address " +
|
||
$"'{tag.Address}' parsed as Size={addr.Size}");
|
||
}
|
||
}
|
||
|
||
/// <summary>Reads a big-endian 16-bit word from <paramref name="block"/> at <paramref name="offset"/>.</summary>
|
||
private static ushort ReadBeUInt16(byte[] block, int offset) =>
|
||
(ushort)((block[offset] << 8) | block[offset + 1]);
|
||
|
||
/// <summary>Reads a big-endian 32-bit dword from <paramref name="block"/> at <paramref name="offset"/>.</summary>
|
||
private static uint ReadBeUInt32(byte[] block, int offset) =>
|
||
((uint)block[offset] << 24) | ((uint)block[offset + 1] << 16)
|
||
| ((uint)block[offset + 2] << 8) | block[offset + 3];
|
||
|
||
/// <summary>
|
||
/// Byte width one narrow scalar (Bit/Byte/Word/DWord) occupies inside a contiguous block —
|
||
/// the stride a span planner uses to size and slice a group read. Delegates to
|
||
/// <see cref="ElementByteSize"/> so the scalar and array paths can never disagree about a
|
||
/// size's width (a Bit costs a whole byte: S7 block reads are byte-granular).
|
||
/// </summary>
|
||
/// <param name="addr">Parsed address carrying the access <see cref="S7Size"/>.</param>
|
||
/// <returns>Byte width: Bit/Byte = 1, Word = 2, DWord = 4.</returns>
|
||
internal static int NarrowScalarWidth(S7ParsedAddress addr) => ElementByteSize(addr.Size);
|
||
|
||
/// <summary>
|
||
/// Pure decode step — turns the tag's own slice of a contiguous big-endian block into the
|
||
/// SEMANTIC CLR value declared by its <see cref="S7DataType"/>, boxed as
|
||
/// <see cref="object"/>. No network I/O and no driver state: this is the span-read twin of
|
||
/// <see cref="ReinterpretRawValue"/> (which reinterprets the boxed value S7.Net's
|
||
/// per-tag string <c>ReadAsync</c> returns), and it deliberately mirrors that method's
|
||
/// sign/width/conversion semantics arm for arm so a tag reads identically whether it was
|
||
/// served by one wire request of its own or by one block read shared with its neighbours.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <paramref name="slice"/> is positioned at the tag's OWN first byte —
|
||
/// <c>slice[0]</c> is the tag's first byte, so <see cref="S7ParsedAddress.ByteOffset"/> is
|
||
/// the caller's business and is never re-applied here. Only the leading
|
||
/// <see cref="NarrowScalarWidth"/> bytes are consumed; a longer slice (the rest of the
|
||
/// block) is fine. Wide/structured types (Int64/UInt64/Float64/String/DateTime) and the
|
||
/// Timer/Counter areas are NOT narrow — they route through
|
||
/// <see cref="DecodeScalarBlock"/> — and fall into the mismatch arm here so a mis-routed
|
||
/// tag fails loudly instead of decoding garbage from a truncated slice.
|
||
/// </remarks>
|
||
/// <param name="tag">Tag definition carrying the narrow <see cref="S7DataType"/>.</param>
|
||
/// <param name="addr">Parsed address carrying the access <see cref="S7Size"/> and (for Bool) the bit position.</param>
|
||
/// <param name="slice">The tag's slice of the block, big-endian, at least <see cref="NarrowScalarWidth"/> bytes long.</param>
|
||
/// <returns>The decoded value boxed as <see cref="object"/>, with the same box type <see cref="ReinterpretRawValue"/> produces.</returns>
|
||
internal static object DecodeNarrowScalar(S7TagDefinition tag, S7ParsedAddress addr, ReadOnlySpan<byte> slice)
|
||
{
|
||
// Each arm boxes explicitly: a bare switch expression would unify bool/byte/ushort/short/
|
||
// uint/int/float to a common type and box THAT, mis-typing every narrow tag (the same trap
|
||
// DecodeScalarBlock documents). The box type is part of the contract — the publish pipeline
|
||
// and the OPC UA variable's DataType both key off it.
|
||
switch (tag.DataType, addr.Size)
|
||
{
|
||
// DBX / M{n}.{b}: S7.Net's string read returns the already-extracted bool. From a block
|
||
// we extract it ourselves at the parsed bit position (0-7, guaranteed by the parser).
|
||
case (S7DataType.Bool, S7Size.Bit):
|
||
return (object)((slice[0] & (1 << addr.BitOffset)) != 0);
|
||
|
||
// DBB: the whole byte, unsigned (S7.Net boxes a byte).
|
||
case (S7DataType.Byte, S7Size.Byte):
|
||
return (object)slice[0];
|
||
|
||
// DBW: one big-endian word. UInt16 is the raw word; Int16 is the SAME bits reinterpreted
|
||
// unchecked (0x8000 => -32768), exactly as ReinterpretRawValue casts S7.Net's ushort.
|
||
case (S7DataType.UInt16, S7Size.Word):
|
||
return (object)System.Buffers.Binary.BinaryPrimitives.ReadUInt16BigEndian(slice);
|
||
case (S7DataType.Int16, S7Size.Word):
|
||
return (object)unchecked((short)System.Buffers.Binary.BinaryPrimitives.ReadUInt16BigEndian(slice));
|
||
|
||
// DBD: one big-endian dword. UInt32 raw; Int32 the same bits unchecked; Float32 the same
|
||
// bits reinterpreted as IEEE-754 single (never a numeric conversion).
|
||
case (S7DataType.UInt32, S7Size.DWord):
|
||
return (object)System.Buffers.Binary.BinaryPrimitives.ReadUInt32BigEndian(slice);
|
||
case (S7DataType.Int32, S7Size.DWord):
|
||
return (object)unchecked((int)System.Buffers.Binary.BinaryPrimitives.ReadUInt32BigEndian(slice));
|
||
case (S7DataType.Float32, S7Size.DWord):
|
||
return (object)BitConverter.UInt32BitsToSingle(
|
||
System.Buffers.Binary.BinaryPrimitives.ReadUInt32BigEndian(slice));
|
||
|
||
default:
|
||
throw new System.IO.InvalidDataException(
|
||
$"S7 narrow-span Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address " +
|
||
$"'{tag.Address}' parsed as Size={addr.Size}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Pure reinterpret step — converts the boxed value that S7.Net returns (always an
|
||
/// unsigned type: <c>bool</c>, <c>byte</c>, <c>ushort</c>, <c>uint</c>) into the
|
||
/// SEMANTIC type declared by the tag's <see cref="S7DataType"/>. No network I/O.
|
||
/// Factored out of <see cref="ReadOneAsync"/> so it can be exercised in unit tests
|
||
/// without a live PLC.
|
||
/// </summary>
|
||
/// <param name="tag">Tag definition containing type information.</param>
|
||
/// <param name="addr">Parsed tag address.</param>
|
||
/// <param name="raw">Raw value from S7.Net.</param>
|
||
/// <returns>The reinterpreted value in the target semantic type.</returns>
|
||
internal static object ReinterpretRawValue(S7TagDefinition tag, S7ParsedAddress addr, object raw) =>
|
||
(tag.DataType, addr.Size, raw) switch
|
||
{
|
||
(S7DataType.Bool, S7Size.Bit, bool b) => b,
|
||
(S7DataType.Byte, S7Size.Byte, byte by) => by,
|
||
(S7DataType.UInt16, S7Size.Word, ushort u16) => u16,
|
||
(S7DataType.Int16, S7Size.Word, ushort u16) => unchecked((short)u16),
|
||
(S7DataType.UInt32, S7Size.DWord, uint u32) => u32,
|
||
(S7DataType.Int32, S7Size.DWord, uint u32) => unchecked((int)u32),
|
||
(S7DataType.Float32, S7Size.DWord, uint u32) => BitConverter.UInt32BitsToSingle(u32),
|
||
|
||
// Unreachable defensive backstop — every wide type (Int64/UInt64/Float64/String/
|
||
// DateTime) AND every Timer/Counter tag routes through the byte-buffer path
|
||
// (IsBufferType → ReadScalarBlockAsync → DecodeScalarBlock) before ReinterpretRawValue
|
||
// is ever consulted. Kept (not deleted) so a future regression that mis-routes a wide
|
||
// type to the narrow path fails loudly here instead of silently mis-decoding.
|
||
(S7DataType.Int64, _, _) => throw new NotSupportedException("S7 Int64 is unreachable here — wide types route through the byte-buffer path (DecodeScalarBlock)"),
|
||
(S7DataType.UInt64, _, _) => throw new NotSupportedException("S7 UInt64 is unreachable here — wide types route through the byte-buffer path (DecodeScalarBlock)"),
|
||
(S7DataType.Float64, _, _) => throw new NotSupportedException("S7 Float64 (LReal) is unreachable here — wide types route through the byte-buffer path (DecodeScalarBlock)"),
|
||
(S7DataType.String, _, _) => throw new NotSupportedException("S7 STRING is unreachable here — wide types route through the byte-buffer path (DecodeScalarBlock)"),
|
||
(S7DataType.DateTime, _, _) => throw new NotSupportedException("S7 DateTime is unreachable here — wide types route through the byte-buffer path (DecodeScalarBlock)"),
|
||
|
||
_ => throw new System.IO.InvalidDataException(
|
||
$"S7 Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " +
|
||
$"parsed as Size={addr.Size}; S7.Net returned {raw.GetType().Name}"),
|
||
};
|
||
|
||
// ---- IWritable ----
|
||
|
||
/// <inheritdoc />
|
||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
||
{
|
||
// Same as ReadAsync — validate before the init check so a null argument is a
|
||
// typed argument error, not the "not initialized" surface.
|
||
ArgumentNullException.ThrowIfNull(writes);
|
||
RequireInitialized();
|
||
var results = new WriteResult[writes.Count];
|
||
// Health is decided ONCE, at the end of the batch, worst-state-wins — see WritePassOutcome.
|
||
var outcome = new WritePassOutcome();
|
||
|
||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||
try
|
||
{
|
||
// (Re)open under the gate; a failed (re)connect degrades every write to
|
||
// BadCommunicationError rather than throwing.
|
||
IS7Plc plc;
|
||
try
|
||
{
|
||
plc = await EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
|
||
}
|
||
// Rethrow ONLY caller cancellation — a timeout-born OCE degrades the batch below rather
|
||
// than escaping as teardown (STAB-14 defense-in-depth, mirrors ReadAsync).
|
||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
|
||
catch (Exception ex)
|
||
{
|
||
for (var i = 0; i < writes.Count; i++)
|
||
results[i] = new WriteResult(StatusBadCommunicationError);
|
||
outcome.RecordFailure(DriverState.Degraded, ex.Message);
|
||
return results;
|
||
}
|
||
|
||
for (var i = 0; i < writes.Count; i++)
|
||
{
|
||
var w = writes[i];
|
||
if (!_resolver.TryResolve(w.FullReference, out var tag))
|
||
{
|
||
results[i] = new WriteResult(StatusBadNodeIdUnknown);
|
||
continue;
|
||
}
|
||
if (!tag.Writable)
|
||
{
|
||
results[i] = new WriteResult(StatusBadNotWritable);
|
||
continue;
|
||
}
|
||
// Timer/Counter write guard: an equipment tag authored writable=true (S7 mapper
|
||
// honours the "writable" key, and node-level auth governs writes) whose address is a
|
||
// Timer/Counter would bypass the !Writable gate above and reach EncodeScalarBlock,
|
||
// which throws NotSupportedException → BadNotSupported. The docs say Timer/Counter
|
||
// writes return BadNotWritable. Detect the area here before the call so it
|
||
// consistently returns BadNotWritable (init guard-d also rejects a writable
|
||
// Timer/Counter, but the mapper doesn't run that guard).
|
||
// TryParse (not Parse) so a malformed address falls through to the existing
|
||
// error path rather than throwing here.
|
||
if (_parsedByRawPath.TryGetValue(tag.Name, out var tcParsed)
|
||
? tcParsed.Area is S7Area.Timer or S7Area.Counter
|
||
: (S7AddressParser.TryParse(tag.Address, out tcParsed) && tcParsed.Area is S7Area.Timer or S7Area.Counter))
|
||
{
|
||
results[i] = new WriteResult(StatusBadNotWritable);
|
||
continue;
|
||
}
|
||
try
|
||
{
|
||
await WriteOneAsync(plc, tag, w.Value, cancellationToken).ConfigureAwait(false);
|
||
results[i] = new WriteResult(0u);
|
||
outcome.RecordSuccess();
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
// Cancellation observed mid-PDU abandons a half-written ISO-on-TCP request on
|
||
// the single gated stream — the handle must be reopened, not reused (STAB-15a).
|
||
// Mark dead (we hold _gate) and propagate: the caller cancelled, nobody consumes
|
||
// the batch, and Release() still runs in finally. Marking is unconditional on
|
||
// OCE — a false positive costs one cheap reopen versus permanent desync.
|
||
_plcDead = true;
|
||
throw;
|
||
}
|
||
catch (NotSupportedException)
|
||
{
|
||
results[i] = new WriteResult(StatusBadNotSupported);
|
||
}
|
||
catch (S7ValueRangeException)
|
||
{
|
||
// Value unrepresentable in the S7 type (string over its declared max, DATE < 1990…):
|
||
// refused with nothing sent — a client error, no health impact.
|
||
results[i] = new WriteResult(StatusBadOutOfRange);
|
||
}
|
||
catch (S7WriteValueException)
|
||
{
|
||
// Client-supplied value of the wrong shape — not a device or comms fault.
|
||
results[i] = new WriteResult(StatusBadTypeMismatch);
|
||
}
|
||
catch (PlcException pex) when (IsAccessDenied(pex))
|
||
{
|
||
// PUT/GET-disabled / access-protection on write — same permanent
|
||
// configuration fault as on read. BadNotSupported +
|
||
// a config-alert health state, not a transient device failure.
|
||
results[i] = new WriteResult(StatusBadNotSupported);
|
||
outcome.RecordFailure(DriverState.Faulted,
|
||
"S7 access denied — enable PUT/GET communication in TIA Portal " +
|
||
$"(Protection & Security) for this CPU. PLC reported: {pex.Message}");
|
||
}
|
||
catch (PlcException pex)
|
||
{
|
||
// Genuine device-layer fault — degrade health so a PLC-down-during-writes
|
||
// scenario is visible to the operator (previously health was never updated
|
||
// on write failure). A connection-fatal PlcException also marks the handle
|
||
// dead so the next call reopens.
|
||
MarkConnectionDeadIfFatal(pex);
|
||
results[i] = new WriteResult(StatusBadDeviceFailure);
|
||
outcome.RecordFailure(DriverState.Degraded, pex.Message);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// Socket/timeout/conversion failure. Map to BadCommunicationError (not
|
||
// BadInternalError) for transport faults; degrade health, and mark the handle
|
||
// dead when it's a connection loss.
|
||
MarkConnectionDeadIfFatal(ex);
|
||
results[i] = new WriteResult(StatusBadCommunicationError);
|
||
outcome.RecordFailure(DriverState.Degraded, ex.Message);
|
||
}
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
// THE single health write of the batch (a finally, so an OCE mid-batch still flushes the
|
||
// evidence gathered so far — a pass that observed nothing writes nothing).
|
||
ApplyWritePassHealth(outcome);
|
||
_gate.Release();
|
||
}
|
||
return results;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Publishes one write batch's verdict (STAB-22): worst state wins over every outcome the
|
||
/// batch recorded, so a Faulted access-denied item is not erased by a later Degraded or
|
||
/// successful sibling. Maintains <see cref="_writeFault"/>, the sticky Faulted a read pass
|
||
/// must not erase. Caller holds <see cref="_gate"/>.
|
||
/// </summary>
|
||
/// <param name="outcome">The batch's accumulated outcomes.</param>
|
||
private void ApplyWritePassHealth(WritePassOutcome outcome)
|
||
{
|
||
if (!outcome.HasObservations) return;
|
||
|
||
var worst = outcome.WorstFailure;
|
||
var priorWriteFault = _writeFault;
|
||
if (worst == DriverState.Faulted)
|
||
_writeFault = (DriverState.Faulted, outcome.WorstError!);
|
||
else if (outcome.AnySuccess)
|
||
_writeFault = null; // a write got through — the protection fault is gone
|
||
|
||
if (worst == DriverState.Healthy)
|
||
{
|
||
// Success-only batch: no health write, except to retire a Faulted that WE (the write
|
||
// side) were holding — a clean write is the only evidence the alert has cleared.
|
||
// Only a Faulted the write side owns: a read-side Faulted (e.g. access denied on another DB) is the
|
||
// read pass's to clear, and Health still carries the write fault's own message iff it is ours.
|
||
if (outcome.AnySuccess && _writeFault is null && priorWriteFault is { } owned
|
||
&& Health.State == DriverState.Faulted && Health.LastError == owned.Message)
|
||
Health = new DriverHealth(DriverState.Healthy, Health.LastSuccessfulRead, null);
|
||
return;
|
||
}
|
||
|
||
var state = worst;
|
||
var error = outcome.WorstError;
|
||
// A Degraded-only batch must not downgrade a still-sticky Faulted.
|
||
if (_writeFault is { } wf && ReadPassOutcome.Rank(wf.State) > ReadPassOutcome.Rank(state))
|
||
{
|
||
state = wf.State;
|
||
error = wf.Message;
|
||
}
|
||
Health = new DriverHealth(state, Health.LastSuccessfulRead, error);
|
||
}
|
||
|
||
/// <summary>
|
||
/// The outcomes ONE <see cref="WriteAsync"/> batch observed — the write-side sibling of
|
||
/// <see cref="ReadPassOutcome"/> (same worst-state-wins ranking and severity-pinned message),
|
||
/// accumulated so the batch writes <see cref="Health"/> once instead of per item.
|
||
/// </summary>
|
||
private sealed class WritePassOutcome
|
||
{
|
||
private readonly ReadPassOutcome _inner = new();
|
||
|
||
/// <summary>True once any item in the batch wrote successfully.</summary>
|
||
public bool AnySuccess => _inner.AnySuccess;
|
||
/// <summary>The worst failure recorded; Healthy means none.</summary>
|
||
public DriverState WorstFailure => _inner.WorstFailure;
|
||
/// <summary>The message explaining <see cref="WorstFailure"/>.</summary>
|
||
public string? WorstError => _inner.WorstError;
|
||
/// <summary>True once the batch observed a success or a failure.</summary>
|
||
public bool HasObservations => _inner.HasObservations;
|
||
/// <summary>Records one successful write.</summary>
|
||
public void RecordSuccess() => _inner.RecordSuccess();
|
||
/// <summary>Records one failed write.</summary>
|
||
/// <param name="state">The state the failure would have stamped alone.</param>
|
||
/// <param name="message">The operator-facing message.</param>
|
||
public void RecordFailure(DriverState state, string message) => _inner.RecordFailure(state, message);
|
||
}
|
||
|
||
private async Task WriteOneAsync(IS7Plc plc, S7TagDefinition tag, object? value, CancellationToken ct)
|
||
{
|
||
// Parse the address the same way ReadOneAsync does: every tag pre-parses at init
|
||
// (_parsedByRawPath), with an on-demand parse as a defensive fallback. Needed here so the
|
||
// wide-type write can byte-address the block (the narrow path below addresses by the raw
|
||
// address string instead).
|
||
var addr = _parsedByRawPath.TryGetValue(tag.Name, out var parsed)
|
||
? parsed
|
||
: S7AddressParser.Parse(tag.Address);
|
||
|
||
// Array path (S7-016): ONE contiguous WriteBytesAsync of count × element-bytes — the inverse
|
||
// of ReadArrayAsync. Bool / wide-type arrays throw NotSupportedException (→ BadNotSupported);
|
||
// a wrong-shaped value throws S7WriteValueException (→ BadTypeMismatch, no health impact).
|
||
if (tag.ArrayCount is >= 1)
|
||
{
|
||
var arrayBytes = EncodeArrayBlock(tag, addr, value);
|
||
await plc.WriteBytesAsync(ToS7NetArea(addr.Area), addr.DbNumber, addr.ByteOffset, arrayBytes, ct)
|
||
.ConfigureAwait(false);
|
||
return;
|
||
}
|
||
|
||
// Wide/structured scalar path: encode the value to a big-endian byte block and write it
|
||
// at the start byte via S7.Net's buffer-based WriteBytesAsync. Mirrors the read seam;
|
||
// the narrow string-based write below stays unchanged for 1/2/4-byte types.
|
||
if (IsBufferType(tag, addr))
|
||
{
|
||
await WriteScalarBlockAsync(plc, tag, addr, value, ct).ConfigureAwait(false);
|
||
return;
|
||
}
|
||
|
||
// S7.Net's Plc.WriteAsync(string address, object value) expects the boxed value to
|
||
// match the address's size-suffix type: DBX=bool, DBB=byte, DBW=ushort, DBD=uint.
|
||
// Our S7DataType lets the caller pass short/int/float; convert to the unsigned
|
||
// wire representation before handing off.
|
||
var boxed = BoxValueForWrite(tag.DataType, value);
|
||
await plc.WriteAsync(tag.Address, boxed, ct).ConfigureAwait(false);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Pure encode loop — the inverse of <see cref="DecodeArrayBlock"/>: turns the caller's array
|
||
/// into the contiguous big-endian block for a single <c>WriteBytesAsync</c>. No network I/O.
|
||
/// Accepts any non-string <see cref="System.Collections.IEnumerable"/> (OPC UA hands the server
|
||
/// a typed array, but a boxed <c>object[]</c> is equally fine); each element is coerced with
|
||
/// <c>Convert.To*</c> like the scalar path.
|
||
/// </summary>
|
||
/// <param name="tag">Tag definition carrying the element type and declared array length.</param>
|
||
/// <param name="addr">Parsed address carrying the access <see cref="S7Size"/>.</param>
|
||
/// <param name="value">The array to encode; must hold exactly <c>ArrayCount</c> elements.</param>
|
||
/// <returns>The big-endian block, <c>count × element-bytes</c> long.</returns>
|
||
/// <exception cref="NotSupportedException">Bool or wide-type element arrays.</exception>
|
||
/// <exception cref="S7WriteValueException">The value is not an array, has the wrong length, or an
|
||
/// element does not convert to the element type.</exception>
|
||
internal static byte[] EncodeArrayBlock(S7TagDefinition tag, S7ParsedAddress addr, object? value)
|
||
{
|
||
var count = tag.ArrayCount!.Value;
|
||
|
||
switch (tag.DataType, addr.Size)
|
||
{
|
||
case (S7DataType.Bool, _):
|
||
throw new NotSupportedException(
|
||
$"S7 Bool array writes are not supported (tag '{tag.Name}'): a byte-granular write would clobber neighbouring bits.");
|
||
case (S7DataType.Int64, _):
|
||
case (S7DataType.UInt64, _):
|
||
case (S7DataType.Float64, _):
|
||
case (S7DataType.String, _):
|
||
case (S7DataType.DateTime, _):
|
||
case (S7DataType.WString, _):
|
||
case (S7DataType.Char, _):
|
||
case (S7DataType.WChar, _):
|
||
case (S7DataType.Dtl, _):
|
||
case (S7DataType.Date, _):
|
||
case (S7DataType.Tod, _):
|
||
case (S7DataType.Time, _):
|
||
case (S7DataType.S5Time, _):
|
||
throw new NotSupportedException($"S7 array writes of {tag.DataType} are not supported (tag '{tag.Name}').");
|
||
case (S7DataType.Byte, S7Size.Byte):
|
||
case (S7DataType.UInt16, S7Size.Word):
|
||
case (S7DataType.Int16, S7Size.Word):
|
||
case (S7DataType.UInt32, S7Size.DWord):
|
||
case (S7DataType.Int32, S7Size.DWord):
|
||
case (S7DataType.Float32, S7Size.DWord):
|
||
break;
|
||
default:
|
||
throw new System.IO.InvalidDataException(
|
||
$"S7 array Write type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address " +
|
||
$"'{tag.Address}' parsed as Size={addr.Size}");
|
||
}
|
||
|
||
if (value is string || value is not System.Collections.IEnumerable items)
|
||
throw new S7WriteValueException($"S7 array tag '{tag.Name}' requires an array value.");
|
||
|
||
var elements = new List<object?>(count);
|
||
foreach (var e in items) elements.Add(e);
|
||
// Convert.ToXxx(null) is 0 — a null element must not silently write zero to the PLC.
|
||
if (elements.Contains(null))
|
||
throw new S7WriteValueException($"S7 array tag '{tag.Name}': an element is null.");
|
||
if (elements.Count != count)
|
||
throw new S7WriteValueException(
|
||
$"S7 array tag '{tag.Name}' has {count} elements but the write supplied {elements.Count}.");
|
||
|
||
var elementBytes = ElementByteSize(addr.Size);
|
||
var block = new byte[count * elementBytes];
|
||
try
|
||
{
|
||
for (var i = 0; i < count; i++)
|
||
{
|
||
var span = block.AsSpan(i * elementBytes, elementBytes);
|
||
var element = elements[i];
|
||
switch (tag.DataType)
|
||
{
|
||
case S7DataType.Byte: span[0] = Convert.ToByte(element); break;
|
||
case S7DataType.UInt16: System.Buffers.Binary.BinaryPrimitives.WriteUInt16BigEndian(span, Convert.ToUInt16(element)); break;
|
||
case S7DataType.Int16: System.Buffers.Binary.BinaryPrimitives.WriteInt16BigEndian(span, Convert.ToInt16(element)); break;
|
||
case S7DataType.UInt32: System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(span, Convert.ToUInt32(element)); break;
|
||
case S7DataType.Int32: System.Buffers.Binary.BinaryPrimitives.WriteInt32BigEndian(span, Convert.ToInt32(element)); break;
|
||
case S7DataType.Float32: System.Buffers.Binary.BinaryPrimitives.WriteSingleBigEndian(span, Convert.ToSingle(element)); break;
|
||
}
|
||
}
|
||
}
|
||
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException)
|
||
{
|
||
throw new S7WriteValueException(
|
||
$"S7 array tag '{tag.Name}': an element does not convert to {tag.DataType} ({ex.Message}).");
|
||
}
|
||
return block;
|
||
}
|
||
|
||
/// <summary>
|
||
/// A caller-supplied write value the driver cannot encode (wrong shape / length / element
|
||
/// type). A client error — maps to <c>BadTypeMismatch</c> and does not touch driver health.
|
||
/// </summary>
|
||
internal sealed class S7WriteValueException(string message) : Exception(message);
|
||
|
||
/// <summary>
|
||
/// Writes a wide/structured scalar as ONE contiguous byte block via S7.Net's
|
||
/// buffer-based <c>Plc.WriteBytesAsync(DataType, db, startByteAdr, byte[] value, ct)</c>.
|
||
/// The pure <see cref="EncodeScalarBlock"/> produces the big-endian bytes; this method
|
||
/// owns only the network I/O (mirrors <see cref="ReadScalarBlockAsync"/>).
|
||
/// </summary>
|
||
private async Task WriteScalarBlockAsync(IS7Plc plc, S7TagDefinition tag, S7ParsedAddress addr, object? value, CancellationToken ct)
|
||
{
|
||
var bytes = EncodeScalarBlock(tag, value);
|
||
await plc.WriteBytesAsync(ToS7NetArea(addr.Area), addr.DbNumber, addr.ByteOffset, bytes, ct)
|
||
.ConfigureAwait(false);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Pure boxing step — converts the caller's value into the unsigned wire type that
|
||
/// S7.Net's <c>Plc.WriteAsync</c> expects for each address size (bool → bool, byte
|
||
/// → byte, short → ushort, int → uint, float → uint-bits). No network I/O.
|
||
/// Factored out of <see cref="WriteOneAsync"/> so it can be exercised in unit tests
|
||
/// without a live PLC.
|
||
/// </summary>
|
||
/// <param name="dataType">Target S7 data type.</param>
|
||
/// <param name="value">Value to box.</param>
|
||
/// <returns>The boxed value in the wire type expected by S7.Net.</returns>
|
||
internal static object BoxValueForWrite(S7DataType dataType, object? value) => dataType switch
|
||
{
|
||
S7DataType.Bool => (object)Convert.ToBoolean(value),
|
||
S7DataType.Byte => (object)Convert.ToByte(value),
|
||
S7DataType.UInt16 => (object)Convert.ToUInt16(value),
|
||
S7DataType.Int16 => (object)unchecked((ushort)Convert.ToInt16(value)),
|
||
S7DataType.UInt32 => (object)Convert.ToUInt32(value),
|
||
S7DataType.Int32 => (object)unchecked((uint)Convert.ToInt32(value)),
|
||
S7DataType.Float32 => (object)BitConverter.SingleToUInt32Bits(Convert.ToSingle(value)),
|
||
|
||
// Unreachable defensive backstop — every wide type routes through the byte-buffer write
|
||
// path (IsBufferType → WriteScalarBlockAsync → EncodeScalarBlock) before BoxValueForWrite
|
||
// is consulted. Kept (not deleted) so a mis-route fails loudly rather than mis-encoding.
|
||
S7DataType.Int64 => throw new NotSupportedException("S7 Int64 is unreachable here — wide types route through the byte-buffer path (EncodeScalarBlock)"),
|
||
S7DataType.UInt64 => throw new NotSupportedException("S7 UInt64 is unreachable here — wide types route through the byte-buffer path (EncodeScalarBlock)"),
|
||
S7DataType.Float64 => throw new NotSupportedException("S7 Float64 (LReal) is unreachable here — wide types route through the byte-buffer path (EncodeScalarBlock)"),
|
||
S7DataType.String => throw new NotSupportedException("S7 STRING is unreachable here — wide types route through the byte-buffer path (EncodeScalarBlock)"),
|
||
S7DataType.DateTime => throw new NotSupportedException("S7 DateTime is unreachable here — wide types route through the byte-buffer path (EncodeScalarBlock)"),
|
||
S7DataType.WString or S7DataType.Char or S7DataType.WChar or S7DataType.Dtl or S7DataType.Date
|
||
or S7DataType.Tod or S7DataType.Time or S7DataType.S5Time
|
||
=> throw new NotSupportedException($"S7 {dataType} is unreachable here — wide types route through the byte-buffer path (EncodeScalarBlock)"),
|
||
_ => throw new InvalidOperationException($"Unknown S7DataType {dataType}"),
|
||
};
|
||
|
||
private void RequireInitialized()
|
||
{
|
||
if (!_initialized) throw new InvalidOperationException("S7Driver not initialized");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Returns a live S7 connection, lazily disposing a dead handle and re-opening a fresh one
|
||
/// when the previous connection dropped (the reconnect path). The fast path returns the
|
||
/// current <see cref="Plc"/> when it is still connected and not marked dead; otherwise it
|
||
/// builds + opens a new connection via <see cref="_plcFactory"/> with the same
|
||
/// timeout-linked CTS the initial connect uses.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// MUST be called while holding <see cref="_gate"/> — it mutates <see cref="Plc"/> /
|
||
/// <see cref="_plcDead"/> and reuses the single-connection-per-PLC invariant the gate
|
||
/// enforces. The read / write / probe paths all take the gate before calling it.
|
||
/// <para>
|
||
/// While the PLC is down every data call and probe tick would pay a full connect attempt
|
||
/// bounded only by <c>_options.Timeout</c> (STAB-8). This method is the single
|
||
/// choke-point all S7 reconnects flow through (already gate-serialized), so the
|
||
/// connect-attempt throttle lives here (STAB-21): the shared <see cref="ConnectionBackoff"/>
|
||
/// primitive is consulted with <c>ShouldAttempt(now)</c> at the top of the slow path —
|
||
/// inside the window the call fails fast with no connect attempt, which every caller
|
||
/// already degrades on — with <c>RecordFailure</c>/<c>RecordSuccess</c> around the connect.
|
||
/// A successful connect clears the window, so recovery is never delayed past the first
|
||
/// attempt that finds the PLC back; the 30 s cap bounds the worst case.
|
||
/// </para>
|
||
/// </remarks>
|
||
/// <param name="ct">Cancellation token for the (re)connect attempt.</param>
|
||
/// <returns>A live <see cref="IS7Plc"/>.</returns>
|
||
private async Task<IS7Plc> EnsureConnectedAsync(CancellationToken ct)
|
||
{
|
||
// Fast path — the current connection is live and not flagged dead.
|
||
if (!_plcDead && Plc is { IsConnected: true } live) return live;
|
||
|
||
// Dispose the stale / dead handle before making a fresh one (exactly once, here).
|
||
if (Plc is not null)
|
||
{
|
||
try { Plc.Dispose(); } catch { /* best-effort */ }
|
||
Plc = null;
|
||
}
|
||
_plcDead = false;
|
||
|
||
// STAB-21: inside the backoff window fail fast WITHOUT a connect attempt. A plain
|
||
// InvalidOperationException (not an OCE) so the read/write/probe catch ladders degrade on it.
|
||
if (!_connectBackoff.ShouldAttempt(BackoffClock()))
|
||
throw new InvalidOperationException(
|
||
$"S7 reconnect to {_options.Host}:{_options.Port} suppressed — backing off after a failed connect attempt.");
|
||
|
||
var plc = _plcFactory.Create(_options);
|
||
try
|
||
{
|
||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||
cts.CancelAfter(_options.Timeout);
|
||
await plc.OpenAsync(cts.Token).ConfigureAwait(false);
|
||
}
|
||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||
{
|
||
_connectBackoff.RecordFailure(BackoffClock());
|
||
// The linked CancelAfter fired — a connect TIMEOUT, not a caller cancellation.
|
||
// Surface it as a connection FAILURE (TimeoutException) so callers route it through
|
||
// their degrade paths: an escaping OCE reads as teardown and permanently killed the
|
||
// subscription poll loops (STAB-14, the unreachable-host regression in the Crit-3 fix).
|
||
try { plc.Dispose(); } catch { /* best-effort */ }
|
||
throw new TimeoutException(
|
||
$"S7 connect to {_options.Host}:{_options.Port} timed out after " +
|
||
$"{(int)_options.Timeout.TotalMilliseconds} ms.");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// Caller cancellation is teardown, not a dead PLC — it must not open a window.
|
||
if (!(ex is OperationCanceledException && ct.IsCancellationRequested))
|
||
_connectBackoff.RecordFailure(BackoffClock());
|
||
try { plc.Dispose(); } catch { /* best-effort */ }
|
||
throw;
|
||
}
|
||
|
||
_connectBackoff.RecordSuccess();
|
||
Plc = plc;
|
||
Volatile.Write(ref _negotiatedPduSize, plc.MaxPduSize);
|
||
_logger.LogInformation("S7Driver reconnected. Driver={DriverInstanceId} Host={Host} CPU={CpuType} PduSize={PduSize}",
|
||
_driverInstanceId, _options.Host, _options.CpuType, _negotiatedPduSize);
|
||
return plc;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Flags the current connection dead when <paramref name="ex"/> is a connection-level loss,
|
||
/// so the next <see cref="EnsureConnectedAsync"/> reopens. A no-op for data-address / type
|
||
/// faults — those don't warrant tearing down a healthy socket.
|
||
/// </summary>
|
||
/// <param name="ex">The exception observed on a read / write.</param>
|
||
private void MarkConnectionDeadIfFatal(Exception ex)
|
||
{
|
||
if (IsS7ConnectionFatal(ex)) _plcDead = true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// True when <paramref name="ex"/> (or any inner exception) is a socket-level / connection
|
||
/// loss OR an ISO-on-TCP framing/desync fault that a reopen can repair — a
|
||
/// <see cref="System.Net.Sockets.SocketException"/> / <see cref="System.IO.IOException"/> /
|
||
/// <see cref="ObjectDisposedException"/>; a <see cref="TimeoutException"/> from a wire op that
|
||
/// blew its <see cref="S7OperationDeadline"/> wall-clock ceiling (an unresponsive but still
|
||
/// TCP-established peer — the READ-leg sibling of the connect-timeout fix, STAB-14); an S7.Net framing violation
|
||
/// (<see cref="TPKTInvalidException"/> / <see cref="TPDUInvalidException"/> /
|
||
/// <see cref="WrongNumberOfBytesException"/>); or a <see cref="PlcException"/> carrying
|
||
/// <see cref="ErrorCode.ConnectionError"/> or <see cref="ErrorCode.WrongNumberReceivedBytes"/>.
|
||
/// A framing violation means the single serialized stream's position is untrustworthy — a
|
||
/// half-read PDU left by a timeout/cancellation makes every later response mis-frame, so only
|
||
/// a reopen recovers (STAB-15a: the Modbus STAB-3 desync mode rebuilt in S7).
|
||
/// <para>
|
||
/// Deliberately NOT <see cref="System.IO.InvalidDataException"/>: the driver's own decode
|
||
/// backstop (<see cref="ReinterpretRawValue"/> / <see cref="DecodeScalarBlock"/>) throws
|
||
/// it for a declared-type/address-size CONFIG mismatch on a HEALTHY socket — classifying
|
||
/// it would churn a full reopen on every read of a mis-authored tag. A genuinely desynced
|
||
/// stream's very next PDU still lands in TPKT/TPDU/WrongNumber territory, which now tears
|
||
/// down, so a real desync self-heals within one extra failed call.
|
||
/// </para>
|
||
/// A data-address / type error (S7.Net's <see cref="ErrorCode.ReadData"/> /
|
||
/// <see cref="ErrorCode.WriteData"/> for a bad address, PUT/GET-denied, etc.) is deliberately
|
||
/// NOT treated as fatal — reopening wouldn't help and would churn a healthy connection.
|
||
/// </summary>
|
||
/// <param name="ex">The exception to classify.</param>
|
||
/// <returns><c>true</c> when a reconnect is warranted.</returns>
|
||
private static bool IsS7ConnectionFatal(Exception ex)
|
||
{
|
||
for (Exception? e = ex; e is not null; e = e.InnerException)
|
||
{
|
||
if (e is System.Net.Sockets.SocketException or System.IO.IOException or ObjectDisposedException)
|
||
return true;
|
||
// A wire op that blew its wall-clock deadline (S7OperationDeadline — the async read/write
|
||
// ceiling S7.Net's ignored socket ReadTimeout/WriteTimeout can't provide) means the
|
||
// connection is unresponsive: discard + reopen. This is the READ-leg sibling of the
|
||
// connect-timeout fix (STAB-14) — without it a frozen-but-established peer wedges the
|
||
// poll loop forever instead of surfacing Bad + reconnecting.
|
||
if (e is TimeoutException)
|
||
return true;
|
||
// ISO-on-TCP framing/desync surface (STAB-15a): the stream position is untrustworthy —
|
||
// a half-read PDU left by a timeout/cancellation makes every later response mis-frame.
|
||
// NOTE: deliberately NOT System.IO.InvalidDataException — ReinterpretRawValue throws it
|
||
// for a declared-type/address-size CONFIG mismatch on a healthy socket.
|
||
if (e is TPKTInvalidException or TPDUInvalidException or WrongNumberOfBytesException)
|
||
return true;
|
||
if (e is PlcException { ErrorCode: ErrorCode.ConnectionError or ErrorCode.WrongNumberReceivedBytes })
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Detects an S7 PUT/GET-disabled / access-protection fault inside an S7.Net
|
||
/// <see cref="PlcException"/>. S7.Net's read/write paths wrap every PLC-side error in a
|
||
/// <c>PlcException</c> with <see cref="ErrorCode.ReadData"/> / <see cref="ErrorCode.WriteData"/>;
|
||
/// the response-code validator throws a plain <see cref="Exception"/> for the S7
|
||
/// <c>AccessingObjectNotAllowed</c> status, which lands as the inner exception. There is
|
||
/// no typed error code for it, so the inner message is the only discriminator
|
||
/// S7.Net exposes.
|
||
/// </summary>
|
||
internal static bool IsAccessDenied(PlcException pex)
|
||
{
|
||
for (Exception? e = pex; e is not null; e = e.InnerException)
|
||
{
|
||
if (e.Message.Contains("Accessing object not allowed", StringComparison.OrdinalIgnoreCase)
|
||
|| e.Message.Contains("not allowed", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// ---- ITagDiscovery ----
|
||
|
||
/// <inheritdoc />
|
||
// Run-once: DiscoverAsync emits the complete node set synchronously from the configured tag
|
||
// table in a single pass — nothing fills in asynchronously after connect, so a single
|
||
// discovery pass is sufficient.
|
||
public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;
|
||
|
||
/// <inheritdoc />
|
||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(builder);
|
||
var folder = builder.Folder("S7", "S7");
|
||
foreach (var t in _tagsByRawPath.Values)
|
||
{
|
||
// A tag carrying a non-null array count (>= 1) surfaces as a 1-D OPC UA array node.
|
||
// A null count stays scalar. A count of 1 IS a valid 1-element array: the foundation
|
||
// materialises a [1] OPC UA array node when isArray:true, so the driver must agree.
|
||
var isArray = t.ArrayCount is >= 1;
|
||
folder.Variable(t.Name, t.Name, new DriverAttributeInfo(
|
||
FullName: t.Name,
|
||
DriverDataType: MapDataType(t.DataType),
|
||
IsArray: isArray,
|
||
ArrayDim: isArray ? (uint)t.ArrayCount!.Value : null,
|
||
SecurityClass: t.Writable ? SecurityClassification.Operate : SecurityClassification.ViewOnly,
|
||
IsHistorized: false,
|
||
IsAlarm: false,
|
||
WriteIdempotent: t.WriteIdempotent));
|
||
}
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
private static DriverDataType MapDataType(S7DataType t) => t switch
|
||
{
|
||
S7DataType.Bool => DriverDataType.Boolean,
|
||
S7DataType.Byte => DriverDataType.Int32, // no 8-bit in DriverDataType yet
|
||
// UInt32 values > int.MaxValue (2^31-1) wrap negative when surfaced as
|
||
// Int32. That residual lossy note now applies ONLY to UInt32 — Int64/UInt64 map to
|
||
// their own DriverDataType members below (Phase 4d), so they are no longer lossy.
|
||
S7DataType.Int16 or S7DataType.UInt16 or S7DataType.Int32 or S7DataType.UInt32 => DriverDataType.Int32,
|
||
S7DataType.Int64 => DriverDataType.Int64,
|
||
S7DataType.UInt64 => DriverDataType.UInt64,
|
||
S7DataType.Float32 => DriverDataType.Float32,
|
||
S7DataType.Float64 => DriverDataType.Float64,
|
||
S7DataType.String or S7DataType.WString or S7DataType.Char or S7DataType.WChar => DriverDataType.String,
|
||
S7DataType.DateTime or S7DataType.Dtl or S7DataType.Date => DriverDataType.DateTime,
|
||
// TOD / TIME / S5TIME surface as Int32 milliseconds (the default arm; named for the reader).
|
||
S7DataType.Tod or S7DataType.Time or S7DataType.S5Time => DriverDataType.Int32,
|
||
_ => DriverDataType.Int32,
|
||
};
|
||
|
||
// ---- ISubscribable (polling overlay) ----
|
||
|
||
/// <inheritdoc />
|
||
public Task<ISubscriptionHandle> SubscribeAsync(
|
||
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
|
||
=> Task.FromResult(_poll.Subscribe(fullReferences, publishingInterval));
|
||
|
||
/// <inheritdoc />
|
||
public async Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||
{
|
||
// Async teardown: the engine drains the in-flight poll without blocking the caller's
|
||
// thread — this runs inside a DriverInstanceActor mailbox turn.
|
||
await _poll.UnsubscribeAsync(handle).ConfigureAwait(false);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 05/STAB-9 — routes a poll-loop reader failure (surfaced by the shared
|
||
/// <see cref="PollGroupEngine"/>'s onError sink) to the driver health surface: logs it and
|
||
/// degrades to <see cref="DriverState.Degraded"/> preserving <c>LastSuccessfulRead</c>. Never
|
||
/// downgrades a <see cref="DriverState.Faulted"/> state (e.g. PUT/GET-denied set by ReadAsync)
|
||
/// — Faulted is the stronger, permanent-config signal. Replaces the retired fork's
|
||
/// <c>HandlePollFailure</c>; the engine now owns the consecutive-failure count + backoff.
|
||
/// </summary>
|
||
/// <param name="ex">The exception caught by the poll engine.</param>
|
||
private void HandlePollError(Exception ex)
|
||
{
|
||
_logger.LogWarning(ex, "S7 poll reader failed. Driver={DriverInstanceId}", _driverInstanceId);
|
||
if (Health.State != DriverState.Faulted)
|
||
Health = new DriverHealth(DriverState.Degraded, Health.LastSuccessfulRead, ex.Message);
|
||
}
|
||
|
||
// ---- IHostConnectivityProbe ----
|
||
|
||
/// <summary>
|
||
/// Host identifier surfaced in <see cref="GetHostStatuses"/>. <c>host:port</c> format
|
||
/// matches the Modbus driver's convention so the Admin UI dashboard renders both
|
||
/// family's rows uniformly.
|
||
/// </summary>
|
||
public string HostName => $"{_options.Host}:{_options.Port}";
|
||
|
||
/// <inheritdoc />
|
||
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses()
|
||
{
|
||
lock (_probeLock)
|
||
return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)];
|
||
}
|
||
|
||
private async Task ProbeLoopAsync(CancellationToken ct)
|
||
{
|
||
while (!ct.IsCancellationRequested)
|
||
{
|
||
var success = false;
|
||
try
|
||
{
|
||
// Probe via S7.Net's low-cost GetCpuStatus — returns the CPU state (Run/Stop)
|
||
// and is intentionally light on the comms mailbox. Single-word Plc.ReadAsync
|
||
// would also work but GetCpuStatus doubles as a "PLC actually up" check.
|
||
using var gateCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||
gateCts.CancelAfter(_options.Probe.Timeout);
|
||
|
||
await _gate.WaitAsync(gateCts.Token).ConfigureAwait(false);
|
||
try
|
||
{
|
||
try
|
||
{
|
||
// EnsureConnectedAsync doubles as the reconnect backstop: if a read/write
|
||
// marked the handle dead (or the socket dropped between ticks), the probe
|
||
// reopens it here so recovery doesn't wait for the next data call.
|
||
// It runs on the LOOP token, not the (shorter) probe deadline: the connect is bounded by
|
||
// _options.Timeout inside, and a timeout there must count as a failure so STAB-21's
|
||
// backoff window opens — cutting it with the probe deadline looked like a caller cancel,
|
||
// recorded nothing, and let a black-holed host cost a connect attempt every probe tick.
|
||
var plc = await EnsureConnectedAsync(ct).ConfigureAwait(false);
|
||
// The probe deadline bounds only the CPU-status round trip.
|
||
using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||
probeCts.CancelAfter(_options.Probe.Timeout);
|
||
await plc.ReadStatusAsync(probeCts.Token).ConfigureAwait(false);
|
||
success = true;
|
||
}
|
||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||
{
|
||
// Loop teardown — rethrow to the outer filter (after finally releases the gate).
|
||
throw;
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
// Probe deadline fired mid-PDU: the CPU-status response may still be in
|
||
// flight, so the handle can't be reused (half-read ⇒ desync). We hold _gate
|
||
// here, keeping _plcDead gate-guarded (STAB-15b).
|
||
_plcDead = true;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// STAB-15b: classify + mark while holding _gate so a wire-dead/desynced
|
||
// handle whose TcpClient still claims Connected is reopened by the NEXT
|
||
// tick's EnsureConnectedAsync instead of being re-probed forever.
|
||
MarkConnectionDeadIfFatal(ex);
|
||
}
|
||
}
|
||
finally { _gate.Release(); }
|
||
}
|
||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
|
||
catch { /* gate-wait timeout / other — treated as Stopped below (gate contention is not a connection fault) */ }
|
||
|
||
TransitionTo(success ? HostState.Running : HostState.Stopped);
|
||
|
||
try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
|
||
catch (OperationCanceledException) { return; }
|
||
}
|
||
}
|
||
|
||
private void TransitionTo(HostState newState)
|
||
{
|
||
HostState old;
|
||
lock (_probeLock)
|
||
{
|
||
old = _hostState;
|
||
if (old == newState) return;
|
||
_hostState = newState;
|
||
_hostStateChangedUtc = DateTime.UtcNow;
|
||
}
|
||
_logger.LogInformation("S7 probe transition. Driver={DriverInstanceId} Host={Host} {OldState} → {NewState}",
|
||
_driverInstanceId, HostName, old, newState);
|
||
OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, old, newState));
|
||
}
|
||
|
||
/// <summary>Disposes the driver and releases resources.</summary>
|
||
public void Dispose()
|
||
{
|
||
// Avoid the sync-over-async DisposeAsync().AsTask().GetAwaiter().GetResult()
|
||
// pattern (a known deadlock surface even when currently safe here). ShutdownAsync's
|
||
// body is effectively synchronous apart from waiting on probe/poll Tasks; do the same
|
||
// teardown directly, blocking only on the drain — and only with a bounded timeout so
|
||
// a wedged loop can't hang Dispose() indefinitely.
|
||
if (_disposed) return;
|
||
_disposed = true;
|
||
SynchronousTeardown();
|
||
_gate.Dispose();
|
||
}
|
||
|
||
/// <summary>Asynchronously disposes the driver and releases resources.</summary>
|
||
/// <returns>A task representing the asynchronous operation.</returns>
|
||
public async ValueTask DisposeAsync()
|
||
{
|
||
if (_disposed) return;
|
||
_disposed = true;
|
||
try { await ShutdownAsync(CancellationToken.None).ConfigureAwait(false); }
|
||
catch { /* disposal is best-effort */ }
|
||
_gate.Dispose();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Synchronous teardown — mirrors <see cref="ShutdownAsync"/> but blocks (with a bounded
|
||
/// timeout) on the probe + poll Tasks instead of awaiting them. Used by the sync
|
||
/// <see cref="Dispose"/> path so we don't sync-over-async <see cref="DisposeAsync"/>.
|
||
/// </summary>
|
||
private void SynchronousTeardown()
|
||
{
|
||
// Drain the polling overlay (drain-before-dispose, STAB-6-S7).
|
||
try { _poll.DisposeAsync().AsTask().Wait(DrainTimeout); }
|
||
catch { /* timeouts/loop faults are tolerated — teardown continues */ }
|
||
|
||
var probeCts = _probeCts;
|
||
var probeTask = _probeTask;
|
||
try { probeCts?.Cancel(); } catch { }
|
||
if (probeTask is not null)
|
||
{
|
||
try { probeTask.Wait(DrainTimeout); }
|
||
catch { /* timeouts/loop faults are tolerated — teardown continues */ }
|
||
}
|
||
|
||
probeCts?.Dispose();
|
||
_probeCts = null;
|
||
_probeTask = null;
|
||
|
||
_initialized = false;
|
||
_plcDead = false;
|
||
_writeFault = null;
|
||
_connectBackoff.RecordSuccess();
|
||
try { Plc?.Dispose(); } catch { /* best-effort — tearing down anyway */ }
|
||
Plc = null;
|
||
Volatile.Write(ref _negotiatedPduSize, 0); // a stale post-teardown reading would read as "online"
|
||
Health = new DriverHealth(DriverState.Unknown, Health.LastSuccessfulRead, null);
|
||
}
|
||
}
|