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;
///
/// 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
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IDisposable, IAsyncDisposable
{
private readonly string _driverInstanceId;
private readonly ILogger _logger;
private readonly IS7PlcFactory _plcFactory;
/// Initializes a new instance of the class.
/// Driver configuration (the constructor-supplied fallback used when
/// Initialize/Reinitialize receive an empty config body).
/// Unique driver instance identifier.
/// Optional S7 connection factory; defaults to
/// (real S7.Net). Tests inject a fake to exercise the reconnect path without a live PLC.
/// Optional logger; a null logger is used when not supplied.
public S7Driver(S7DriverOptions options, string driverInstanceId,
IS7PlcFactory? plcFactory = null, ILogger? logger = null)
{
_options = options;
_driverInstanceId = driverInstanceId;
_plcFactory = plcFactory ?? new S7PlcFactory();
_logger = logger ?? NullLogger.Instance;
_resolver = new EquipmentTagRefResolver(
r => _tagsByName.TryGetValue(r, out var t) ? t : null,
r => S7EquipmentTagParser.TryParse(r, out var d) ? d : null);
// CONV-1: the polling overlay runs on the shared PollGroupEngine (structural array diff +
// drain-before-dispose + capped-exponential failure backoff + caller-token-filtered OCE),
// retiring the bespoke S7 poll fork. minInterval 100 ms matches the S7 scan-mailbox floor;
// backoffCap 30 s matches the retired fork's PollBackoffCap.
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)),
minInterval: TimeSpan.FromMilliseconds(100),
onError: HandlePollError,
backoffCap: TimeSpan.FromSeconds(30));
}
// ---- ISubscribable + IHostConnectivityProbe state ----
private readonly PollGroupEngine _poll;
private readonly object _probeLock = new();
private HostState _hostState = HostState.Unknown;
private DateTime _hostStateChangedUtc = DateTime.UtcNow;
private CancellationTokenSource? _probeCts;
///
/// 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.
///
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);
/// Occurs when a subscribed tag value or status code changes.
public event EventHandler? OnDataChange;
/// Occurs when host connectivity status changes.
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 = 0x808B0000u;
private readonly Dictionary _tagsByName = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary _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 _resolver;
///
/// Active driver configuration. Seeded from the constructor argument, then replaced by
/// whatever / parse out of
/// the supplied driverConfigJson. 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;
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 PLC connection. Null until returns; null
/// after . Also (re)opened lazily by
/// after a transient drop. Read-only outside this class;
/// Read/Write/probe take the before touching it.
///
internal IS7Plc? Plc { get; private set; }
///
/// True once has parsed the tag table + opened the first
/// connection, false after . Distinct from Plc != null
/// because the reconnect path can leave transiently null / dead while the
/// driver is still initialized — a data call then repairs the connection rather than
/// throwing "not initialized". Guarded by .
///
private bool _initialized;
///
/// Set when a connection-fatal fault (socket drop / )
/// is observed on a read/write, so the next disposes the
/// dead handle and re-opens. Kept as a flag (rather than nulling inline) so
/// the dead handle is disposed exactly once, in . Guarded
/// by .
///
private bool _plcDead;
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. 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;
}
}
///
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);
}
///
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
// Signal cancellation to the probe + poll loops first, collect their Task handles,
// then await all of them with a bounded timeout BEFORE disposing the shared semaphore
// and CTS objects. Without the drain, a loop iteration mid-_gate would call Release()
// on (or WaitAsync against) a disposed semaphore.
// Drain the polling overlay first — the engine cancels each loop and awaits it before
// disposing its CTS (drain-before-dispose, STAB-6-S7), so no poll iteration touches _gate
// after this returns.
await _poll.DisposeAsync().ConfigureAwait(false);
var probeCts = _probeCts;
var probeTask = _probeTask;
try { probeCts?.Cancel(); } catch { }
if (probeTask is not null)
{
try
{
await probeTask.WaitAsync(DrainTimeout, CancellationToken.None).ConfigureAwait(false);
}
catch (TimeoutException) { /* a wedged probe loop — proceed; better than leaking the teardown */ }
catch { /* loop faults are already surfaced via health; teardown continues */ }
}
// The probe loop has now observed cancellation and released _gate — safe to dispose the CTS.
probeCts?.Dispose();
_probeCts = null;
_probeTask = null;
_initialized = false;
_plcDead = false;
try { Plc?.Dispose(); } catch { /* best-effort — tearing down anyway */ }
Plc = null;
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
}
///
public DriverHealth GetHealth() => _health;
///
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
///
/// 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 "[]";
}
///
/// S7 data types addressed by START BYTE (the B suffix) rather than by an
/// access-width suffix. Phase 4d decodes these from a contiguous byte buffer
/// (DB{n}.DBB{offset} / MB / IB / QB{offset}), so a wide tag
/// authored against a non-byte suffix (DBW/DBD/…) is a config error — see
/// .
///
private static readonly HashSet ByteAnchoredDataTypes = new()
{
S7DataType.Int64,
S7DataType.UInt64,
S7DataType.Float64,
S7DataType.String,
S7DataType.DateTime,
};
///
/// Combined init-time config guard (Phase 4d, supersedes the old Timer/Counter-only
/// RejectUnsupportedTagAddresses). Timer/Counter tags and the wide/structured
/// types (Int64/UInt64/Float64/String/DateTime) are now supported — 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:
///
/// (a) a wide/structured type declared as an array — wide-type arrays are out of scope this phase.
/// (b) a wide/structured type on a DB/M/I/Q area whose address isn't byte-anchored (DBB/MB/IB/QB).
/// (c) a Timer tag not typed Float64, or a Counter tag not typed Int32.
///
/// Addresses that don't parse are left to the existing
/// pass in so the parse error isn't double-handled.
///
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.");
}
}
}
///
/// Rejects tags configured with an still on the
/// list — types /
/// throw for, which
/// would otherwise create live OPC UA nodes via that return
/// BadNotSupported on every access.
/// The set is now empty (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.
///
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.");
}
}
}
///
/// S7DataType members that the read/write helpers throw NotSupportedException for.
/// Now empty — 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
/// ) as the single grep target / future seam should a
/// data type ever need re-gating at init.
///
private static readonly HashSet UnimplementedDataTypes = new();
///
// 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 ----
///
public async Task> ReadAsync(
IReadOnlyList 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 (OperationCanceledException)
{
// Cancellation observed mid-PDU abandons a half-read ISO-on-TCP response on the
// single gated stream — the handle must be reopened, not reused (STAB-15a). Mark
// dead (we hold _gate) and propagate: the caller cancelled, nobody consumes the
// batch. Marking is unconditional on OCE (not routed through the type classifier):
// whether bytes moved is unknowable, and a false positive costs one cheap reopen
// versus permanent desync for a false negative.
_plcDead = true;
throw;
}
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