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;
///
/// Siemens S7 native driver — speaks S7comm over ISO-on-TCP (port 102) via the S7netplus
/// library. First implementation of for an in-process .NET Standard
/// PLC protocol that is NOT Modbus, validating that the v2 driver-capability interfaces
/// generalize beyond Modbus + Galaxy.
///
///
///
/// PR 62 ships the scaffold: only (Initialize / Reinitialize /
/// Shutdown / GetHealth). , ,
/// , ,
/// land in PRs 63-65 once the address parser (PR 63) is in place.
///
///
/// Single-connection policy: S7netplus documented pattern is one
/// Plc instance per PLC, serialized with a .
/// 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.
///
///
public sealed class S7Driver(S7DriverOptions options, string driverInstanceId, ILogger? logger = null)
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IDisposable, IAsyncDisposable
{
private readonly ILogger _logger = logger ?? NullLogger.Instance;
// ---- ISubscribable + IHostConnectivityProbe state ----
private readonly ConcurrentDictionary _subscriptions = new();
private long _nextSubscriptionId;
private readonly object _probeLock = new();
private HostState _hostState = HostState.Unknown;
private DateTime _hostStateChangedUtc = DateTime.UtcNow;
private CancellationTokenSource? _probeCts;
///
/// Handle to the in-flight probe loop. Tracked (rather than fire-and-forget) so
/// can await it after cancelling — otherwise a probe
/// iteration still inside the would race a disposed semaphore.
/// See code-review finding Driver.S7-006.
///
private Task? _probeTask;
///
/// Bounded grace window waits for the probe + poll loops to
/// observe cancellation and exit before it disposes the shared semaphore / CTS objects.
///
private static readonly TimeSpan DrainTimeout = TimeSpan.FromSeconds(5);
public event EventHandler? OnDataChange;
public event EventHandler? OnHostStatusChanged;
/// OPC UA StatusCode used when the tag name isn't in the driver's tag map.
private const uint StatusBadNodeIdUnknown = 0x80340000u;
/// OPC UA StatusCode used when the tag's data type isn't implemented yet.
private const uint StatusBadNotSupported = 0x803D0000u;
/// OPC UA StatusCode used when the tag is declared read-only.
private const uint StatusBadNotWritable = 0x803B0000u;
/// OPC UA StatusCode used when write fails validation (e.g. out-of-range value).
private const uint StatusBadInternalError = 0x80020000u;
/// OPC UA StatusCode used for socket / timeout / protocol-layer faults.
private const uint StatusBadCommunicationError = 0x80050000u;
/// OPC UA StatusCode used for a genuine device fault (CPU error, hardware fault).
private const uint StatusBadDeviceFailure = 0x80550000u;
private readonly Dictionary _tagsByName = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary _parsedByName = new(StringComparer.OrdinalIgnoreCase);
///
/// Active driver configuration. Seeded from the constructor argument, then replaced by
/// whatever / parse out of
/// the supplied driverConfigJson — 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 "{}" some unit tests pass).
///
private S7DriverOptions _options = options;
private readonly SemaphoreSlim _gate = new(1, 1);
///
/// 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.
///
internal SemaphoreSlim Gate => _gate;
///
/// Active S7.Net PLC connection. Null until returns; null
/// after . Read-only outside this class; PR 64's Read/Write
/// will take the before touching it.
///
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();
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);
}
///
/// True when carries a real config body. The
/// bootstrapper always passes a populated document; some unit tests pass "{}" or
/// an empty string to exercise lifecycle shape without a config — those keep the
/// constructor-supplied .
///
private static bool HasConfigBody(string? driverConfigJson)
{
if (string.IsNullOrWhiteSpace(driverConfigJson)) return false;
var trimmed = driverConfigJson.Trim();
return trimmed is not "{}" and not "[]";
}
///
/// Rejects tag addresses the read path cannot serve. Timer (T{n}) and Counter
/// (C{n}) addresses parse cleanly via but
/// has no decode case for them and
/// 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).
///
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;
///
/// Approximate memory footprint. The Plc instance + one 240-960 byte PDU buffer is
/// under 4 KB; return 0 because the contract asks for a
/// driver-attributable growth number and S7.Net doesn't expose one.
///
public long GetMemoryFootprint() => 0;
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
// ---- IReadable ----
public async Task> ReadAsync(
IReadOnlyList 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