S7-002: add inline comment documenting the UInt32→Int32 lossiness in MapDataType, consistent with the Int64/UInt64 note. Tracked for a follow-up that adds unsigned DriverDataType members. S7-004: inject ILogger<S7Driver> (optional, defaults to NullLogger); add structured log calls for connect success/failure, probe Running/Stopped transitions, and swallowed poll-loop exceptions, so operators have an event trail via Serilog. S7-008: restructure WriteAsync catch ladder to mirror ReadAsync — OperationCanceledException re-throws, NotSupportedException → BadNotSupported, PUT/GET-disabled PlcException → BadNotSupported/Faulted, genuine PlcException → BadDeviceFailure/Degraded, all others → BadCommunicationError/Degraded. Health is now updated on every write failure. Also factor ReadOneAsync reinterpret into internal ReinterpretRawValue and WriteOneAsync boxing into internal BoxValueForWrite for testability (Driver.S7-014). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
716 lines
35 KiB
C#
716 lines
35 KiB
C#
using System.Collections.Concurrent;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using S7.Net;
|
|
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(S7DriverOptions options, string driverInstanceId, ILogger<S7Driver>? logger = null)
|
|
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IDisposable, IAsyncDisposable
|
|
{
|
|
private readonly ILogger<S7Driver> _logger = logger ?? NullLogger<S7Driver>.Instance;
|
|
// ---- 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.
|
|
/// See code-review finding Driver.S7-006.
|
|
/// </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);
|
|
|
|
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
|
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 = 0x80550000u;
|
|
|
|
private readonly Dictionary<string, S7TagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly Dictionary<string, S7ParsedAddress> _parsedByName = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
/// <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> — see code-review finding Driver.S7-011. 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 = 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.Net PLC connection. Null until <see cref="InitializeAsync"/> returns; null
|
|
/// after <see cref="ShutdownAsync"/>. Read-only outside this class; PR 64's Read/Write
|
|
/// will take the <see cref="_gate"/> before touching it.
|
|
/// </summary>
|
|
internal Plc? Plc { get; private set; }
|
|
|
|
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
|
private bool _disposed;
|
|
|
|
public string DriverInstanceId => driverInstanceId;
|
|
public string DriverType => "S7";
|
|
|
|
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
|
{
|
|
_health = new DriverHealth(DriverState.Initializing, null, null);
|
|
try
|
|
{
|
|
// Re-parse the supplied DriverConfig JSON so a config change delivered through the
|
|
// IDriver contract is honoured (Driver.S7-011). An empty / placeholder document
|
|
// (e.g. the "{}" some unit tests pass) keeps the constructor-supplied options.
|
|
if (HasConfigBody(driverConfigJson))
|
|
_options = S7DriverFactoryExtensions.ParseOptions(driverInstanceId, driverConfigJson);
|
|
|
|
// Timer (T{n}) / Counter (C{n}) addresses parse cleanly but the read path has no
|
|
// S7DataType for them and no decode case — reject them here so a config typo
|
|
// fails fast at init instead of throwing a misleading type-mismatch on every
|
|
// read (Driver.S7-001). Drop this guard when Timer/Counter reads are wired through.
|
|
RejectUnsupportedTagAddresses();
|
|
|
|
var plc = new Plc(_options.CpuType, _options.Host, _options.Port, _options.Rack, _options.Slot);
|
|
// S7netplus writes timeouts into the underlying TcpClient via Plc.WriteTimeout /
|
|
// Plc.ReadTimeout (milliseconds). Set before OpenAsync so the handshake itself
|
|
// honours the bound.
|
|
plc.WriteTimeout = (int)_options.Timeout.TotalMilliseconds;
|
|
plc.ReadTimeout = (int)_options.Timeout.TotalMilliseconds;
|
|
|
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
cts.CancelAfter(_options.Timeout);
|
|
await plc.OpenAsync(cts.Token).ConfigureAwait(false);
|
|
|
|
Plc = plc;
|
|
|
|
// 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();
|
|
foreach (var t in _options.Tags)
|
|
{
|
|
var parsed = S7AddressParser.Parse(t.Address); // throws FormatException
|
|
_tagsByName[t.Name] = t;
|
|
_parsedByName[t.Name] = parsed;
|
|
}
|
|
|
|
_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 (Driver.S7-006). 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. S7netplus's Close() is best-effort and idempotent.
|
|
try { Plc?.Close(); } catch { }
|
|
Plc = null;
|
|
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
|
|
_logger.LogError(ex, "S7Driver connect failed. Driver={DriverInstanceId} Host={Host}", driverInstanceId, _options.Host);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
|
{
|
|
// InitializeAsync re-parses driverConfigJson, so a config change delivered here is
|
|
// applied in place rather than silently discarded (Driver.S7-011).
|
|
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
|
|
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
|
{
|
|
// Signal cancellation to the probe + poll loops first, collect their Task handles,
|
|
// then await all of them with a bounded timeout BEFORE disposing the shared semaphore
|
|
// and CTS objects. Without the drain, a loop iteration mid-_gate would call Release()
|
|
// on (or WaitAsync against) a disposed semaphore — see code-review finding Driver.S7-006.
|
|
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();
|
|
|
|
try { Plc?.Close(); } catch { /* best-effort — tearing down anyway */ }
|
|
Plc = null;
|
|
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
|
}
|
|
|
|
/// <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>
|
|
/// Rejects tag addresses the read path cannot serve. Timer (<c>T{n}</c>) and Counter
|
|
/// (<c>C{n}</c>) addresses parse cleanly via <see cref="S7AddressParser"/> but
|
|
/// <see cref="ReadOneAsync"/> has no decode case for them and <see cref="S7DataType"/>
|
|
/// has no Timer/Counter member — left unguarded they fail fast init's promise and throw
|
|
/// a misleading type-mismatch on every read instead (code-review finding Driver.S7-001).
|
|
/// </summary>
|
|
private void RejectUnsupportedTagAddresses()
|
|
{
|
|
foreach (var t in _options.Tags)
|
|
{
|
|
if (S7AddressParser.TryParse(t.Address, out var parsed)
|
|
&& parsed.Area is S7Area.Timer or S7Area.Counter)
|
|
{
|
|
throw new NotSupportedException(
|
|
$"S7 tag '{t.Name}' uses a {parsed.Area} address ('{t.Address}'); " +
|
|
"Timer/Counter tags are not yet supported by the S7 driver. " +
|
|
"Remove the tag or use a DB/M/I/Q address until Timer/Counter reads are wired through.");
|
|
}
|
|
}
|
|
}
|
|
|
|
public DriverHealth GetHealth() => _health;
|
|
|
|
/// <summary>
|
|
/// Approximate memory footprint. The Plc instance + one 240-960 byte PDU buffer is
|
|
/// under 4 KB; return 0 because the <see cref="IDriver"/> contract asks for a
|
|
/// driver-attributable growth number and S7.Net doesn't expose one.
|
|
/// </summary>
|
|
public long GetMemoryFootprint() => 0;
|
|
|
|
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
|
|
|
// ---- IReadable ----
|
|
|
|
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
|
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
|
{
|
|
var plc = RequirePlc();
|
|
var now = DateTime.UtcNow;
|
|
var results = new DataValueSnapshot[fullReferences.Count];
|
|
|
|
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
for (var i = 0; i < fullReferences.Count; i++)
|
|
{
|
|
var name = fullReferences[i];
|
|
if (!_tagsByName.TryGetValue(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 and
|
|
// code-review finding Driver.S7-007.
|
|
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.
|
|
results[i] = new DataValueSnapshot(null, StatusBadDeviceFailure, null, now);
|
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, pex.Message);
|
|
}
|
|
catch (Exception 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(global::S7.Net.Plc plc, S7TagDefinition tag, CancellationToken ct)
|
|
{
|
|
var addr = _parsedByName[tag.Name];
|
|
// 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>
|
|
/// 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 (Driver.S7-014).
|
|
/// </summary>
|
|
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),
|
|
|
|
(S7DataType.Int64, _, _) => throw new NotSupportedException("S7 Int64 reads land in a follow-up PR"),
|
|
(S7DataType.UInt64, _, _) => throw new NotSupportedException("S7 UInt64 reads land in a follow-up PR"),
|
|
(S7DataType.Float64, _, _) => throw new NotSupportedException("S7 Float64 (LReal) reads land in a follow-up PR"),
|
|
(S7DataType.String, _, _) => throw new NotSupportedException("S7 STRING reads land in a follow-up PR"),
|
|
(S7DataType.DateTime, _, _) => throw new NotSupportedException("S7 DateTime reads land in a follow-up PR"),
|
|
|
|
_ => throw new System.IO.InvalidDataException(
|
|
$"S7 Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " +
|
|
$"parsed as Size={addr.Size}; S7.Net returned {raw.GetType().Name}"),
|
|
};
|
|
|
|
// ---- IWritable ----
|
|
|
|
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
|
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
|
{
|
|
var plc = RequirePlc();
|
|
var results = new WriteResult[writes.Count];
|
|
|
|
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
for (var i = 0; i < writes.Count; i++)
|
|
{
|
|
var w = writes[i];
|
|
if (!_tagsByName.TryGetValue(w.FullReference, out var tag))
|
|
{
|
|
results[i] = new WriteResult(StatusBadNodeIdUnknown);
|
|
continue;
|
|
}
|
|
if (!tag.Writable)
|
|
{
|
|
results[i] = new WriteResult(StatusBadNotWritable);
|
|
continue;
|
|
}
|
|
try
|
|
{
|
|
await WriteOneAsync(plc, tag, w.Value, cancellationToken).ConfigureAwait(false);
|
|
results[i] = new WriteResult(0u);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Driver.S7-008: 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 (Driver.S7-007). 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 — Driver.S7-008).
|
|
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 — Driver.S7-008.
|
|
results[i] = new WriteResult(StatusBadCommunicationError);
|
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
|
}
|
|
}
|
|
}
|
|
finally { _gate.Release(); }
|
|
return results;
|
|
}
|
|
|
|
private async Task WriteOneAsync(global::S7.Net.Plc plc, S7TagDefinition tag, object? value, CancellationToken ct)
|
|
{
|
|
// 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 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 (Driver.S7-014).
|
|
/// </summary>
|
|
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)),
|
|
|
|
S7DataType.Int64 => throw new NotSupportedException("S7 Int64 writes land in a follow-up PR"),
|
|
S7DataType.UInt64 => throw new NotSupportedException("S7 UInt64 writes land in a follow-up PR"),
|
|
S7DataType.Float64 => throw new NotSupportedException("S7 Float64 (LReal) writes land in a follow-up PR"),
|
|
S7DataType.String => throw new NotSupportedException("S7 STRING writes land in a follow-up PR"),
|
|
S7DataType.DateTime => throw new NotSupportedException("S7 DateTime writes land in a follow-up PR"),
|
|
_ => throw new InvalidOperationException($"Unknown S7DataType {dataType}"),
|
|
};
|
|
|
|
private Plc RequirePlc() =>
|
|
Plc ?? throw new InvalidOperationException("S7Driver not initialized");
|
|
|
|
/// <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 — see code-review finding Driver.S7-007.
|
|
/// </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 ----
|
|
|
|
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(builder);
|
|
var folder = builder.Folder("S7", "S7");
|
|
foreach (var t in _options.Tags)
|
|
{
|
|
folder.Variable(t.Name, t.Name, new DriverAttributeInfo(
|
|
FullName: t.Name,
|
|
DriverDataType: MapDataType(t.DataType),
|
|
IsArray: false,
|
|
ArrayDim: 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
|
|
// Driver.S7-002: UInt32 values > int.MaxValue (2^31-1) wrap negative when surfaced as
|
|
// Int32. This is lossy in the same way as the Int64/UInt64 mapping below — both are
|
|
// acknowledged limitations until unsigned DriverDataType members ship.
|
|
S7DataType.Int16 or S7DataType.UInt16 or S7DataType.Int32 or S7DataType.UInt32 => DriverDataType.Int32,
|
|
S7DataType.Int64 or S7DataType.UInt64 => DriverDataType.Int32, // lossy for values > 2^31-1; tracked for follow-up
|
|
S7DataType.Float32 => DriverDataType.Float32,
|
|
S7DataType.Float64 => DriverDataType.Float64,
|
|
S7DataType.String => DriverDataType.String,
|
|
S7DataType.DateTime => DriverDataType.DateTime,
|
|
_ => DriverDataType.Int32,
|
|
};
|
|
|
|
// ---- ISubscribable (polling overlay) ----
|
|
|
|
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 (Driver.S7-006).
|
|
state.PollTask = Task.Run(() => PollLoopAsync(state, cts.Token), CancellationToken.None);
|
|
return Task.FromResult<ISubscriptionHandle>(handle);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
private async Task PollLoopAsync(SubscriptionState state, CancellationToken ct)
|
|
{
|
|
// Initial-data push per OPC UA Part 4 convention.
|
|
try { await PollOnceAsync(state, forceRaise: true, ct).ConfigureAwait(false); }
|
|
catch (OperationCanceledException) { return; }
|
|
catch (Exception ex)
|
|
{
|
|
// First-read error — polling continues; log so the operator has an event trail.
|
|
_logger.LogWarning(ex, "S7 poll initial-read failed. Driver={DriverInstanceId}", driverInstanceId);
|
|
}
|
|
|
|
while (!ct.IsCancellationRequested)
|
|
{
|
|
try { await Task.Delay(state.Interval, ct).ConfigureAwait(false); }
|
|
catch (OperationCanceledException) { return; }
|
|
|
|
try { await PollOnceAsync(state, forceRaise: false, ct).ConfigureAwait(false); }
|
|
catch (OperationCanceledException) { return; }
|
|
catch (Exception ex)
|
|
{
|
|
// Transient polling error — loop continues; log so the operator has an event trail.
|
|
_logger.LogWarning(ex, "S7 poll tick failed. Driver={DriverInstanceId}", driverInstanceId);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task PollOnceAsync(SubscriptionState state, bool forceRaise, CancellationToken ct)
|
|
{
|
|
var snapshots = await ReadAsync(state.TagReferences, ct).ConfigureAwait(false);
|
|
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)
|
|
{
|
|
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 — see code-review finding Driver.S7-006.
|
|
/// </summary>
|
|
public Task PollTask { get; set; } = Task.CompletedTask;
|
|
}
|
|
|
|
private sealed record S7SubscriptionHandle(long Id) : ISubscriptionHandle
|
|
{
|
|
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}";
|
|
|
|
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);
|
|
|
|
var plc = Plc;
|
|
if (plc is null) throw new InvalidOperationException("Plc dropped during probe");
|
|
|
|
await _gate.WaitAsync(probeCts.Token).ConfigureAwait(false);
|
|
try
|
|
{
|
|
_ = 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));
|
|
}
|
|
|
|
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
try { await ShutdownAsync(CancellationToken.None).ConfigureAwait(false); }
|
|
catch { /* disposal is best-effort */ }
|
|
_gate.Dispose();
|
|
}
|
|
}
|