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