STAB-21 ConnectionBackoff in EnsureConnectedAsync; STAB-22 write-pass accumulator with a write-side Faulted that a clean read does not erase; _health on the Volatile idiom; S7-016 contiguous array write (Bool arrays stay refused). Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -115,6 +115,8 @@ public sealed class S7Driver
|
||||
private const uint StatusBadNodeIdUnknown = 0x80340000u;
|
||||
/// <summary>OPC UA StatusCode used when the tag's data type isn't implemented yet.</summary>
|
||||
private const uint StatusBadNotSupported = 0x803D0000u;
|
||||
/// <summary>OPC UA StatusCode used when a written value has the wrong shape / length / element type.</summary>
|
||||
private const uint StatusBadTypeMismatch = 0x80740000u;
|
||||
/// <summary>OPC UA StatusCode used when 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>
|
||||
@@ -179,6 +181,38 @@ public sealed class S7Driver
|
||||
private bool _plcDead;
|
||||
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
/// <summary>
|
||||
/// The health snapshot, accessed with volatile semantics: it is written from the gate-holding
|
||||
/// read/write passes, the poll engine's error sink and the lifecycle methods, and read lock-free
|
||||
/// by <see cref="GetHealth"/> (the health tick) — the same Volatile idiom <c>ModbusDriver</c> uses.
|
||||
/// <see cref="DriverHealth"/> is an immutable record, so a reference swap is a complete update.
|
||||
/// </summary>
|
||||
private DriverHealth Health
|
||||
{
|
||||
get => Volatile.Read(ref _health);
|
||||
set => Volatile.Write(ref _health, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-device connect-attempt throttle (STAB-21): a dead PLC costs one connect attempt per
|
||||
/// backoff window (1 s doubling to 30 s) instead of one per data call / poll tick / probe tick.
|
||||
/// Not internally synchronized — only touched from <see cref="EnsureConnectedAsync"/> (under
|
||||
/// <see cref="_gate"/>) and from init/shutdown, which run with every loop drained.
|
||||
/// </summary>
|
||||
private readonly ConnectionBackoff _connectBackoff =
|
||||
new(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(30));
|
||||
|
||||
/// <summary>Clock the connect backoff is measured on. Internal so tests step it deterministically.</summary>
|
||||
internal Func<DateTime> BackoffClock { get; set; } = static () => DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// The sticky write-side config fault (access denied / PUT-GET disabled), or null. A read
|
||||
/// pass that finds everything readable must NOT erase it (reads can succeed while writes are
|
||||
/// protected); it is cleared by the next write pass that writes something successfully.
|
||||
/// Guarded by <see cref="_gate"/>.
|
||||
/// </summary>
|
||||
private (DriverState State, string Message)? _writeFault;
|
||||
private bool _disposed;
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -189,7 +223,7 @@ public sealed class S7Driver
|
||||
/// <inheritdoc />
|
||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
Health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
try
|
||||
{
|
||||
// Re-parse the supplied DriverConfig JSON so a config change delivered through the
|
||||
@@ -263,6 +297,7 @@ public sealed class S7Driver
|
||||
|
||||
Plc = plc;
|
||||
_plcDead = false;
|
||||
_connectBackoff.RecordSuccess();
|
||||
|
||||
// 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
|
||||
@@ -274,7 +309,7 @@ public sealed class S7Driver
|
||||
}
|
||||
|
||||
_initialized = true;
|
||||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
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, _tagsByRawPath.Count);
|
||||
|
||||
@@ -299,7 +334,7 @@ public sealed class S7Driver
|
||||
Plc = null;
|
||||
_initialized = false;
|
||||
_plcDead = false;
|
||||
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
|
||||
Health = new DriverHealth(DriverState.Faulted, null, ex.Message);
|
||||
_logger.LogError(ex, "S7Driver connect failed. Driver={DriverInstanceId} Host={Host}", _driverInstanceId, _options.Host);
|
||||
throw;
|
||||
}
|
||||
@@ -346,13 +381,15 @@ public sealed class S7Driver
|
||||
|
||||
_initialized = false;
|
||||
_plcDead = false;
|
||||
_writeFault = null;
|
||||
_connectBackoff.RecordSuccess();
|
||||
try { Plc?.Dispose(); } catch { /* best-effort — tearing down anyway */ }
|
||||
Plc = null;
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
Health = new DriverHealth(DriverState.Unknown, Health.LastSuccessfulRead, null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public DriverHealth GetHealth() => _health;
|
||||
public DriverHealth GetHealth() => Health;
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="driverConfigJson"/> carries a real config body. The
|
||||
@@ -453,19 +490,18 @@ public sealed class S7Driver
|
||||
"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)
|
||||
// (e) Bool array declared Writable — element writes are byte-granular (one byte per
|
||||
// element, mirroring the read), so a write would overwrite the 7 neighbouring bits of
|
||||
// every byte it touches; a safe bit-packed write needs read-modify-write and is not
|
||||
// implemented. Every other supported element type (Byte/Int16/UInt16/Int32/UInt32/
|
||||
// Float32) writes as ONE contiguous block (EncodeArrayBlock) — S7-016. Wide-type arrays
|
||||
// are already rejected above (guard-a).
|
||||
if (t.ArrayCount is >= 1 && t.Writable && t.DataType == S7DataType.Bool)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
$"S7 tag '{t.Name}' is a {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.");
|
||||
$"S7 tag '{t.Name}' is a Bool array (ArrayCount={t.ArrayCount}) declared Writable — " +
|
||||
"Bool array writes are not supported (a byte-granular write would clobber the neighbouring " +
|
||||
"bits). Set Writable=false, or write the bits as individual scalar Bool tags.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -567,7 +603,7 @@ public sealed class S7Driver
|
||||
{
|
||||
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);
|
||||
Health = new DriverHealth(DriverState.Degraded, Health.LastSuccessfulRead, ex.Message);
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -683,12 +719,23 @@ public sealed class S7Driver
|
||||
//
|
||||
// LastSuccessfulRead advances whenever ANYTHING read, even into a Degraded/Faulted
|
||||
// state — see ReadPassOutcome for why that is load-bearing.
|
||||
//
|
||||
// A sticky write-side Faulted (PUT/GET denied) outranks a read pass that found less
|
||||
// wrong: reads can succeed against a CPU whose writes are protected, and a clean read
|
||||
// must not erase the config alert. The write pass clears it on its next success.
|
||||
if (outcome.HasObservations)
|
||||
{
|
||||
_health = new DriverHealth(
|
||||
outcome.WorstFailure,
|
||||
outcome.AnySuccess ? now : _health.LastSuccessfulRead,
|
||||
outcome.WorstFailure == DriverState.Healthy ? null : outcome.WorstError);
|
||||
var state = outcome.WorstFailure;
|
||||
var error = state == DriverState.Healthy ? null : outcome.WorstError;
|
||||
if (_writeFault is { } wf && ReadPassOutcome.Rank(wf.State) > ReadPassOutcome.Rank(state))
|
||||
{
|
||||
state = wf.State;
|
||||
error = wf.Message;
|
||||
}
|
||||
Health = new DriverHealth(
|
||||
state,
|
||||
outcome.AnySuccess ? now : Health.LastSuccessfulRead,
|
||||
error);
|
||||
}
|
||||
|
||||
_gate.Release();
|
||||
@@ -698,7 +745,7 @@ public sealed class S7Driver
|
||||
|
||||
/// <summary>
|
||||
/// The outcomes ONE <see cref="ReadAsync"/> pass observed, accumulated so the pass writes
|
||||
/// <see cref="_health"/> exactly once — <b>worst state wins</b>.
|
||||
/// <see cref="Health"/> exactly once — <b>worst state wins</b>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -780,7 +827,7 @@ public sealed class S7Driver
|
||||
/// <summary>Severity order the pass ranks by: Healthy (none) < Degraded < Faulted.</summary>
|
||||
/// <param name="state">The state to rank.</param>
|
||||
/// <returns>Its severity, higher being worse.</returns>
|
||||
private static int Rank(DriverState state) => state switch
|
||||
internal static int Rank(DriverState state) => state switch
|
||||
{
|
||||
DriverState.Faulted => 2,
|
||||
DriverState.Degraded => 1,
|
||||
@@ -1129,7 +1176,7 @@ public sealed class S7Driver
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The health state is recorded, not applied.</b> It goes into
|
||||
/// <paramref name="outcome"/> so the pass can write <see cref="_health"/> once at the
|
||||
/// <paramref name="outcome"/> so the pass can write <see cref="Health"/> once at the
|
||||
/// end, worst-state-wins — writing it here is what let a later good tag erase this
|
||||
/// failure. The <c>_plcDead</c> / <see cref="MarkConnectionDeadIfFatal"/> side effects
|
||||
/// stay IMMEDIATE: they govern whether the very next read reopens the handle, which
|
||||
@@ -1691,6 +1738,8 @@ public sealed class S7Driver
|
||||
ArgumentNullException.ThrowIfNull(writes);
|
||||
RequireInitialized();
|
||||
var results = new WriteResult[writes.Count];
|
||||
// Health is decided ONCE, at the end of the batch, worst-state-wins — see WritePassOutcome.
|
||||
var outcome = new WritePassOutcome();
|
||||
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
@@ -1709,7 +1758,7 @@ public sealed class S7Driver
|
||||
{
|
||||
for (var i = 0; i < writes.Count; i++)
|
||||
results[i] = new WriteResult(StatusBadCommunicationError);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
outcome.RecordFailure(DriverState.Degraded, ex.Message);
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -1746,6 +1795,7 @@ public sealed class S7Driver
|
||||
{
|
||||
await WriteOneAsync(plc, tag, w.Value, cancellationToken).ConfigureAwait(false);
|
||||
results[i] = new WriteResult(0u);
|
||||
outcome.RecordSuccess();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -1761,13 +1811,18 @@ public sealed class S7Driver
|
||||
{
|
||||
results[i] = new WriteResult(StatusBadNotSupported);
|
||||
}
|
||||
catch (S7WriteValueException)
|
||||
{
|
||||
// Client-supplied value of the wrong shape — not a device or comms fault.
|
||||
results[i] = new WriteResult(StatusBadTypeMismatch);
|
||||
}
|
||||
catch (PlcException pex) when (IsAccessDenied(pex))
|
||||
{
|
||||
// PUT/GET-disabled / access-protection on write — same permanent
|
||||
// configuration fault as on read. BadNotSupported +
|
||||
// a config-alert health state, not a transient device failure.
|
||||
results[i] = new WriteResult(StatusBadNotSupported);
|
||||
_health = new DriverHealth(DriverState.Faulted, _health.LastSuccessfulRead,
|
||||
outcome.RecordFailure(DriverState.Faulted,
|
||||
"S7 access denied — enable PUT/GET communication in TIA Portal " +
|
||||
$"(Protection & Security) for this CPU. PLC reported: {pex.Message}");
|
||||
}
|
||||
@@ -1779,7 +1834,7 @@ public sealed class S7Driver
|
||||
// dead so the next call reopens.
|
||||
MarkConnectionDeadIfFatal(pex);
|
||||
results[i] = new WriteResult(StatusBadDeviceFailure);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, pex.Message);
|
||||
outcome.RecordFailure(DriverState.Degraded, pex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1788,25 +1843,84 @@ public sealed class S7Driver
|
||||
// dead when it's a connection loss.
|
||||
MarkConnectionDeadIfFatal(ex);
|
||||
results[i] = new WriteResult(StatusBadCommunicationError);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
outcome.RecordFailure(DriverState.Degraded, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
finally
|
||||
{
|
||||
// THE single health write of the batch (a finally, so an OCE mid-batch still flushes the
|
||||
// evidence gathered so far — a pass that observed nothing writes nothing).
|
||||
ApplyWritePassHealth(outcome);
|
||||
_gate.Release();
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes one write batch's verdict (STAB-22): worst state wins over every outcome the
|
||||
/// batch recorded, so a Faulted access-denied item is not erased by a later Degraded or
|
||||
/// successful sibling. Maintains <see cref="_writeFault"/>, the sticky Faulted a read pass
|
||||
/// must not erase. Caller holds <see cref="_gate"/>.
|
||||
/// </summary>
|
||||
/// <param name="outcome">The batch's accumulated outcomes.</param>
|
||||
private void ApplyWritePassHealth(WritePassOutcome outcome)
|
||||
{
|
||||
if (!outcome.HasObservations) return;
|
||||
|
||||
var worst = outcome.WorstFailure;
|
||||
if (worst == DriverState.Faulted)
|
||||
_writeFault = (DriverState.Faulted, outcome.WorstError!);
|
||||
else if (outcome.AnySuccess)
|
||||
_writeFault = null; // a write got through — the protection fault is gone
|
||||
|
||||
if (worst == DriverState.Healthy)
|
||||
{
|
||||
// Success-only batch: no health write, except to retire a Faulted that WE (the write
|
||||
// side) were holding — a clean write is the only evidence the alert has cleared.
|
||||
if (outcome.AnySuccess && _writeFault is null && Health.State == DriverState.Faulted)
|
||||
Health = new DriverHealth(DriverState.Healthy, Health.LastSuccessfulRead, null);
|
||||
return;
|
||||
}
|
||||
|
||||
var state = worst;
|
||||
var error = outcome.WorstError;
|
||||
// A Degraded-only batch must not downgrade a still-sticky Faulted.
|
||||
if (_writeFault is { } wf && ReadPassOutcome.Rank(wf.State) > ReadPassOutcome.Rank(state))
|
||||
{
|
||||
state = wf.State;
|
||||
error = wf.Message;
|
||||
}
|
||||
Health = new DriverHealth(state, Health.LastSuccessfulRead, error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The outcomes ONE <see cref="WriteAsync"/> batch observed — the write-side sibling of
|
||||
/// <see cref="ReadPassOutcome"/> (same worst-state-wins ranking and severity-pinned message),
|
||||
/// accumulated so the batch writes <see cref="Health"/> once instead of per item.
|
||||
/// </summary>
|
||||
private sealed class WritePassOutcome
|
||||
{
|
||||
private readonly ReadPassOutcome _inner = new();
|
||||
|
||||
/// <summary>True once any item in the batch wrote successfully.</summary>
|
||||
public bool AnySuccess => _inner.AnySuccess;
|
||||
/// <summary>The worst failure recorded; Healthy means none.</summary>
|
||||
public DriverState WorstFailure => _inner.WorstFailure;
|
||||
/// <summary>The message explaining <see cref="WorstFailure"/>.</summary>
|
||||
public string? WorstError => _inner.WorstError;
|
||||
/// <summary>True once the batch observed a success or a failure.</summary>
|
||||
public bool HasObservations => _inner.HasObservations;
|
||||
/// <summary>Records one successful write.</summary>
|
||||
public void RecordSuccess() => _inner.RecordSuccess();
|
||||
/// <summary>Records one failed write.</summary>
|
||||
/// <param name="state">The state the failure would have stamped alone.</param>
|
||||
/// <param name="message">The operator-facing message.</param>
|
||||
public void RecordFailure(DriverState state, string message) => _inner.RecordFailure(state, message);
|
||||
}
|
||||
|
||||
private async Task WriteOneAsync(IS7Plc plc, S7TagDefinition tag, object? value, CancellationToken ct)
|
||||
{
|
||||
// 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: every tag pre-parses at init
|
||||
// (_parsedByRawPath), with an on-demand parse as a defensive fallback. Needed here so the
|
||||
// wide-type write can byte-address the block (the narrow path below addresses by the raw
|
||||
@@ -1815,6 +1929,17 @@ public sealed class S7Driver
|
||||
? parsed
|
||||
: S7AddressParser.Parse(tag.Address);
|
||||
|
||||
// Array path (S7-016): ONE contiguous WriteBytesAsync of count × element-bytes — the inverse
|
||||
// of ReadArrayAsync. Bool / wide-type arrays throw NotSupportedException (→ BadNotSupported);
|
||||
// a wrong-shaped value throws S7WriteValueException (→ BadTypeMismatch, no health impact).
|
||||
if (tag.ArrayCount is >= 1)
|
||||
{
|
||||
var arrayBytes = EncodeArrayBlock(tag, addr, value);
|
||||
await plc.WriteBytesAsync(ToS7NetArea(addr.Area), addr.DbNumber, addr.ByteOffset, arrayBytes, ct)
|
||||
.ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Wide/structured scalar path: encode the value to a big-endian byte block and write it
|
||||
// at the start byte via S7.Net's buffer-based WriteBytesAsync. Mirrors the read seam;
|
||||
// the narrow string-based write below stays unchanged for 1/2/4-byte types.
|
||||
@@ -1832,6 +1957,90 @@ public sealed class S7Driver
|
||||
await plc.WriteAsync(tag.Address, boxed, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure encode loop — the inverse of <see cref="DecodeArrayBlock"/>: turns the caller's array
|
||||
/// into the contiguous big-endian block for a single <c>WriteBytesAsync</c>. No network I/O.
|
||||
/// Accepts any non-string <see cref="System.Collections.IEnumerable"/> (OPC UA hands the server
|
||||
/// a typed array, but a boxed <c>object[]</c> is equally fine); each element is coerced with
|
||||
/// <c>Convert.To*</c> like the scalar path.
|
||||
/// </summary>
|
||||
/// <param name="tag">Tag definition carrying the element type and declared array length.</param>
|
||||
/// <param name="addr">Parsed address carrying the access <see cref="S7Size"/>.</param>
|
||||
/// <param name="value">The array to encode; must hold exactly <c>ArrayCount</c> elements.</param>
|
||||
/// <returns>The big-endian block, <c>count × element-bytes</c> long.</returns>
|
||||
/// <exception cref="NotSupportedException">Bool or wide-type element arrays.</exception>
|
||||
/// <exception cref="S7WriteValueException">The value is not an array, has the wrong length, or an
|
||||
/// element does not convert to the element type.</exception>
|
||||
internal static byte[] EncodeArrayBlock(S7TagDefinition tag, S7ParsedAddress addr, object? value)
|
||||
{
|
||||
var count = tag.ArrayCount!.Value;
|
||||
|
||||
switch (tag.DataType, addr.Size)
|
||||
{
|
||||
case (S7DataType.Bool, _):
|
||||
throw new NotSupportedException(
|
||||
$"S7 Bool array writes are not supported (tag '{tag.Name}'): a byte-granular write would clobber neighbouring bits.");
|
||||
case (S7DataType.Int64, _):
|
||||
case (S7DataType.UInt64, _):
|
||||
case (S7DataType.Float64, _):
|
||||
case (S7DataType.String, _):
|
||||
case (S7DataType.DateTime, _):
|
||||
throw new NotSupportedException($"S7 array writes of {tag.DataType} are not supported (tag '{tag.Name}').");
|
||||
case (S7DataType.Byte, S7Size.Byte):
|
||||
case (S7DataType.UInt16, S7Size.Word):
|
||||
case (S7DataType.Int16, S7Size.Word):
|
||||
case (S7DataType.UInt32, S7Size.DWord):
|
||||
case (S7DataType.Int32, S7Size.DWord):
|
||||
case (S7DataType.Float32, S7Size.DWord):
|
||||
break;
|
||||
default:
|
||||
throw new System.IO.InvalidDataException(
|
||||
$"S7 array Write type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address " +
|
||||
$"'{tag.Address}' parsed as Size={addr.Size}");
|
||||
}
|
||||
|
||||
if (value is string || value is not System.Collections.IEnumerable items)
|
||||
throw new S7WriteValueException($"S7 array tag '{tag.Name}' requires an array value.");
|
||||
|
||||
var elements = new List<object?>(count);
|
||||
foreach (var e in items) elements.Add(e);
|
||||
if (elements.Count != count)
|
||||
throw new S7WriteValueException(
|
||||
$"S7 array tag '{tag.Name}' has {count} elements but the write supplied {elements.Count}.");
|
||||
|
||||
var elementBytes = ElementByteSize(addr.Size);
|
||||
var block = new byte[count * elementBytes];
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var span = block.AsSpan(i * elementBytes, elementBytes);
|
||||
var element = elements[i];
|
||||
switch (tag.DataType)
|
||||
{
|
||||
case S7DataType.Byte: span[0] = Convert.ToByte(element); break;
|
||||
case S7DataType.UInt16: System.Buffers.Binary.BinaryPrimitives.WriteUInt16BigEndian(span, Convert.ToUInt16(element)); break;
|
||||
case S7DataType.Int16: System.Buffers.Binary.BinaryPrimitives.WriteInt16BigEndian(span, Convert.ToInt16(element)); break;
|
||||
case S7DataType.UInt32: System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(span, Convert.ToUInt32(element)); break;
|
||||
case S7DataType.Int32: System.Buffers.Binary.BinaryPrimitives.WriteInt32BigEndian(span, Convert.ToInt32(element)); break;
|
||||
case S7DataType.Float32: System.Buffers.Binary.BinaryPrimitives.WriteSingleBigEndian(span, Convert.ToSingle(element)); break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException)
|
||||
{
|
||||
throw new S7WriteValueException(
|
||||
$"S7 array tag '{tag.Name}': an element does not convert to {tag.DataType} ({ex.Message}).");
|
||||
}
|
||||
return block;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A caller-supplied write value the driver cannot encode (wrong shape / length / element
|
||||
/// type). A client error — maps to <c>BadTypeMismatch</c> and does not touch driver health.
|
||||
/// </summary>
|
||||
internal sealed class S7WriteValueException(string message) : Exception(message);
|
||||
|
||||
/// <summary>
|
||||
/// Writes a wide/structured scalar as ONE contiguous byte block via S7.Net's
|
||||
/// buffer-based <c>Plc.WriteBytesAsync(DataType, db, startByteAdr, byte[] value, ct)</c>.
|
||||
@@ -1893,18 +2102,15 @@ public sealed class S7Driver
|
||||
/// <see cref="_plcDead"/> and reuses the single-connection-per-PLC invariant the gate
|
||||
/// enforces. The read / write / probe paths all take the gate before calling it.
|
||||
/// <para>
|
||||
/// While the PLC is down every data call and probe tick pays a full connect attempt
|
||||
/// While the PLC is down every data call and probe tick would pay a full connect attempt
|
||||
/// bounded only by <c>_options.Timeout</c> (STAB-8). This method is the single
|
||||
/// choke-point all S7 reconnects flow through (already gate-serialized), so the
|
||||
/// fleet-wide connect-attempt throttle plugs in here. R2-09 (sub-batch B3/B4) shipped the
|
||||
/// shared <see cref="ConnectionBackoff"/> primitive
|
||||
/// (<c>src/Core/…/Core.Abstractions/ConnectionBackoff.cs</c>) — the S7 wiring is a
|
||||
/// per-driver <c>ConnectionBackoff</c> field consulted with <c>ShouldAttempt(now)</c> at
|
||||
/// the top of the slow path (fail fast + degrade inside the window) plus
|
||||
/// <c>RecordFailure</c>/<c>RecordSuccess</c> around the connect, all under <c>_gate</c>
|
||||
/// (which satisfies the primitive's caller-synchronized contract; TwinCAT/FOCAS/AbCip
|
||||
/// already wire it this way). Deliberately NOT implemented driver-locally here — owned by
|
||||
/// <c>archreview/plans/R2-01-s7-fault-hardening-plan.md</c> Finding 3.
|
||||
/// connect-attempt throttle lives here (STAB-21): the shared <see cref="ConnectionBackoff"/>
|
||||
/// primitive is consulted with <c>ShouldAttempt(now)</c> at the top of the slow path —
|
||||
/// inside the window the call fails fast with no connect attempt, which every caller
|
||||
/// already degrades on — with <c>RecordFailure</c>/<c>RecordSuccess</c> around the connect.
|
||||
/// A successful connect clears the window, so recovery is never delayed past the first
|
||||
/// attempt that finds the PLC back; the 30 s cap bounds the worst case.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="ct">Cancellation token for the (re)connect attempt.</param>
|
||||
@@ -1922,6 +2128,12 @@ public sealed class S7Driver
|
||||
}
|
||||
_plcDead = false;
|
||||
|
||||
// STAB-21: inside the backoff window fail fast WITHOUT a connect attempt. A plain
|
||||
// InvalidOperationException (not an OCE) so the read/write/probe catch ladders degrade on it.
|
||||
if (!_connectBackoff.ShouldAttempt(BackoffClock()))
|
||||
throw new InvalidOperationException(
|
||||
$"S7 reconnect to {_options.Host}:{_options.Port} suppressed — backing off after a failed connect attempt.");
|
||||
|
||||
var plc = _plcFactory.Create(
|
||||
_options.CpuType, _options.Host, _options.Port, _options.Rack, _options.Slot, _options.Timeout);
|
||||
try
|
||||
@@ -1932,6 +2144,7 @@ public sealed class S7Driver
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
_connectBackoff.RecordFailure(BackoffClock());
|
||||
// The linked CancelAfter fired — a connect TIMEOUT, not a caller cancellation.
|
||||
// Surface it as a connection FAILURE (TimeoutException) so callers route it through
|
||||
// their degrade paths: an escaping OCE reads as teardown and permanently killed the
|
||||
@@ -1941,12 +2154,16 @@ public sealed class S7Driver
|
||||
$"S7 connect to {_options.Host}:{_options.Port} timed out after " +
|
||||
$"{(int)_options.Timeout.TotalMilliseconds} ms.");
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Caller cancellation is teardown, not a dead PLC — it must not open a window.
|
||||
if (!(ex is OperationCanceledException && ct.IsCancellationRequested))
|
||||
_connectBackoff.RecordFailure(BackoffClock());
|
||||
try { plc.Dispose(); } catch { /* best-effort */ }
|
||||
throw;
|
||||
}
|
||||
|
||||
_connectBackoff.RecordSuccess();
|
||||
Plc = plc;
|
||||
_logger.LogInformation("S7Driver reconnected. Driver={DriverInstanceId} Host={Host} CPU={CpuType}",
|
||||
_driverInstanceId, _options.Host, _options.CpuType);
|
||||
@@ -2114,8 +2331,8 @@ public sealed class S7Driver
|
||||
private void HandlePollError(Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "S7 poll reader failed. Driver={DriverInstanceId}", _driverInstanceId);
|
||||
if (_health.State != DriverState.Faulted)
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
if (Health.State != DriverState.Faulted)
|
||||
Health = new DriverHealth(DriverState.Degraded, Health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
|
||||
// ---- IHostConnectivityProbe ----
|
||||
@@ -2257,8 +2474,10 @@ public sealed class S7Driver
|
||||
|
||||
_initialized = false;
|
||||
_plcDead = false;
|
||||
_writeFault = null;
|
||||
_connectBackoff.RecordSuccess();
|
||||
try { Plc?.Dispose(); } catch { /* best-effort — tearing down anyway */ }
|
||||
Plc = null;
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
Health = new DriverHealth(DriverState.Unknown, Health.LastSuccessfulRead, null);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user