diff --git a/docs/drivers/S7.md b/docs/drivers/S7.md
index 02d27885..39788f8c 100644
--- a/docs/drivers/S7.md
+++ b/docs/drivers/S7.md
@@ -88,6 +88,9 @@ After the handshake, Test Connect reads 2 bytes from `MB0`. A CPU with PUT/GET d
default) completes the handshake but denies every read, so the probe fails with an actionable "enable
PUT/GET communication" message instead of a false green tick. A CPU that merely rejects that address (no
`MB0`) still passes — the connection is fine; any other read fault fails the probe with its message.
+Both are recognised in the bare-`Exception` shape S7.Net's read path really raises. The handshake and
+test read are bounded by the config's `timeoutMs` (the driver's own per-operation timeout), capped by the
+Test Connect timeout — before R07:H7 the config value was ignored.
## Address forms
@@ -103,9 +106,18 @@ Addresses use Siemens TIA-Portal / STEP 7 Classic syntax, parsed by
| Timer | `T0` / `T15` | Timer area (read-only — see Wide types section) |
| Counter | `C0` / `C10` | Counter area (read-only — see Wide types section) |
-Parsing is strict and runs once at `InitializeAsync` so a config typo fails fast
-at load instead of surfacing as `BadInternalError` on every read. Bit offsets
-must be 0-7, byte offsets non-negative, DB numbers >= 1.
+Parsing is strict. Bit offsets must be 0-7, byte offsets non-negative, DB numbers >= 1.
+
+**Tag shape rules are judged per tag, at every gate** (`S7TagShapeRules`, called by
+`S7TagDefinitionFactory.FromTagConfig`): the AdminUI editor refuses the tag, the deploy gate
+(`Inspect`) warns, and the driver's init **skips** it (reported through `ISkippedTagReporter`; it reads
+`BadNodeIdUnknown`) while every other tag keeps working. Refused: an address that does not parse, a
+wide/structured type that is an array or is not byte-addressed (`DBB`/`MB`/`IB`/`QB`), a narrow type at the
+wrong access width (`Int32` on `DBW0`, `Bool` on `DBB0`...), a Timer not typed `Float64` or a Counter not typed
+`Int32`, a writable or array Timer/Counter, a writable `Bool` array, and a `Bool` array that does not start
+at bit 0 (a Bool array reads bit 0 of each byte, so `DB1.DBX0.3` used to silently return bit 0 of bytes
+0..n). These used to be init guards that THREW, so one typo faulted every tag on the PLC and the actor
+retried the same init every 10 s forever (R07:H3).
## Data types
@@ -277,7 +289,7 @@ AdminUI `/hosts` page (Gitea #640). See `docs/Telemetry.md` ("Driver diagnostics
| `IWritable` | `WriteAsync` → `S7.Net.Plc.WriteAsync` | Read-only tags (`Writable=false`) return `BadNotWritable` |
| `ITagDiscovery` | `DiscoverAsync` | Emits a flat `S7/` folder of the configured tags — no live browse |
| `ISubscribable` | per-tag poll loop with capped exponential backoff | S7 has no push model; floor is 100 ms (the CPU services the comms mailbox once per scan) |
-| `IHostConnectivityProbe` | periodic `S7.Net.Plc.ReadStatusAsync` (CPU-status PDU) | `host:port` host key; `Running`/`Stopped` transitions raise `OnHostStatusChanged` |
+| `IHostConnectivityProbe` | periodic `S7.Net.Plc.ReadStatusAsync` (CPU-status PDU) | `host:port` host key; `Running`/`Stopped` transitions raise `OnHostStatusChanged`. A tick that finds the gate held past the probe deadline (a long read pass) gives **no verdict** — it no longer reports `Stopped` for a connection that is busy, not lost (R07:H7) |
### Single-connection policy
@@ -302,8 +314,9 @@ Portal under *Protection & Security* for the CPU.
| Tag not in config | `BadNodeIdUnknown` | unchanged |
| Read-only tag written | `BadNotWritable` | unchanged |
| Unimplemented data type | `BadNotSupported` | unchanged |
-| PUT/GET denied | `BadNotSupported` | `Faulted` (config alert) |
-| CPU / hardware fault | `BadDeviceFailure` | `Degraded` |
+| PUT/GET denied | `BadNotSupported` | `Faulted` (config alert) — also counts as *answered*, advances `LastSuccessfulRead` (no watchdog reconnect loop) |
+| CPU refusal of the item (object does not exist, address out of range, type inconsistent, hardware fault) | `BadDeviceFailure` | unchanged — counts as *answered*, advances `LastSuccessfulRead`; one Warning per tag |
+| Other device-layer fault (`PlcException` that is neither a refusal nor connection-fatal) | `BadDeviceFailure` | `Degraded` |
| Socket / timeout | `BadCommunicationError` | `Degraded` |
| Reconnect suppressed (backoff window) | `BadCommunicationError` | `Degraded` |
| Array write of the wrong shape / length / element type | `BadTypeMismatch` | unchanged |
@@ -314,7 +327,36 @@ accumulate their outcomes and write health once (`Healthy < Degraded < Faulted`)
`Faulted` item is not erased by a later `Degraded` or successful sibling. The write-side `Faulted` is
**sticky against reads**: a clean read pass (reads often work against a write-protected CPU) leaves
`Faulted` in place and only advances `LastSuccessfulRead`; the next write that succeeds clears it. A
-transient `Degraded` write failure is still cleared by a clean read, as before.
+transient `Degraded` write failure is cleared by a clean read, or by the next write batch the CPU
+answers without a failure.
+
+**A refusal is an answer (R07:H4).** S7.Net's read paths raise the CPU's item error as a bare
+`Exception` ("Received error from PLC: Object does not exist." — live-probed on the snap7 fixture), its
+write paths as a `PlcException(WriteData)`; both are classified over the whole exception chain, and the
+access-denied text inside either (S7.Net's exact "Accessing object not allowed", and never when the chain
+also carries a transport fault) is the `Faulted` alert — which is ALSO an answer: it advances
+`LastSuccessfulRead`, so a factory-default CPU with PUT/GET disabled stays `Faulted` instead of being
+reconnected by the comms-loss watchdog every 90 s. A multi-var chunk that falls back per tag memoises the
+members refused on their own, like a refused span. A refusal is deterministic, so it neither
+degrades health nor reopens the connection, and because it proves the CPU is reachable it advances
+`LastSuccessfulRead` and restores `Healthy` after an earlier comms fault. Before this, a driver whose
+every tag was refused (a wrong DB number) sat `Degraded` with a frozen timestamp and the comms-loss
+watchdog reconnected it every 90 s against a perfectly reachable PLC.
+
+**A dead handle ends the pass's wire traffic (R07:P1).** Once a read pass or write batch marks the
+connection dead (a wire op blew its deadline on a frozen-but-established peer, a socket dropped), the
+remaining spans / multi-var chunks / tags / writes of that pass are stamped `BadCommunicationError`
+without I/O; the next call reopens. A multi-var chunk that dies on a transport loss no longer falls back
+to one doomed per-tag read per member. Without this an op that ignores its cancellation token would cost
+one full deadline per remaining item under the gate, and `Degraded` (and so the comms-loss watchdog's
+clock) only became visible after the whole pass.
+
+**A refused span falls back per member (R07:P2).** A grouped block read covers the union of its members'
+ranges, so one member the CPU refuses (running past the DB's real end, an over-declared `stringLength`)
+used to make every neighbour in the block Bad on every poll. When the CPU refuses a multi-member span the
+driver re-reads its members one at a time for that pass and remembers which tags must not be grouped —
+the members refused on their own, or every member when none was — so later passes group only the rest.
+The memo lasts until the next initialize. A span failure that is not a refusal still stamps every member.
**Connect backoff.** A dead PLC costs one connect attempt per backoff window (1 s doubling to a 30 s
cap), not one per read / poll tick / probe tick: inside the window `EnsureConnectedAsync` fails fast
@@ -359,7 +401,7 @@ normal dev (the S7 sim is on the shared Docker host but currently offline).
**Array writes (S7-016)** — a writable `Byte` / `Int16` / `UInt16` / `Int32` / `UInt32` / `Float32` array
is written as ONE contiguous `WriteBytesAsync` of `count x element-bytes` (the inverse of the read
decode), and the value must be an array of exactly `arrayLength` elements (else `BadTypeMismatch`, no
-health impact). **Writable `Bool` arrays are refused at init** (`NotSupportedException`): the array
+health impact). **Writable `Bool` arrays are refused per tag** (see the tag shape rules above): the array
read is byte-granular, so a matching write would overwrite the 7 neighbouring bits of every byte it
touches, and a safe bit-packed write needs read-modify-write. Mark Bool arrays `writable: false`. A `null` array element is rejected (`BadTypeMismatch`, nothing written)
rather than coerced to 0. An array write larger than one PDU is split by S7.Net into several writes and is
diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/AbLegacyCommandBase.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/AbLegacyCommandBase.cs
index e96b1a75..157caf2d 100644
--- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/AbLegacyCommandBase.cs
+++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/AbLegacyCommandBase.cs
@@ -32,6 +32,27 @@ public abstract class AbLegacyCommandBase : DriverCommandBase
"LogixPccc unverified).")]
public AbLegacyCliTransport Transport { get; init; } = AbLegacyCliTransport.Default;
+ ///
+ /// Runs a one-shot command's driver work so a Ctrl-C (cancellation of ) prints
+ /// Cancelled. instead of an unhandled stack trace (review 07 U7).
+ /// Any other exception — and an OCE the operator did not ask for — still propagates.
+ ///
+ /// The CLI console.
+ /// The console's Ctrl-C token.
+ /// The command body.
+ /// A task that completes when the body has finished or was cancelled.
+ internal static async ValueTask RunCancellableAsync(CliFx.Infrastructure.IConsole console, CancellationToken ct, Func body)
+ {
+ try
+ {
+ await body();
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ await console.Error.WriteLineAsync("Cancelled.");
+ }
+ }
+
/// Gets the per-operation timeout in milliseconds.
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
public int TimeoutMs { get; init; } = 5000;
diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/ProbeCommand.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/ProbeCommand.cs
index c1ae0737..f89d28d1 100644
--- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/ProbeCommand.cs
+++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/ProbeCommand.cs
@@ -43,17 +43,20 @@ public sealed class ProbeCommand : AbLegacyCommandBase
var driver = new AbLegacyDriver(options, DriverInstanceId);
try
{
- await driver.InitializeAsync("{}", ct);
- var snapshot = await driver.ReadAsync(["__probe"], ct);
- var health = driver.GetHealth();
+ await RunCancellableAsync(console, ct, async () =>
+ {
+ await driver.InitializeAsync("{}", ct);
+ var snapshot = await driver.ReadAsync(["__probe"], ct);
+ var health = driver.GetHealth();
- await console.Output.WriteLineAsync($"Gateway: {Gateway}");
- await console.Output.WriteLineAsync($"PLC type: {PlcType}");
- await console.Output.WriteLineAsync($"Health: {health.State}");
- if (health.LastError is { } err)
- await console.Output.WriteLineAsync($"Last error: {err}");
- await console.Output.WriteLineAsync();
- await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
+ await console.Output.WriteLineAsync($"Gateway: {Gateway}");
+ await console.Output.WriteLineAsync($"PLC type: {PlcType}");
+ await console.Output.WriteLineAsync($"Health: {health.State}");
+ if (health.LastError is { } err)
+ await console.Output.WriteLineAsync($"Last error: {err}");
+ await console.Output.WriteLineAsync();
+ await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
+ });
}
finally
{
diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/ReadCommand.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/ReadCommand.cs
index 48c381c5..89a1e071 100644
--- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/ReadCommand.cs
+++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/ReadCommand.cs
@@ -45,9 +45,12 @@ public sealed class ReadCommand : AbLegacyCommandBase
var driver = new AbLegacyDriver(options, DriverInstanceId);
try
{
- await driver.InitializeAsync("{}", ct);
- var snapshot = await driver.ReadAsync([tagName], ct);
- await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
+ await RunCancellableAsync(console, ct, async () =>
+ {
+ await driver.InitializeAsync("{}", ct);
+ var snapshot = await driver.ReadAsync([tagName], ct);
+ await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
+ });
}
finally
{
diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/WriteCommand.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/WriteCommand.cs
index 2a414bfc..d4c61ba4 100644
--- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/WriteCommand.cs
+++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/WriteCommand.cs
@@ -55,9 +55,12 @@ public sealed class WriteCommand : AbLegacyCommandBase
var driver = new AbLegacyDriver(options, DriverInstanceId);
try
{
- await driver.InitializeAsync("{}", ct);
- var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
- await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(Address, results[0]));
+ await RunCancellableAsync(console, ct, async () =>
+ {
+ await driver.InitializeAsync("{}", ct);
+ var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
+ await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(Address, results[0]));
+ });
}
finally
{
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs
index 6f9cde21..c083d592 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs
@@ -48,6 +48,16 @@ public sealed class AbLegacyDriverOptions
/// Gets or sets the default timeout for read/write operations.
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
+ ///
+ /// Whether scalar tags on adjacent elements of one N / B / F / L file are read as ONE multi-element PCCC read
+ /// (driver-config key coalesceReads, default ). A block the device refuses falls
+ /// back to per-tag reads on its own; set this false to read every tag singly. (the key
+ /// absent) means the default, ON - and null is never written, so a save through any serializer (the AdminUI
+ /// form included) keeps a defaulted driver defaulted instead of pinning today's default (review 07 r0 M9).
+ ///
+ [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
+ public bool? CoalesceReads { get; init; }
+
///
/// Timeout for the AdminUI Test Connect probe, in seconds. The AdminUI clamps to a
/// 60s server-side maximum; this default is what the form pre-fills for new instances.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTagDefinitionFactory.cs
index 8c0742f5..c10832d7 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTagDefinitionFactory.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTagDefinitionFactory.cs
@@ -77,6 +77,10 @@ public static class AbLegacyTagDefinitionFactory
if (rawLength >= 1)
arrayLength = Math.Min(rawLength, AbLegacyArray.MaxElements);
}
+ // A deterministic refusal (review 07 H16): String / Timer / Counter / Control elements are structured
+ // or variable-width and do not lay out as a flat array, so both runtimes refuse to decode them. Accepting
+ // the tag only bought a handle create + a NotSupportedException on every poll.
+ if (arrayLength is not null && !IsArrayElementTypeSupported(dataType)) return false;
// "writable" defaults to true when absent (today's value) — makes read-only equipment tags
// authorable.
var writable = TagConfigJson.ReadWritable(root);
@@ -173,6 +177,9 @@ public static class AbLegacyTagDefinitionFactory
{
if (AbLegacyAddress.Validate(accepted.Address, family) is { } addressError)
warnings.Add($"AbLegacy tag address: {addressError} The driver reads this tag as BadNodeIdUnknown.");
+ else if (accepted.ArrayLength is not null
+ && ArrayFileMismatch(accepted.DataType, accepted.Address) is { } widthError)
+ warnings.Add(widthError);
return warnings;
}
@@ -224,6 +231,78 @@ public static class AbLegacyTagDefinitionFactory
}
if (TagConfigJson.DescribeInvalidEnum(root, "dataType") is { } w) warnings.Add(w);
+
+ if (IsArrayFlag(root) && ReadInt(root, "arrayLength") >= 1
+ && TagConfigJson.TryReadEnumStrict(root, "dataType", AbLegacyDataType.Int, out var dt)
+ && !IsArrayElementTypeSupported(dt))
+ warnings.Add(ArrayElementTypeError(dt));
+ }
+
+ ///
+ /// Whether can be the element type of an array tag. Only the flat word/long/float/bit
+ /// files lay out as a contiguous array; String, Timer, Counter and Control elements are structured.
+ ///
+ /// The authored element data type.
+ /// for Bit, Int, AnalogInt, Long and Float.
+ public static bool IsArrayElementTypeSupported(AbLegacyDataType type) => type is
+ AbLegacyDataType.Bit or AbLegacyDataType.Int or AbLegacyDataType.AnalogInt
+ or AbLegacyDataType.Long or AbLegacyDataType.Float;
+
+ /// The authoring / deploy-gate message for an array of an unsupported element type.
+ /// The authored element data type.
+ /// The message.
+ public static string ArrayElementTypeError(AbLegacyDataType type) =>
+ $"AbLegacy {type} is not supported as an array element (String, Timer, Counter and Control elements are "
+ + "structured); author a scalar tag per element instead. The driver skips this tag and it reads BadNodeIdUnknown.";
+
+ ///
+ /// The element width an array on 's data file has, as the data type that decodes /
+ /// encodes it: N, B, A and the I/O/S word files are 16-bit words (), L is
+ /// 32-bit (), F is IEEE float ().
+ /// for a file with no flat array layout (ST, T, C, R, function files) or an address that
+ /// does not parse.
+ ///
+ /// The authored address.
+ /// The file's element type, or .
+ public static AbLegacyDataType? ArrayFileElementType(string address) =>
+ AbLegacyAddress.TryParse(address) is { } a ? ArrayFileElementType(a) : null;
+
+ ///
+ public static AbLegacyDataType? ArrayFileElementType(AbLegacyAddress a) => a.FileLetter switch
+ {
+ "N" or "B" or "A" or "I" or "O" or "S" => AbLegacyDataType.Int,
+ "L" => AbLegacyDataType.Long,
+ "F" => AbLegacyDataType.Float,
+ _ => null,
+ };
+
+ ///
+ /// Why an ARRAY authored as on does not match its data
+ /// file's element width (review 07 r0 I3), or when it does. Int / AnalogInt need a 16-bit
+ /// word file (N/B/A/I/O/S), Long an L file, Float an F file; Bit arrays are bit-packed words and fit any 16-bit
+ /// word file. A mismatch reads the file at the wrong width (garbage) and the driver refuses to WRITE it
+ /// (BadTypeMismatch), because encoding at the authored width would corrupt the neighbouring elements.
+ ///
+ /// The authored element data type.
+ /// The authored address.
+ /// The mismatch message, or .
+ public static string? ArrayFileMismatch(AbLegacyDataType type, string address)
+ {
+ if (!IsArrayElementTypeSupported(type)) return null; // structured arrays have their own refusal
+ // The file LETTER is static even for an indirect address (N7:[N7:0], N[N7:0]:5) — only the numbers move —
+ // so the width rule applies to it too (review 07 r1 N5).
+ if (AbLegacyAddress.TryParse(address) is not { } a) return null;
+ var file = ArrayFileElementType(a);
+ var ok = type switch
+ {
+ AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Bit => file == AbLegacyDataType.Int,
+ _ => file == type,
+ };
+ return ok
+ ? null
+ : $"AbLegacy {type} array on '{address}' does not match the {a.FileLetter} file's element width "
+ + "(Int/AnalogInt/Bit need an N/B/A word file, Long an L file, Float an F file): reads decode the wrong width "
+ + "and writes are refused BadTypeMismatch.";
}
private static int ReadInt(JsonElement o, string name)
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyArrayWriteEncoder.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyArrayWriteEncoder.cs
new file mode 100644
index 00000000..a28e7169
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyArrayWriteEncoder.cs
@@ -0,0 +1,79 @@
+using System.Collections;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
+
+///
+/// Converts an OPC UA array-write value into the element-typed CLR array a runtime's
+/// takes. Pure: it runs BEFORE any runtime is created, so a refused
+/// write never costs a wire round trip and never touches driver health.
+///
+///
+/// Elements are converted, not exact-matched. ScadaBridge writes CLR lists such as int[] to
+/// Int16 nodes, and an OPC UA client may send any numeric array, so each element goes through the same checked
+/// the scalar path uses. A value that does not fit is BadOutOfRange; one that is not
+/// a number at all is BadTypeMismatch.
+/// The length must equal the tag's element count (BadOutOfRange otherwise): a shorter write
+/// would have to fill the tail from the last read buffer, which is the stale-elements defect this replaces.
+/// Bit arrays are refused (BadNotSupported): a PCCC bit array is bit-packed into words, so
+/// encoding it rewrites whole words and would clobber neighbouring bits the PLC owns. String / Timer / Counter /
+/// Control arrays never reach here — the mapper rejects them.
+///
+internal static class AbLegacyArrayWriteEncoder
+{
+ /// Converts to a typed array of exactly elements.
+ /// The tag's element data type.
+ /// The value the client wrote.
+ /// The tag's element count.
+ /// The converted array when the result is Good.
+ /// , or the Bad status the write is answered with.
+ public static uint TryConvert(AbLegacyDataType type, object? value, int count, out Array? typed)
+ {
+ typed = null;
+ if (type is AbLegacyDataType.Bit) return AbLegacyStatusMapper.BadNotSupported;
+ if (type is not (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Long or AbLegacyDataType.Float))
+ return AbLegacyStatusMapper.BadNotSupported;
+ if (value is null or string || value is not IEnumerable items) return AbLegacyStatusMapper.BadTypeMismatch;
+
+ var elements = items.Cast
-public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
+public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscovery, IBatchSubscribable,
IHostConnectivityProbe, IPerCallHostResolver, ISkippedTagReporter, IDisposable, IAsyncDisposable
{
/// Authored tags this driver could not build into its poll table.
@@ -23,16 +23,30 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
/// Mutable so can adopt a re-parsed config on reinitialize.
/// Only ever written on the init path, before any reader/session uses it.
- private AbLegacyDriverOptions _options;
+ private volatile AbLegacyDriverOptions _options;
private readonly string _driverInstanceId;
private readonly IAbLegacyTagFactory _tagFactory;
private readonly ILogger _logger;
private readonly PollGroupEngine _poll;
- private readonly Dictionary _devices = new(StringComparer.OrdinalIgnoreCase);
+
+ // Device table, keyed by HostAddress. BUILD-AND-SWAP (review 07 H13): InitializeAsync builds a fresh
+ // dictionary and publishes it by reference; teardown swaps in an empty one. A published dictionary is never
+ // mutated, so the retained poll loop, the actor's GetHostStatuses and a server read can enumerate whatever
+ // snapshot they hold without "Collection was modified". volatile = the publish is visible to every reader.
+ private volatile Dictionary _devices = new(StringComparer.OrdinalIgnoreCase);
// v3 authored-tag table, keyed by RawPath (the tag identity + driver wire reference). RawPath is
- // case-sensitive ordinal. Built in InitializeAsync from _options.RawTags via the pure mapper.
- private readonly Dictionary _tagsByRawPath = new(StringComparer.Ordinal);
+ // case-sensitive ordinal. Built in InitializeAsync from _options.RawTags via the pure mapper, and published
+ // by reference alongside _devices (same build-and-swap rule).
+ private volatile Dictionary _tagsByRawPath = new(StringComparer.Ordinal);
+
+ // Serialises every PUBLISH/RETIRE of the two tables with the lifecycle epoch. Never held across an await.
+ private readonly object _lifecycleLock = new();
+
+ // Bumped by every InitializeAsync start and every teardown. An initialise only publishes if the epoch is still
+ // the one it took — so an InitializeAsync the runtime abandoned (Lane A: it times the attempt out and calls
+ // ShutdownAsync) cannot resurrect its tables and probe loops behind a driver the runtime believes stopped.
+ private long _lifecycleEpoch;
// Resolves a read/write/subscribe RawPath reference to its tag definition via a single hit on the
// authored _tagsByRawPath table (v3: the reference is always a RawPath; a miss is a miss).
@@ -50,6 +64,12 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
///
public event EventHandler? OnDataChange;
+ ///
+ /// One raise per poll cycle carrying every tag that changed in it (review 02 P1). Raised alongside the per-tag
+ /// ; a consumer subscribes to exactly one of the two.
+ ///
+ public event EventHandler? OnDataChangeBatch;
+
///
/// Occurs when host status changes.
///
@@ -85,6 +105,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)),
onError: HandlePollError,
backoffCap: PollBackoffCap,
+ onBatch: (handle, items) =>
+ OnDataChangeBatch?.Invoke(this, new DataChangeBatchEventArgs(handle, items)),
// The engine owns no Meter (Core.Abstractions takes no metrics dependency), so this
// closure IS the production consumer of its overrun signal.
onOverrun: _ => OtOpcUaTelemetry.PollOverruns.Add(1,
@@ -121,9 +143,12 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
internal void HandlePollError(Exception ex)
{
_logger.LogWarning(ex, "AbLegacy poll reader failed. Driver={DriverInstanceId}", _driverInstanceId);
- var current = _health;
- if (current.State != DriverState.Faulted)
- _health = new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, ex.Message);
+ lock (_lifecycleLock)
+ {
+ var current = _health;
+ if (current.State != DriverState.Faulted)
+ _health = new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, ex.Message);
+ }
}
///
@@ -145,66 +170,78 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
///
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
+ long epoch;
+ lock (_lifecycleLock) epoch = ++_lifecycleEpoch;
+
// InitializeAsync can run on a live driver without a teardown first (the IDriver contract
// allows it, and a reconnect retry can overlap a running attempt). Release the previous device states' probe loops + runtimes BEFORE the
- // overwrite below, or they are orphaned beyond the reach of ShutdownAsync. Deliberately NOT
+ // new table is published, or they are orphaned beyond the reach of ShutdownAsync. Deliberately NOT
// ShutdownAsync: that also drops _poll and every subscription, which are owned by the shutdown path (the actor re-subscribes after every successful init).
- await ReleaseDeviceStatesAsync().ConfigureAwait(false);
- _health = new DriverHealth(DriverState.Initializing, null, null);
+ await ReleaseDeviceStatesAsync(RetireTables(bumpEpoch: false).States, "re-initialise").ConfigureAwait(false);
+ SetHealthIfCurrent(epoch, new DriverHealth(DriverState.Initializing, null, null));
_logger.LogInformation(
"AbLegacy driver {DriverInstanceId} initializing: transport={Transport} tagFactory={TagFactory}",
_driverInstanceId, _transportDescription, _tagFactory.GetType().Name);
+
+ // Built locally and published by reference at the end (build-and-swap): nothing a concurrent reader holds
+ // is ever mutated.
+ var devices = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var tags = new Dictionary(StringComparer.Ordinal);
try
{
// re-parse the supplied DriverConfig so a config change delivered through the IDriver
// contract is honoured. Without this the driver keeps serving the options it was CONSTRUCTED
// with, so an operator's edit is discarded while the deployment still seals green.
- if (HasConfigBody(driverConfigJson))
- _options = AbLegacyDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
+ var options = HasConfigBody(driverConfigJson)
+ ? AbLegacyDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson)
+ : _options;
// Native is opt-in for every family (the default is libplctag); an operator may pick it explicitly
// even for PLC-5 / LogixPccc. Allowed, but the limitation is client-visible, so name it ONCE per instance —
// not on every init, which includes every comms-loss reconnect retry.
if (!_limitedFamilyWarned
&& _transport == AbLegacyTransport.Native
- && AbLegacyTransportResolver.NativeLimitedFamilies(_options.Devices) is { Count: > 0 } limited)
+ && AbLegacyTransportResolver.NativeLimitedFamilies(options.Devices) is { Count: > 0 } limited)
{
_limitedFamilyWarned = true;
_logger.LogWarning(
"AbLegacy driver {DriverInstanceId} runs the Native transport against {Families} device(s); native " +
- "support is limited there (PLC-5: N-file reads only; LogixPccc: framing unverified). Set " +
+ "support is limited there (PLC-5: N-file reads only, every write refused; LogixPccc: framing unverified). Set " +
"\"transport\": \"Libplctag\" to use the fallback stack.",
_driverInstanceId, string.Join(", ", limited));
}
- foreach (var device in _options.Devices)
+ foreach (var device in options.Devices)
{
var addr = AbLegacyHostAddress.TryParse(device.HostAddress)
?? throw new InvalidOperationException(
$"AbLegacy device has invalid HostAddress '{device.HostAddress}' — expected 'ab://gateway[:port]/cip-path'.");
var profile = AbLegacyPlcFamilyProfile.ForFamily(device.PlcFamily);
- _devices[device.HostAddress] = new DeviceState(addr, device, profile);
+ devices[device.HostAddress] = new DeviceState(addr, device, profile);
}
// Build the RawPath → definition table from the authored raw tags. Each entry's TagConfig
// blob is mapped by the pure factory; the entry's WriteIdempotent flag is threaded onto the
// def (it lives on the RawTagEntry, not inside the blob), and its DeviceName is resolved to
// the owning device's host so the existing DeviceHostAddress-keyed routing continues to work.
// A mapper miss is skipped (logged), never thrown — a bad tag must not fail the whole init.
- _skippedTags.BeginRebuild(_options.RawTags.Count);
- foreach (var entry in _options.RawTags)
+ _skippedTags.BeginRebuild(options.RawTags.Count);
+ foreach (var entry in options.RawTags)
{
if (!AbLegacyTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
{
- _skippedTags.Skip(entry.RawPath, "TagConfig did not map to a definition");
+ // The deploy gate's own explanation (same authority) — e.g. an array of a structured type.
+ var reason = AbLegacyTagDefinitionFactory.Inspect(entry.TagConfig).FirstOrDefault()
+ ?? "TagConfig did not map to a definition";
+ _skippedTags.Skip(entry.RawPath, reason);
_logger.LogWarning(
- "AbLegacy tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
- _driverInstanceId, entry.RawPath);
+ "AbLegacy tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath} Reason={Reason}",
+ _driverInstanceId, entry.RawPath, reason);
continue;
}
- _tagsByRawPath[entry.RawPath] = def with
+ tags[entry.RawPath] = def with
{
WriteIdempotent = entry.WriteIdempotent,
- DeviceHostAddress = ResolveDeviceHost(entry.DeviceName),
+ DeviceHostAddress = ResolveDeviceHost(options, entry.DeviceName),
};
}
_skippedTags.Commit();
@@ -213,9 +250,9 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
// and String (ST-file) are not supported by all PCCC families; reject them early
// so a misconfigured tag fails at init time with a clear message rather than
// surfacing an opaque comms error at runtime.
- foreach (var tag in _tagsByRawPath.Values)
+ foreach (var tag in tags.Values)
{
- if (!_devices.TryGetValue(tag.DeviceHostAddress, out var deviceForTag)) continue;
+ if (!devices.TryGetValue(tag.DeviceHostAddress, out var deviceForTag)) continue;
var profile = deviceForTag.Profile;
if (tag.DataType == AbLegacyDataType.Long && !profile.SupportsLongFile)
throw new InvalidOperationException(
@@ -227,68 +264,108 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
$"(family {deviceForTag.Options.PlcFamily}) does not support ST-files.");
}
- // Probe loops — one per device when enabled + probe address configured.
- if (_options.Probe.Enabled && !string.IsNullOrWhiteSpace(_options.Probe.ProbeAddress))
+ // Publish, start the probe loops and go Healthy as ONE step under the lifecycle lock, and only if no
+ // teardown (or newer initialise) ran since this attempt began. Starting the loops inside the lock
+ // means a teardown that swaps this table out always sees their tasks and joins them.
+ lock (_lifecycleLock)
{
- foreach (var state in _devices.Values)
+ if (epoch != _lifecycleEpoch)
+ throw new OperationCanceledException(
+ $"AbLegacy driver {_driverInstanceId} initialise was superseded by a shutdown or a newer initialise.");
+ _options = options;
+ _tagsByRawPath = tags;
+ _devices = devices;
+ _resolver.Clear(); // drop transient parses made against the previous table
+ if (options.Probe.Enabled && !string.IsNullOrWhiteSpace(options.Probe.ProbeAddress))
{
- state.ProbeCts = new CancellationTokenSource();
- var ct = state.ProbeCts.Token;
- state.ProbeTask = Task.Run(() => ProbeLoopAsync(state, ct), ct);
+ foreach (var state in devices.Values)
+ {
+ state.ProbeCts = new CancellationTokenSource();
+ var ct = state.ProbeCts.Token;
+ var probe = options.Probe;
+ state.ProbeTask = Task.Run(() => ProbeLoopAsync(state, probe, ct), ct);
+ }
}
+ _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
}
- _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
}
catch (Exception ex)
{
- _health = new DriverHealth(DriverState.Faulted, null, ex.Message);
+ // Release what this attempt built. Nothing was published (the publish is the last statement of the
+ // try and runs entirely under the lock), so no loop is running against these states.
+ foreach (var state in devices.Values) state.Retire();
+ if (ex is OperationCanceledException && epoch != Volatile.Read(ref _lifecycleEpoch))
+ {
+ _logger.LogInformation(
+ "AbLegacy driver {DriverInstanceId} initialise abandoned: superseded before it could publish.",
+ _driverInstanceId);
+ throw;
+ }
+ SetHealthIfCurrent(epoch, new DriverHealth(DriverState.Faulted, null, ex.Message));
_logger.LogError(ex,
"AbLegacy driver initialise failed. Driver={DriverInstanceId}", _driverInstanceId);
- // Release cached state created before the failure so a caller who catches and abandons
- // (rather than retrying via ReinitializeAsync) doesn't leave CancellationTokenSources and
- // libplctag handles alive. No probe-loop join here: the loops start as the LAST statement of
- // the try and nothing after that can throw, so a failure never has a running loop to join.
- foreach (var state in _devices.Values)
- {
- state.ProbeCts?.Dispose();
- state.ProbeCts = null;
- state.ProbeTask = null;
- state.DisposeRuntimes();
- }
- _devices.Clear();
- _tagsByRawPath.Clear();
- _resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses
throw;
}
}
- ///
- /// Cancels, bounded-joins and disposes every existing device state, then clears the table.
- /// Device-state part of only (no poll / subscription teardown).
- ///
- private async Task ReleaseDeviceStatesAsync()
+ /// Writes only while is still the current lifecycle
+ /// epoch, so a superseded initialise never overwrites the health of the teardown that superseded it.
+ private void SetHealthIfCurrent(long epoch, DriverHealth health)
{
- if (_devices.Count == 0) return;
- foreach (var state in _devices.Values)
+ lock (_lifecycleLock)
+ {
+ if (epoch == _lifecycleEpoch) _health = health;
+ }
+ }
+
+ /// The current lifecycle epoch; a read/write captures it so a late result cannot rewrite health after a teardown.
+ private long CurrentEpoch => Volatile.Read(ref _lifecycleEpoch);
+
+ ///
+ /// Atomically swaps the device table (and, for a teardown, the tag table) for an empty one and returns the
+ /// device states that were published, for the caller to release. Concurrent teardowns (ShutdownAsync racing
+ /// Dispose) each get a DISJOINT set, so no state is released twice and neither enumerates a table the other
+ /// is clearing.
+ ///
+ /// True for a teardown: supersedes any in-flight initialise.
+ /// The device states that were published, and the epoch as of the retirement (a teardown writes its final
+ /// health only while that epoch is still current — review 07 r0 M1).
+ private (List States, long Epoch) RetireTables(bool bumpEpoch)
+ {
+ lock (_lifecycleLock)
+ {
+ if (bumpEpoch)
+ {
+ _lifecycleEpoch++;
+ _tagsByRawPath = new Dictionary(StringComparer.Ordinal);
+ }
+ var retired = _devices.Values.ToList();
+ _devices = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ return (retired, _lifecycleEpoch);
+ }
+ }
+
+ ///
+ /// Cancels, bounded-joins and retires the given (already unpublished) device states.
+ ///
+ /// The states to release.
+ /// "shutdown" / "re-initialise", for the join-timeout warning.
+ private async Task ReleaseDeviceStatesAsync(List states, string phase)
+ {
+ if (states.Count == 0) return;
+ foreach (var state in states)
try { state.ProbeCts?.Cancel(); } catch { }
- var loops = _devices.Values.Select(d => d.ProbeTask).OfType().ToList();
+ var loops = states.Select(d => d.ProbeTask).OfType().ToList();
if (loops.Count > 0)
{
var all = Task.WhenAll(loops);
var finished = await Task.WhenAny(all, Task.Delay(LoopJoinTimeout, CancellationToken.None)).ConfigureAwait(false);
if (finished != all)
_logger.LogWarning(
- "AbLegacy re-initialise: {Count} probe loop(s) did not stop within {Timeout}; proceeding. Driver={DriverInstanceId}",
- loops.Count, LoopJoinTimeout, _driverInstanceId);
+ "AbLegacy {Phase}: {Count} probe loop(s) did not stop within {Timeout}; proceeding. Driver={DriverInstanceId}",
+ phase, loops.Count, LoopJoinTimeout, _driverInstanceId);
}
- foreach (var state in _devices.Values)
- {
- state.ProbeCts?.Dispose();
- state.ProbeCts = null;
- state.ProbeTask = null;
- state.DisposeRuntimes();
- }
- _devices.Clear();
+ foreach (var state in states) state.Retire();
}
///
@@ -305,44 +382,39 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
/// Synchronous bounded join of the (already-cancelled) probe loops for the sync teardown paths.
/// The loop body uses ConfigureAwait(false) throughout, so blocking here cannot deadlock.
///
- private void JoinProbeLoopsSync()
+ /// The retired states whose loops to join.
+ private static void JoinProbeLoopsSync(List states)
{
- var loops = _devices.Values.Select(d => d.ProbeTask).OfType().ToArray();
+ var loops = states.Select(d => d.ProbeTask).OfType().ToArray();
if (loops.Length == 0) return;
try { Task.WaitAll(loops, LoopJoinTimeout); }
catch (AggregateException) { /* loops observe cancellation; a fault is not actionable at teardown */ }
}
- ///
+ ///
+ /// Complete teardown: stops every poll subscription, cancels and (bounded) joins the probe loops, disposes
+ /// every cached runtime and unpublishes both tables. Safe to run concurrently with /
+ /// (each teardown retires a disjoint set of states) and with an in-flight
+ /// , which it supersedes: that attempt will not publish.
+ ///
+ /// Unused; teardown is bounded by .
+ /// A task that completes when teardown is done.
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
+ // Unpublish FIRST, so an in-flight initialise is superseded and new reads resolve nothing.
+ var (retired, teardownEpoch) = RetireTables(bumpEpoch: true);
await _poll.DisposeAsync().ConfigureAwait(false);
// Cancel the probe loops, JOIN them (bounded), then dispose the CTSs and runtimes.
// The loop used to be fire-and-forget, so one parked in a probe-runtime create/read could
- // outlive shutdown and keep a libplctag handle alive past DisposeRuntimes.
- foreach (var state in _devices.Values)
- try { state.ProbeCts?.Cancel(); } catch { }
- var loops = _devices.Values.Select(d => d.ProbeTask).OfType().ToList();
- if (loops.Count > 0)
- {
- var all = Task.WhenAll(loops);
- var finished = await Task.WhenAny(all, Task.Delay(LoopJoinTimeout, CancellationToken.None)).ConfigureAwait(false);
- if (finished != all)
- _logger.LogWarning(
- "AbLegacy shutdown: {Count} probe loop(s) did not stop within {Timeout}; proceeding with teardown. Driver={DriverInstanceId}",
- loops.Count, LoopJoinTimeout, _driverInstanceId);
- }
- foreach (var state in _devices.Values)
- {
- state.ProbeCts?.Dispose();
- state.ProbeCts = null;
- state.ProbeTask = null;
- state.DisposeRuntimes();
- }
- _devices.Clear();
- _tagsByRawPath.Clear();
+ // outlive shutdown and keep a libplctag handle alive past retirement.
+ await ReleaseDeviceStatesAsync(retired, "shutdown").ConfigureAwait(false);
_resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses
- _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
+ // A newer InitializeAsync that ran while this teardown was still joining owns health now.
+ lock (_lifecycleLock)
+ {
+ if (teardownEpoch == _lifecycleEpoch)
+ _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
+ }
}
///
@@ -364,123 +436,431 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
// ---- IReadable ----
+ ///
+ /// How many devices one reads concurrently. Each device is still read serially (its
+ /// tags share one PCCC connection, and SLC/MicroLogix controllers accept few), so this bounds the number of
+ /// devices — not requests — in flight, which is what keeps a dead device from starving a healthy one.
+ ///
+ internal const int MaxConcurrentDevices = 8;
+
+ ///
+ /// Consecutive comms faults within one device that mark it DOWN (opening its backoff window and failing the
+ /// rest of the pass fast). Two, not one: a single tag that times out (an odd address, a slow file) must not
+ /// blacken every sibling on a device that is answering.
+ ///
+ internal const int FaultsToMarkDeviceDown = 2;
+
+ ///
+ /// The clock every per-device backoff decision reads (window open? when does it end?). Wall-clock UTC in
+ /// production; a test substitutes a manual clock so the window can be crossed deterministically rather than by
+ /// sleeping (review 07 r0 M7).
+ ///
+ internal Func Clock { get; set; } = () => DateTime.UtcNow;
+
+ /// One resolved reference queued for its device's pass.
+ private sealed record ReadItem(int Index, string Reference, AbLegacyTagDefinition Def, AbLegacyAddress Parsed);
+
+ ///
+ /// What one wire operation proved. = the device replied (a Good value OR a
+ /// deterministic device refusal such as "file not found") — the evidence that advances
+ /// LastSuccessfulRead; = a comms failure (timeout, dropped connection, garbage
+ /// reply); = failed fast inside the device's backoff window (Bad, Degraded, but no new
+ /// evidence for the backoff); = nothing reached the wire or it says nothing about
+ /// reachability (a local refusal, a bad index value).
+ ///
+ /// = a reply arrived that cannot be classified (libplctag ERR_REMOTE_ERR, native
+ /// BadReply/Partial): it may be the device refusing OR a routing hop reporting that the end device is
+ /// gone, so it is NOT an answer (no backoff reset, no LastSuccessfulRead) and NOT a fault (no fail-fast);
+ /// a pass that produced only opaque outcomes for a device degrades (review 07 r1 N1).
+ private enum OpVerdict { Neutral, Answered, Fault, Throttled, Opaque }
+
+ /// The health evidence one device pass produced (collected, then written once per read).
+ private struct DeviceReadOutcome
+ {
+ public bool AnyGood;
+ public bool AnyAnswered;
+ public string? FaultMessage;
+ public string? OpaqueMessage;
+
+ ///
+ /// Folds an opaque-only device into the fault class for STATE (Degraded, LastSuccessfulRead not advanced
+ /// by it) — option (a) of review 07 r1 N1: a dead PLC behind a bridge answers every request with a fast remote
+ /// error, and only this lets the comms-loss watchdog (non-Healthy AND frozen LastSuccessfulRead) reach
+ /// it. Cost: a single-tag device whose only tag draws an opaque refusal is re-initialised every 90 s.
+ ///
+ public void FoldOpaque()
+ {
+ if (OpaqueMessage is not null && !AnyAnswered) FaultMessage ??= OpaqueMessage;
+ }
+ }
+
///
+ ///
+ /// Review 07 P7: references are bucketed by device and the devices are read concurrently (at most
+ /// ); within a device the tags are read in order. A device that produces
+ /// consecutive comms faults opens its
+ /// window and every remaining (and every later) read on it fails fast with BadCommunicationError,
+ /// touching no wire, until the window expires or the probe sees it answer. Health is written ONCE per call:
+ /// worst outcome wins, and LastSuccessfulRead advances when ANY device answered.
+ ///
public async Task> ReadAsync(
IReadOnlyList fullReferences, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fullReferences);
+ var epoch = CurrentEpoch;
var now = DateTime.UtcNow;
var results = new DataValueSnapshot[fullReferences.Count];
+ var devices = _devices; // one snapshot of the published table for the whole call
+ var buckets = new Dictionary>(ReferenceEqualityComparer.Instance);
for (var i = 0; i < fullReferences.Count; i++)
{
var reference = fullReferences[i];
- if (!_resolver.TryResolve(reference, out var def))
+ if (!_resolver.TryResolve(reference, out var def) || !devices.TryGetValue(def.DeviceHostAddress, out var device))
{
results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNodeIdUnknown, null, now);
continue;
}
- if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
+ var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
+ if (parsed is null)
{
+ WarnMalformedOnce(device, def);
results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNodeIdUnknown, null, now);
continue;
}
+ if (!buckets.TryGetValue(device, out var list)) buckets[device] = list = [];
+ list.Add(new ReadItem(i, reference, def, parsed));
+ }
+ if (buckets.Count == 0) return results;
- try
+ var passes = buckets.ToArray();
+ var outcomes = new DeviceReadOutcome[passes.Length];
+ using (var window = new SemaphoreSlim(MaxConcurrentDevices, MaxConcurrentDevices))
+ {
+ var tasks = new Task[passes.Length];
+ for (var d = 0; d < passes.Length; d++)
+ tasks[d] = RunPassAsync(d);
+ // WhenAll completes only once every pass has finished (it never short-circuits on a fault), so the
+ // window cannot be disposed under a pass still waiting on it.
+ await Task.WhenAll(tasks).ConfigureAwait(false);
+
+ async Task RunPassAsync(int d)
{
- var preParsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
- if (preParsed is null)
- {
- WarnMalformedOnce(device, def);
- results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNodeIdUnknown, null, now);
- continue;
- }
- if (preParsed.IsIndirect)
- {
- // Indirect: read the index word(s) NOW (never cached - the index can change between
- // polls), compose the concrete address, then run the normal path against it.
- var res = await ResolveIndirectAsync(device, def, preParsed, cancellationToken).ConfigureAwait(false);
- if (res.Def is null)
- {
- results[i] = new DataValueSnapshot(null, res.Status, null, now);
- if (res.TransportFault)
- _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
- $"transport fault resolving indirect address for {reference}");
- continue;
- }
- def = res.Def;
- }
-
- var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
-
- int status;
- object? value;
- // Serialise the Read → GetStatus → DecodeValue sequence against the shared
- // runtime — the server read path and the poll loop both call ReadAsync against
- // the same cached libplctag Tag handle, which is not concurrency-safe.
- var opLock = device.GetRuntimeLock(def.Name);
- await opLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+ await window.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
- await runtime.ReadAsync(cancellationToken).ConfigureAwait(false);
- status = runtime.GetStatus();
- var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
- if (status != 0)
- value = null;
- else if (IsArrayTag(def))
- value = runtime.DecodeArray(def.DataType, EffectiveArrayLength(def));
- else
- value = runtime.DecodeValue(def.DataType, parsed?.BitIndex);
+ outcomes[d] = await ReadDevicePassAsync(passes[d].Key, passes[d].Value, results, now, cancellationToken)
+ .ConfigureAwait(false);
}
finally
{
- opLock.Release();
+ window.Release();
}
-
- if (status != 0)
- {
- // Evict the stale handle so the next call re-creates it (mirrors AbCip).
- EvictRuntime(device, def.Name);
- results[i] = new DataValueSnapshot(null,
- AbLegacyStatusMapper.MapStatus(status), null, now);
- _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
- $"transport status {status} reading {reference}");
- if (!device.FirstNonZeroStatusLogged)
- {
- device.FirstNonZeroStatusLogged = true;
- _logger.LogWarning(
- "AbLegacy non-zero transport status. Driver={DriverInstanceId} Device={DeviceHostAddress} Reference={Reference} Status={Status}",
- _driverInstanceId, def.DeviceHostAddress, reference, status);
- }
- continue;
- }
- // Healthy read — re-arm the per-device first-failure log so a future non-zero
- // status logs again rather than being suppressed by an old flag from a prior outage.
- device.FirstNonZeroStatusLogged = false;
-
- results[i] = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now);
- _health = new DriverHealth(DriverState.Healthy, now, null);
- }
- catch (OperationCanceledException) { throw; }
- catch (AbTransportException ex) when (IsLocalRefusal(ex))
- {
- // The runtime refused the operation before the wire (e.g. a native PLC-5 non-N read).
- // Not a comms fault: BadNotSupported, keep the handle, leave health alone.
- results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now);
- }
- catch (Exception ex)
- {
- // Transport exception — evict so the next read creates a fresh handle (mirrors AbCip).
- EvictRuntime(device, def.Name);
- results[i] = new DataValueSnapshot(null,
- AbLegacyStatusMapper.BadCommunicationError, null, now);
- _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
}
}
+ ApplyReadHealth(epoch, outcomes, now);
return results;
}
+ ///
+ /// Writes the read's health once, after every device pass: Degraded if any pass hit a comms fault, else
+ /// Healthy if anything read Good; LastSuccessfulRead advances when any device answered (a Good read or a
+ /// deterministic device refusal — both prove the device is reachable). A read that reached no device's
+ /// wire, or only drew refusals, does not change the state.
+ ///
+ private void ApplyReadHealth(long epoch, DeviceReadOutcome[] outcomes, DateTime now)
+ {
+ var anyGood = false;
+ var anyAnswered = false;
+ string? fault = null;
+ foreach (var o in outcomes)
+ {
+ anyGood |= o.AnyGood;
+ anyAnswered |= o.AnyAnswered;
+ fault ??= o.FaultMessage;
+ }
+ ApplyHealth(epoch, fault, anyGood, anyAnswered, now);
+ }
+
+ ///
+ /// The single health write for a read or write call (worst wins): any comms fault ⇒ Degraded; otherwise any
+ /// answer — a Good value OR a device refusal, both prove the device replied — ⇒ Healthy (review 07 r0 I2: a
+ /// refusal-only pass used to leave a recovered driver Degraded). LastSuccessfulRead advances whenever
+ /// any device answered. A call that reached no wire changes nothing; a Faulted (config-level) state is kept.
+ /// Epoch-guarded so a late call cannot rewrite the health of a teardown or newer initialise.
+ ///
+ private void ApplyHealth(long epoch, string? fault, bool anyGood, bool anyAnswered, DateTime now)
+ {
+ if (fault is null && !anyGood && !anyAnswered) return;
+ lock (_lifecycleLock)
+ {
+ if (epoch != _lifecycleEpoch) return;
+ var current = _health;
+ var lastRead = anyAnswered ? now : current.LastSuccessfulRead;
+ if (current.State == DriverState.Faulted)
+ _health = current with { LastSuccessfulRead = lastRead };
+ else if (fault is not null)
+ _health = new DriverHealth(DriverState.Degraded, lastRead, fault);
+ else
+ _health = new DriverHealth(DriverState.Healthy, lastRead, null);
+ }
+ }
+
+ /// Reads one device's references in order, failing fast once the device is marked down.
+ private async Task ReadDevicePassAsync(
+ DeviceState device, List items, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
+ {
+ var outcome = new DeviceReadOutcome();
+ var work = new Queue(PlanReadUnits(device, items));
+ while (work.TryDequeue(out var unit))
+ {
+ var members = unit.Block?.Members ?? [unit.Single!];
+ if (device.IsThrottled(Clock()))
+ {
+ foreach (var m in members)
+ results[m.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadCommunicationError, null, now);
+ outcome.FaultMessage ??= $"device {device.Options.HostAddress} is in its reconnect backoff window";
+ continue;
+ }
+
+ OpVerdict verdict;
+ string? message;
+ if (unit.Block is { } block)
+ {
+ (var blockOutcome, verdict, message) = await ReadBlockAsync(device, block, results, now, ct).ConfigureAwait(false);
+ if (blockOutcome == BlockOutcome.Fallback)
+ {
+ // Refused as a block: read its members singly, now, so each gets its own answer.
+ var rest = work.ToList();
+ work.Clear();
+ foreach (var m in block.Members) work.Enqueue(new ReadUnit(m, null));
+ foreach (var r in rest) work.Enqueue(r);
+ continue;
+ }
+ }
+ else
+ {
+ (verdict, message) = await ReadOneAsync(device, unit.Single!, results, now, ct).ConfigureAwait(false);
+ }
+ switch (verdict)
+ {
+ case OpVerdict.Answered:
+ outcome.AnyAnswered = true;
+ if (members.Any(m => results[m.Index].StatusCode == AbLegacyStatusMapper.Good)) outcome.AnyGood = true;
+ device.RecordAnswered();
+ break;
+ case OpVerdict.Fault:
+ outcome.FaultMessage ??= message;
+ device.RecordCommsFault(Clock());
+ break;
+ case OpVerdict.Throttled:
+ outcome.FaultMessage ??= message;
+ break;
+ case OpVerdict.Opaque:
+ outcome.OpaqueMessage ??= message;
+ break;
+ }
+ }
+ outcome.FoldOpaque();
+ return outcome;
+ }
+
+ /// Reads one reference; writes its snapshot and reports what the operation proved.
+ private async Task<(OpVerdict Verdict, string? Message)> ReadOneAsync(
+ DeviceState device, ReadItem item, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
+ {
+ var def = item.Def;
+ var reference = item.Reference;
+ try
+ {
+ if (item.Parsed.IsIndirect)
+ {
+ // Indirect: read the index word(s) NOW (never cached - the index can change between
+ // polls), compose the concrete address, then run the normal path against it.
+ var res = await ResolveIndirectAsync(device, def, item.Parsed, ct).ConfigureAwait(false);
+ if (res.Def is null)
+ {
+ results[item.Index] = new DataValueSnapshot(null, res.Status, null, now);
+ return res.TransportFault
+ ? (OpVerdict.Fault, $"transport fault resolving indirect address for {reference}")
+ : res.Opaque
+ ? (OpVerdict.Opaque, $"unclassifiable reply resolving indirect address for {reference}")
+ : (OpVerdict.Neutral, null);
+ }
+ def = res.Def;
+ }
+
+ // A mis-authored array (its type does not match its data file) would decode the file at the wrong width
+ // and serve garbage as Good (review 07 r1 N2): answered BadTypeMismatch, warned once, no health write.
+ if (IsArrayTag(def) && AbLegacyTagDefinitionFactory.ArrayFileMismatch(def.DataType, def.Address) is { } widthError)
+ {
+ WarnRefusalOnce(device, def, "read", widthError, AbLegacyStatusMapper.BadTypeMismatch);
+ results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadTypeMismatch, null, now);
+ return (OpVerdict.Neutral, null);
+ }
+
+ var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
+
+ int status;
+ object? value;
+ // Serialise the Read → GetStatus → DecodeValue sequence against the shared
+ // runtime — the server read path and the poll loop both call ReadAsync against
+ // the same cached libplctag Tag handle, which is not concurrency-safe.
+ var opLock = device.GetRuntimeLock(def.Name);
+ device.BeforeOperationGate?.Invoke(def.Name);
+ await opLock.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ if (await CurrentRuntimeUnderLockAsync(device, def, runtime, ct).ConfigureAwait(false) is not { } live)
+ {
+ // A swept indirect resolution (the index moved under us): never resurrect it under a forgotten gate.
+ results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadCommunicationError, null, now);
+ return (OpVerdict.Neutral, null);
+ }
+ runtime = live;
+ await runtime.ReadAsync(ct).ConfigureAwait(false);
+ status = runtime.GetStatus();
+ var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
+ if (status != 0)
+ value = null;
+ else if (IsArrayTag(def))
+ value = runtime.DecodeArray(def.DataType, EffectiveArrayLength(def));
+ else
+ value = runtime.DecodeValue(def.DataType, parsed?.BitIndex);
+ }
+ finally
+ {
+ opLock.Release();
+ }
+
+ if (status != 0)
+ {
+ results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.MapStatus(status), null, now);
+ if (IsDeviceRefusal((AbStatus)status))
+ {
+ // The device ANSWERED with a refusal (file/element missing, out of bounds, a remote error…):
+ // per-tag Bad, the handle is kept (re-creating it would only re-pay the init read), warned once
+ // per tag, and it is evidence of reachability rather than a comms fault.
+ WarnRefusalOnce(device, def, "read", $"device status {(AbStatus)status}", results[item.Index].StatusCode);
+ return (OpVerdict.Answered, null);
+ }
+ if ((AbStatus)status == AbStatus.Unsupported)
+ {
+ // Refused locally / not supported: says nothing about reachability. Keep the handle.
+ WarnRefusalOnce(device, def, "read", "status Unsupported", AbLegacyStatusMapper.BadNotSupported);
+ return (OpVerdict.Neutral, null);
+ }
+ if (IsOpaque((AbStatus)status))
+ {
+ // A reply we cannot classify: per-tag Bad, keep the handle, warn once — neither an answer nor a fault.
+ WarnRefusalOnce(device, def, "read", $"unclassifiable reply {(AbStatus)status}", results[item.Index].StatusCode);
+ return (OpVerdict.Opaque, $"unclassifiable reply {(AbStatus)status} reading {reference}");
+ }
+ // Evict the stale handle so the next call re-creates it (mirrors AbCip).
+ EvictRuntime(device, def.Name);
+ if (!device.FirstNonZeroStatusLogged)
+ {
+ device.FirstNonZeroStatusLogged = true;
+ _logger.LogWarning(
+ "AbLegacy non-zero transport status. Driver={DriverInstanceId} Device={DeviceHostAddress} Reference={Reference} Status={Status}",
+ _driverInstanceId, def.DeviceHostAddress, reference, status);
+ }
+ return (OpVerdict.Fault, $"transport status {status} reading {reference}");
+ }
+ // Healthy read — re-arm the per-device first-failure log so a future non-zero
+ // status logs again rather than being suppressed by an old flag from a prior outage.
+ device.FirstNonZeroStatusLogged = false;
+ results[item.Index] = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now);
+ return (OpVerdict.Answered, null);
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (ConnectionThrottledException tex)
+ {
+ // A cold connect refused inside the backoff window: no wire, but the tag has no value either.
+ results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadCommunicationError, null, now);
+ return (OpVerdict.Throttled, tex.Message);
+ }
+ catch (AbTransportException ex) when (IsLocalRefusal(ex))
+ {
+ // The runtime refused the operation before the wire (e.g. a native PLC-5 non-N read).
+ // Not a comms fault: BadNotSupported, keep the handle, leave health alone.
+ WarnRefusalOnce(device, def, "read", ex.Message, AbLegacyStatusMapper.BadNotSupported);
+ results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now);
+ return (OpVerdict.Neutral, null);
+ }
+ catch (AbTransportException ex) when (IsDeviceRefusal(ex.Status))
+ {
+ // The native runtime raises a device's refusal (e.g. STS 0x20 "file not found") as an exception.
+ WarnRefusalOnce(device, def, "read", ex.Message, AbLegacyStatusMapper.MapStatus(ex.Status));
+ results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.MapStatus(ex.Status), null, now);
+ return (OpVerdict.Answered, null);
+ }
+ catch (AbTransportException ex) when (IsOpaque(ex.Status))
+ {
+ // A truncated / mismatched / unmapped reply (native BadReply, Partial): not proof the device answered.
+ WarnRefusalOnce(device, def, "read", ex.Message, AbLegacyStatusMapper.MapStatus(ex.Status));
+ results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.MapStatus(ex.Status), null, now);
+ return (OpVerdict.Opaque, ex.Message);
+ }
+ catch (NotSupportedException nse)
+ {
+ // A deterministic decode refusal (review 07 H16) — the same answer every poll. BadNotSupported, keep
+ // the handle (re-creating it only re-paid the init read), warn once, and NO health write.
+ WarnRefusalOnce(device, def, "read", nse.Message, AbLegacyStatusMapper.BadNotSupported);
+ results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now);
+ return (OpVerdict.Neutral, null);
+ }
+ catch (Exception ex)
+ {
+ // Transport exception — evict so the next read creates a fresh handle (mirrors AbCip).
+ EvictRuntime(device, def.Name);
+ results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadCommunicationError, null, now);
+ return (OpVerdict.Fault, ex.Message);
+ }
+ }
+
+ ///
+ /// True for a status meaning NO REPLY arrived: a timeout, a dropped / refused connection, an aborted request, a
+ /// request still pending, or a code this build does not know. Only these are comms faults — they evict the
+ /// handle, degrade health and count toward (review 07 r0 I1: fail-fast
+ /// must trip only when nothing came back).
+ ///
+ /// The transport status.
+ /// when no reply arrived.
+ internal static bool IsNoReply(AbStatus status) =>
+ !Enum.IsDefined(status) || status is AbStatus.Timeout or AbStatus.BadConnection or AbStatus.Abort or AbStatus.Pending;
+
+ ///
+ /// True for a status that PROVES the device answered and refused this one operation — an ALLOW-LIST (review 07
+ /// r1 N1): the file or element does not exist, the access is not allowed, it is out of bounds, a size or type is
+ /// wrong, or the processor is in a conflicting state. Per-tag Bad, keeps the handle, and is evidence of
+ /// reachability (backoff reset, LastSuccessfulRead, Healthy).
+ ///
+ ///
+ /// ⚠️ Inherits the native transport's PCCC STS table (AbStatusCodes.FromPccc, Lane K): it maps STS 0x30
+ /// ("remote node missing" — a dead DH+/DH-485 node behind a bridge) and 0x20 to NotAllowed/NotFound,
+ /// so on the NATIVE transport such a dead node still reads as answered. Follow-up F8 (review 07 r0 I4); this
+ /// classification needs no change once it lands.
+ ///
+ /// The transport status.
+ /// for a device refusal.
+ internal static bool IsDeviceRefusal(AbStatus status) => status is
+ AbStatus.NotFound or AbStatus.NotAllowed or AbStatus.OutOfBounds or AbStatus.TooLarge or AbStatus.TooSmall
+ or AbStatus.TypeMismatch or AbStatus.DeviceStateConflict;
+
+ ///
+ /// True for a reply that arrived but cannot be classified (review 07 r1 N1): —
+ /// libplctag's opaque ERR_REMOTE_ERR/ERR_BAD_STATUS, which folds EVERY non-zero CIP general status
+ /// and PCCC STS together (including a routing hop's "unconnected send timed out", "connection lost" and "remote
+ /// node missing"), and the native transport's unmapped / truncated / mismatched reply — and
+ /// . Opaque statuses are per-tag Bad and trigger the coalescing fallback, but are
+ /// neither an answer nor a comms fault; see for the health rule.
+ ///
+ /// The transport status.
+ /// for an unclassifiable reply.
+ internal static bool IsOpaque(AbStatus status) => status is AbStatus.BadReply or AbStatus.Partial;
+
// ---- IWritable ----
///
@@ -488,7 +868,12 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
IReadOnlyList writes, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(writes);
+ var epoch = CurrentEpoch;
var results = new WriteResult[writes.Count];
+ var devices = _devices; // one snapshot of the published table for the whole call
+ // Writes feed health too (review 07 r0 I2): a Good or device-refused write proves the device answered; a
+ // no-reply failure is a comms fault. Collected per call and written once, worst wins.
+ var outcome = new DeviceReadOutcome();
for (var i = 0; i < writes.Count; i++)
{
@@ -503,7 +888,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotWritable);
continue;
}
- if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
+ if (!devices.TryGetValue(def.DeviceHostAddress, out var device))
{
results[i] = new WriteResult(AbLegacyStatusMapper.BadNodeIdUnknown);
continue;
@@ -521,6 +906,15 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
continue;
}
+ // Inside the device's backoff window nothing is dialled — the write fails fast exactly as a read
+ // does, instead of paying the full Timeout on a handle that is still cached (review 07 r0 M2).
+ if (device.IsThrottled(Clock()))
+ {
+ results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError);
+ outcome.FaultMessage ??= $"device {device.Options.HostAddress} is in its reconnect backoff window";
+ continue;
+ }
+
// Indirect: resolve the index first (non-atomic with the write - the index may change
// between the two PLC accesses), then write the concrete address through the normal path.
if (parsed.IsIndirect)
@@ -530,8 +924,12 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
{
results[i] = new WriteResult(res.Status);
if (res.TransportFault)
- _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
- $"transport fault resolving indirect address for {w.FullReference}");
+ {
+ device.RecordCommsFault(Clock());
+ outcome.FaultMessage ??= $"transport fault resolving indirect address for {w.FullReference}";
+ }
+ else if (res.Opaque)
+ outcome.OpaqueMessage ??= $"unclassifiable reply resolving indirect address for {w.FullReference}";
continue;
}
def = res.Def;
@@ -543,12 +941,46 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
// READ, and against a device lacking that file the read failure surfaced as a comms fault —
// BadCommunicationError, evicted handle, Degraded health — instead of the refusal.
// Ahead of the bit-RMW branch too, which would otherwise read the parent word first.
- if (parsed is not null && _tagFactory.WriteRefusal(CreateParamsFor(device, def, parsed)) is not null)
+ if (_tagFactory.WriteRefusal(CreateParamsFor(device, def, parsed)) is { } refusal)
{
+ WarnRefusalOnce(device, def, "write", refusal);
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported);
continue;
}
+ // Array writes (review 07 U10): convert and length-check BEFORE any runtime exists, then encode every
+ // element. A bit / sub-element array has no flat encoding.
+ // The encode width is the DATA FILE's element width (review 07 r0 I3), never the authored type's: an
+ // authored type that does not match its file is refused BadTypeMismatch before anything is encoded.
+ Array? arrayValue = null;
+ AbLegacyDataType arrayFileType = default;
+ if (IsArrayTag(def))
+ {
+ var fileType = AbLegacyTagDefinitionFactory.ArrayFileElementType(parsed);
+ var widthRefusal = AbLegacyTagDefinitionFactory.ArrayFileMismatch(def.DataType, def.Address)
+ ?? (fileType is null ? $"{def.Address} has no flat array layout." : null);
+ uint conversion;
+ if (parsed.BitIndex is not null || parsed.SubElement is not null || def.DataType == AbLegacyDataType.Bit)
+ conversion = AbLegacyStatusMapper.BadNotSupported;
+ else if (widthRefusal is not null || fileType is null)
+ {
+ WarnRefusalOnce(device, def, "write", widthRefusal ?? "no flat array layout", AbLegacyStatusMapper.BadTypeMismatch);
+ results[i] = new WriteResult(AbLegacyStatusMapper.BadTypeMismatch);
+ continue;
+ }
+ else
+ {
+ arrayFileType = fileType.Value;
+ conversion = AbLegacyArrayWriteEncoder.TryConvert(arrayFileType, w.Value, EffectiveArrayLength(def), out arrayValue);
+ }
+ if (conversion != AbLegacyStatusMapper.Good)
+ {
+ if (conversion == AbLegacyStatusMapper.BadNotSupported)
+ WarnRefusalOnce(device, def, "write", $"{def.DataType} array writes are not supported.");
+ results[i] = new WriteResult(conversion);
+ continue;
+ }
+ }
// PCCC bit-within-word writes — RMW against a parallel parent-word runtime (strip the /N
// bit suffix). The per-parent-word lock serialises concurrent bit writers. Applies to every
// bit-addressable PCCC file: N-file (N7:0/3), B-file (B3:0/0), and the I/O image files
@@ -557,10 +989,12 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
// sub-element combined with a /N bit index, so their BitIndex is always null. NOTE: an Input-image (I) write is sent
// to the PLC like any other write — the device drives the input image from physical inputs and
// may reject it; we surface that real PCCC status rather than pre-rejecting at the driver.
- if (def.DataType == AbLegacyDataType.Bit && parsed?.BitIndex is int bit)
+ else if (def.DataType == AbLegacyDataType.Bit && parsed.BitIndex is int bit)
{
- results[i] = new WriteResult(
- await WriteBitInWordAsync(device, parsed, bit, w.Value, cancellationToken).ConfigureAwait(false));
+ var (code, raw) = await WriteBitInWordAsync(device, def, parsed, bit, w.Value, cancellationToken)
+ .ConfigureAwait(false);
+ results[i] = new WriteResult(code);
+ RecordWriteStatus(device, def, raw, ref outcome);
continue;
}
@@ -573,7 +1007,14 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
await opLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
- runtime.EncodeValue(def.DataType, parsed?.BitIndex, w.Value);
+ if (await CurrentRuntimeUnderLockAsync(device, def, runtime, cancellationToken).ConfigureAwait(false) is not { } live)
+ {
+ results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError);
+ continue; // the index moved under us (N3): not written, no health evidence
+ }
+ runtime = live;
+ if (arrayValue is not null) runtime.EncodeArray(arrayFileType, arrayValue);
+ else runtime.EncodeValue(def.DataType, parsed.BitIndex, w.Value);
await runtime.WriteAsync(cancellationToken).ConfigureAwait(false);
status = runtime.GetStatus();
}
@@ -582,22 +1023,36 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
opLock.Release();
}
- if (status != 0)
- {
- // Evict the stale handle so the next call re-creates it (mirrors AbCip).
- EvictRuntime(device, def.Name);
- results[i] = new WriteResult(AbLegacyStatusMapper.MapStatus(status));
- }
- else
- {
- results[i] = new WriteResult(AbLegacyStatusMapper.Good);
- }
+ results[i] = new WriteResult(status == 0 ? AbLegacyStatusMapper.Good : AbLegacyStatusMapper.MapStatus(status));
+ // A no-reply status evicts the stale handle so the next call re-creates it (mirrors AbCip).
+ if (IsNoReply((AbStatus)status) && status != 0) EvictRuntime(device, def.Name);
+ RecordWriteStatus(device, def, status, ref outcome);
}
catch (OperationCanceledException) { throw; }
+ catch (ConnectionThrottledException tex)
+ {
+ // The device is in its reconnect backoff window: fail fast, no wire, no new evidence for the backoff.
+ results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError);
+ outcome.FaultMessage ??= tex.Message;
+ }
+ catch (AbTransportException ex) when (IsDeviceRefusal(ex.Status))
+ {
+ device.RecordAnswered();
+ outcome.AnyAnswered = true;
+ WarnRefusalOnce(device, def, "write", ex.Message, AbLegacyStatusMapper.MapStatus(ex.Status));
+ results[i] = new WriteResult(AbLegacyStatusMapper.MapStatus(ex.Status));
+ }
+ catch (AbTransportException ex) when (IsOpaque(ex.Status))
+ {
+ outcome.OpaqueMessage ??= ex.Message;
+ WarnRefusalOnce(device, def, "write", ex.Message, AbLegacyStatusMapper.MapStatus(ex.Status));
+ results[i] = new WriteResult(AbLegacyStatusMapper.MapStatus(ex.Status));
+ }
catch (NotSupportedException nse)
{
+ // Deterministic (review 07 H16): nothing reached the wire, so it says nothing about the device.
+ WarnRefusalOnce(device, def, "write", nse.Message);
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported);
- _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
}
catch (Exception ex) when (ex is FormatException or InvalidCastException)
{
@@ -611,20 +1066,52 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
{
// The runtime refused the write before the wire — a native status-bit sub-element write or a
// PLC-5 write. Not a comms fault: BadNotSupported, keep the handle, leave health alone.
+ WarnRefusalOnce(device, def, "write", ex.Message);
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported);
}
catch (Exception ex)
{
// Transport exception — evict so the next write/read creates a fresh handle (mirrors AbCip).
EvictRuntime(device, def.Name);
+ device.RecordCommsFault(Clock());
results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError);
- _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
+ outcome.FaultMessage ??= ex.Message;
}
}
+ outcome.FoldOpaque();
+ ApplyHealth(epoch, outcome.FaultMessage, anyGood: outcome.AnyAnswered, anyAnswered: outcome.AnyAnswered, DateTime.UtcNow);
return results;
}
+ ///
+ /// Classifies a completed write's raw status into the call's health evidence and the device's backoff:
+ /// Good or a device refusal = answered; a no-reply status = comms fault (it used to leave health untouched on
+ /// this path while the exception path degraded — review 07 r0 M10); Unsupported = neutral.
+ ///
+ private void RecordWriteStatus(DeviceState device, AbLegacyTagDefinition def, int status, ref DeviceReadOutcome outcome)
+ {
+ var s = (AbStatus)status;
+ if (status == 0 || IsDeviceRefusal(s))
+ {
+ device.RecordAnswered();
+ outcome.AnyAnswered = true;
+ if (status != 0)
+ WarnRefusalOnce(device, def, "write", $"device status {s}", AbLegacyStatusMapper.MapStatus(status));
+ }
+ else if (IsNoReply(s))
+ {
+ device.RecordCommsFault(Clock());
+ outcome.FaultMessage ??= $"transport status {status} writing {def.Name}";
+ }
+ else if (IsOpaque(s))
+ {
+ // Neither an answer nor a fault (review 07 r1 N1).
+ WarnRefusalOnce(device, def, "write", $"unclassifiable reply {s}", AbLegacyStatusMapper.MapStatus(status));
+ outcome.OpaqueMessage ??= $"unclassifiable reply {s} writing {def.Name}";
+ }
+ }
+
// ---- ITagDiscovery ----
///
@@ -684,7 +1171,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
public IReadOnlyList GetHostStatuses() =>
[.. _devices.Values.Select(s => new HostConnectivityStatus(s.Options.HostAddress, s.HostState, s.HostStateChangedUtc))];
- private async Task ProbeLoopAsync(DeviceState state, CancellationToken ct)
+ private async Task ProbeLoopAsync(DeviceState state, AbLegacyProbeOptions probe, CancellationToken ct)
{
var probeParams = new AbLegacyTagCreateParams(
Gateway: state.ParsedAddress.Gateway,
@@ -693,9 +1180,9 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute,
// Normalised exactly like tag paths (PLC-5 octal I/O -> decimal); an unparseable probe address is passed through
// so the transport reports the failure as it always did.
- TagName: AbLegacyProbeAddress.Normalise(_options.Probe.ProbeAddress, state.Options.PlcFamily)
- ?? _options.Probe.ProbeAddress!,
- Timeout: _options.Probe.Timeout);
+ TagName: AbLegacyProbeAddress.Normalise(probe.ProbeAddress, state.Options.PlcFamily)
+ ?? probe.ProbeAddress!,
+ Timeout: probe.Timeout);
IAbLegacyTagRuntime? probeRuntime = null;
while (!ct.IsCancellationRequested)
@@ -710,19 +1197,26 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
state.ProbeInitialized = true;
}
await probeRuntime.ReadAsync(ct).ConfigureAwait(false);
- success = probeRuntime.GetStatus() == 0;
+ var status = probeRuntime.GetStatus();
+ success = status == 0;
+ // The probe's verdict feeds the data path's backoff (review 07 P7): an answer resets the window so a
+ // recovered device is read again at once; a comms fault counts toward marking it down.
+ if (success || IsDeviceRefusal((AbStatus)status)) state.RecordAnswered();
+ else if (IsNoReply((AbStatus)status)) state.RecordCommsFault(Clock()); // opaque / Unsupported: neutral
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
- catch
+ catch (Exception ex)
{
try { probeRuntime?.Dispose(); } catch { }
probeRuntime = null;
state.ProbeInitialized = false;
+ if (ex is AbTransportException ate && IsDeviceRefusal(ate.Status)) state.RecordAnswered();
+ else if (ex is not AbTransportException { Status: var st } || IsNoReply(st)) state.RecordCommsFault(Clock());
}
TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped);
- try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
+ try { await Task.Delay(probe.Interval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { break; }
}
try { probeRuntime?.Dispose(); } catch { }
@@ -768,21 +1262,22 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
/// HostAddress as the connection key. The HostAddress fallback is deliberate: the Client CLI
/// passes the host as the DeviceName.
///
+ /// The options being initialised (their device list is the routing table).
/// The owning device name delivered on the .
/// The matched device's HostAddress; empty when unroutable.
- private string ResolveDeviceHost(string deviceName)
+ private static string ResolveDeviceHost(AbLegacyDriverOptions options, string deviceName)
{
if (!string.IsNullOrEmpty(deviceName))
{
- foreach (var d in _options.Devices)
+ foreach (var d in options.Devices)
if (string.Equals(d.DeviceName, deviceName, StringComparison.OrdinalIgnoreCase))
return d.HostAddress;
- foreach (var d in _options.Devices)
+ foreach (var d in options.Devices)
if (string.Equals(d.HostAddress, deviceName, StringComparison.OrdinalIgnoreCase))
return d.HostAddress;
}
// Best-available fallback: a single-device driver routes every tag to its sole device.
- return _options.Devices.Count == 1 ? _options.Devices[0].HostAddress : "";
+ return options.Devices.Count == 1 ? options.Devices[0].HostAddress : "";
}
// ---- IPerCallHostResolver ----
@@ -810,8 +1305,10 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
/// . The parent word is masked to its native width before the
/// bit operation so a sign-extended decode never corrupts the high bits.
///
- private async Task WriteBitInWordAsync(
- AbLegacyDriver.DeviceState device, AbLegacyAddress bitAddress, int bit, object? value, CancellationToken ct)
+ /// The OPC UA status the write is answered with, and the raw transport status (0 = Good) for health.
+ private async Task<(uint Code, int Raw)> WriteBitInWordAsync(
+ AbLegacyDriver.DeviceState device, AbLegacyTagDefinition def, AbLegacyAddress bitAddress, int bit, object? value,
+ CancellationToken ct)
{
// The parent word width follows the file letter: an L-file element is 32-bit, every
// other bit-addressable PCCC file (N/I/O/S/A) is a 16-bit word. bit is already
@@ -824,6 +1321,23 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
var parentAddress = bitAddress with { BitIndex = null };
var parentName = parentAddress.ToLibplctagName();
+ // An indirect bit write (N7:[N7:0]/3) resolves to a different parent word per index value: release the
+ // previous resolution's parent handle so a sweeping index does not keep one per word (review 07 r0 M4). The
+ // per-word RMW gate is kept — removing a gate another writer may hold would let two RMWs of one word race.
+ var arrow = def.Name.IndexOf("=>", StringComparison.Ordinal);
+ if (arrow > 0)
+ {
+ var indirectName = def.Name[..arrow];
+ var previous = device.IndirectLastParent.TryGetValue(indirectName, out var p) ? p : null;
+ device.IndirectLastParent[indirectName] = parentName;
+ if (previous is not null && !string.Equals(previous, parentName, StringComparison.OrdinalIgnoreCase))
+ {
+ var oldGate = device.GetRmwLock(previous);
+ await oldGate.WaitAsync(ct).ConfigureAwait(false);
+ try { EvictParentRuntime(device, previous); } finally { oldGate.Release(); }
+ }
+ }
+
var rmwLock = device.GetRmwLock(parentName);
await rmwLock.WaitAsync(ct).ConfigureAwait(false);
try
@@ -831,7 +1345,11 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
var parentRuntime = await EnsureParentRuntimeAsync(device, parentName, ct).ConfigureAwait(false);
await parentRuntime.ReadAsync(ct).ConfigureAwait(false);
var readStatus = parentRuntime.GetStatus();
- if (readStatus != 0) return AbLegacyStatusMapper.MapStatus(readStatus);
+ if (readStatus != 0)
+ {
+ if (IsNoReply((AbStatus)readStatus)) EvictParentRuntime(device, parentName);
+ return (AbLegacyStatusMapper.MapStatus(readStatus), readStatus);
+ }
// Mask the decoded word to its native width: DecodeValue for a 16-bit Int returns a
// sign-extended int, so a word with bit 15 set decodes negative. Masking pins the
@@ -848,9 +1366,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
parentRuntime.EncodeValue(AbLegacyDataType.Int, bitIndex: null, (short)updated);
await parentRuntime.WriteAsync(ct).ConfigureAwait(false);
var writeStatus = parentRuntime.GetStatus();
- return writeStatus == 0
- ? AbLegacyStatusMapper.Good
- : AbLegacyStatusMapper.MapStatus(writeStatus);
+ if (writeStatus != 0 && IsNoReply((AbStatus)writeStatus)) EvictParentRuntime(device, parentName);
+ return (writeStatus == 0 ? AbLegacyStatusMapper.Good : AbLegacyStatusMapper.MapStatus(writeStatus), writeStatus);
}
finally
{
@@ -885,7 +1402,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
if (parsed.IndirectFileSource is { } fileSrc)
{
var r = await ReadIndexAsync(device, fileSrc, ct).ConfigureAwait(false);
- if (r.Status != 0) return new IndirectResolution(null, r.Status, r.TransportFault);
+ if (r.Status != 0) return new IndirectResolution(null, r.Status, r.TransportFault, r.Opaque);
if (r.Value is < 0 or > MaxIndirectFile)
return new IndirectResolution(null, AbLegacyStatusMapper.BadOutOfRange, false);
fileNumber = (int)r.Value!;
@@ -893,7 +1410,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
if (parsed.IndirectWordSource is { } wordSrc)
{
var r = await ReadIndexAsync(device, wordSrc, ct).ConfigureAwait(false);
- if (r.Status != 0) return new IndirectResolution(null, r.Status, r.TransportFault);
+ if (r.Status != 0) return new IndirectResolution(null, r.Status, r.TransportFault, r.Opaque);
if (r.Value < 0 || r.Value > maxElement)
return new IndirectResolution(null, AbLegacyStatusMapper.BadOutOfRange, false);
word = (int)r.Value!;
@@ -906,8 +1423,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
var concreteText = concrete.ToLibplctagName();
var resolvedName = $"{def.Name}=>{concreteText}";
- // The index moved: drop the previous resolution's handle so a sweeping index does not accumulate one
- // libplctag handle per value it ever took.
+ // The index moved: drop the previous resolution's handle AND its per-key gates (review 07 L7), so a sweeping
+ // index does not accumulate one libplctag handle plus two SemaphoreSlims per value it ever took.
var previous = device.IndirectLastName.TryGetValue(def.Name, out var prior) ? prior : null;
device.IndirectLastName[def.Name] = resolvedName;
if (previous is not null && !string.Equals(previous, resolvedName, StringComparison.OrdinalIgnoreCase))
@@ -915,18 +1432,32 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
var oldLock = device.GetRuntimeLock(previous);
await oldLock.WaitAsync(ct).ConfigureAwait(false);
try { EvictRuntime(device, previous); } finally { oldLock.Release(); }
+ device.ForgetGates(previous);
+ // A concurrent reader of the same indirect tag can have cached a resolution between our TryGetValue and
+ // the write above (a stale one nothing would ever evict). Sweep every OTHER resolution of this tag.
+ var prefix = def.Name + "=>";
+ foreach (var key in device.Runtimes.Keys)
+ {
+ if (!key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(key, resolvedName, StringComparison.OrdinalIgnoreCase)) continue;
+ // Under the key's own gate (review 07 r0 M3), so no reader is mid-operation on the handle we dispose.
+ var gate = device.GetRuntimeLock(key);
+ await gate.WaitAsync(ct).ConfigureAwait(false);
+ try { EvictRuntime(device, key); } finally { gate.Release(); }
+ device.ForgetGates(key);
+ }
}
return new IndirectResolution(def with { Name = resolvedName, Address = concreteText }, 0, false);
}
/// Reads one direct index address as an integer through the shared parent-runtime cache.
- private async Task<(long? Value, uint Status, bool TransportFault)> ReadIndexAsync(
+ private async Task<(long? Value, uint Status, bool TransportFault, bool Opaque)> ReadIndexAsync(
DeviceState device, AbLegacyAddress inner, CancellationToken ct)
{
AbLegacyDataType type;
if (inner.BitIndex is not null) type = AbLegacyDataType.Bit;
else if (inner.FileLetter == "L") type = AbLegacyDataType.Long;
- else if (inner.FileLetter is "F" or "ST") return (null, AbLegacyStatusMapper.BadOutOfRange, false);
+ else if (inner.FileLetter is "F" or "ST") return (null, AbLegacyStatusMapper.BadOutOfRange, false, false);
else type = AbLegacyDataType.Int;
// A bit-addressed index reads its parent word and decodes the bit, like the RMW path does.
@@ -942,35 +1473,52 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
await runtime.ReadAsync(ct).ConfigureAwait(false);
}
catch (OperationCanceledException) { throw; }
+ catch (ConnectionThrottledException) { throw; } // the caller answers it as a fail-fast
catch (AbTransportException ex) when (IsLocalRefusal(ex))
{
- return (null, AbLegacyStatusMapper.BadNotSupported, false);
+ return (null, AbLegacyStatusMapper.BadNotSupported, false, false);
+ }
+ catch (AbTransportException ex) when (IsDeviceRefusal(ex.Status))
+ {
+ // The device answered: the index file/element does not exist. Per-tag, not a comms fault.
+ return (null, AbLegacyStatusMapper.MapStatus(ex.Status), false, false);
+ }
+ catch (AbTransportException ex) when (IsOpaque(ex.Status))
+ {
+ return (null, AbLegacyStatusMapper.MapStatus(ex.Status), false, true);
}
catch
{
EvictParentRuntime(device, runtimeName);
- return (null, AbLegacyStatusMapper.BadCommunicationError, true);
+ return (null, AbLegacyStatusMapper.BadCommunicationError, true, false);
}
var status = runtime.GetStatus();
- if (status != 0)
+ if (status != 0 && IsDeviceRefusal((AbStatus)status))
+ return (null, AbLegacyStatusMapper.MapStatus(status), false, false);
+ if (status != 0 && IsOpaque((AbStatus)status))
+ return (null, AbLegacyStatusMapper.MapStatus(status), false, true);
+ // Only a NO-REPLY status is a transport fault (review 07 r1 N4: Unsupported used to fall in here).
+ if (status != 0 && IsNoReply((AbStatus)status))
{
EvictParentRuntime(device, runtimeName);
- return (null, AbLegacyStatusMapper.MapStatus(status), true);
+ return (null, AbLegacyStatusMapper.MapStatus(status), true, false);
}
+ if (status != 0)
+ return (null, AbLegacyStatusMapper.MapStatus(status), false, false);
var raw = runtime.DecodeValue(type, inner.BitIndex);
try
{
return raw switch
{
- bool b => (b ? 1L : 0L, 0u, false),
- null => (null, AbLegacyStatusMapper.BadOutOfRange, false),
- _ => (Convert.ToInt64(raw), 0u, false),
+ bool b => (b ? 1L : 0L, 0u, false, false),
+ null => (null, AbLegacyStatusMapper.BadOutOfRange, false, false),
+ _ => (Convert.ToInt64(raw), 0u, false, false),
};
}
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException)
{
- return (null, AbLegacyStatusMapper.BadOutOfRange, false);
+ return (null, AbLegacyStatusMapper.BadOutOfRange, false, false);
}
}
finally
@@ -987,37 +1535,61 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
}
}
- private sealed record IndirectResolution(AbLegacyTagDefinition? Def, uint Status, bool TransportFault);
+ private sealed record IndirectResolution(AbLegacyTagDefinition? Def, uint Status, bool TransportFault, bool Opaque = false);
private async Task EnsureParentRuntimeAsync(
AbLegacyDriver.DeviceState device, string parentName, CancellationToken ct)
{
- // Fast path: runtime already cached.
- if (device.ParentRuntimes.TryGetValue(parentName, out var existing)) return existing;
-
- // Slow path: serialise creation per key so concurrent callers don't each create a
- // runtime and one of them gets overwritten + leaked. Only one caller initialises; the
- // others find the entry on the second TryGetValue inside the lock.
- var creationLock = device.GetCreationLock($"parent:{parentName}");
- await creationLock.WaitAsync(ct).ConfigureAwait(false);
- try
- {
- if (device.ParentRuntimes.TryGetValue(parentName, out existing)) return existing;
-
- var runtime = _tagFactory.Create(new AbLegacyTagCreateParams(
+ var timeout = _options.Timeout;
+ return await CreateSingleFlightAsync(device, device.ParentRuntimes, parentName, () => _tagFactory.Create(
+ new AbLegacyTagCreateParams(
Gateway: device.ParsedAddress.Gateway,
Port: device.ParsedAddress.Port,
CipPath: device.EffectiveCipPath,
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: parentName,
- Timeout: _options.Timeout));
- await InitializeOrKeepRefusedAsync(runtime, () => device.ParentRuntimes[parentName] = runtime, ct)
- .ConfigureAwait(false);
+ Timeout: timeout)), ct).ConfigureAwait(false);
+ }
+
+ ///
+ /// Thrown when a cold connect is refused because the device's window is open
+ /// — a fail-fast that costs no wire traffic. Reads and writes answer it with BadCommunicationError.
+ ///
+ internal sealed class ConnectionThrottledException(string message) : Exception(message);
+
+ ///
+ /// Returns the cached runtime for , or creates and initialises one. The COLD path is
+ /// single-flight per DEVICE (, review 07 P7): concurrent callers for
+ /// different cold tags of one device connect one at a time, so a down device sees one connect attempt instead
+ /// of one per caller, and inside an open backoff window no attempt is made at all
+ /// (). The HOT path (cached handle) never touches the gate. The
+ /// caller records the outcome on the device's backoff (once — recording here as well would double-count).
+ ///
+ private async Task CreateSingleFlightAsync(
+ DeviceState device,
+ System.Collections.Concurrent.ConcurrentDictionary table,
+ string key,
+ Func create,
+ CancellationToken ct)
+ {
+ if (table.TryGetValue(key, out var existing)) return existing;
+
+ await device.ConnectGate.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ // Re-check under the gate: a sibling caller may have connected this very key while we waited.
+ if (table.TryGetValue(key, out existing)) return existing;
+ if (device.IsThrottled(Clock()))
+ throw new ConnectionThrottledException(
+ $"AbLegacy connect for '{key}' on {device.Options.HostAddress} throttled: the device is in its reconnect backoff window.");
+
+ var runtime = create();
+ await InitializeOrKeepRefusedAsync(runtime, () => device.Cache(table, key, runtime), ct).ConfigureAwait(false);
return runtime;
}
finally
{
- creationLock.Release();
+ device.ConnectGate.Release();
}
}
@@ -1087,6 +1659,20 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
_driverInstanceId, def.DeviceHostAddress, def.Name, def.Address, device.Options.PlcFamily);
}
+ ///
+ /// A deterministic refusal (BadNotSupported) is answered the same way every time, so it is logged ONCE per
+ /// tag and operation — a polled tag must not flood the log, and the refusal never touches driver health.
+ ///
+ private void WarnRefusalOnce(DeviceState device, AbLegacyTagDefinition def, string operation, string reason,
+ uint status = AbLegacyStatusMapper.BadNotSupported)
+ {
+ if (!device.RefusalLogged.TryAdd($"{operation}:{def.Name}", 0)) return;
+ // The status the operation is actually answered with (review 07 r0 M8) — not always BadNotSupported.
+ _logger.LogWarning(
+ "AbLegacy {Operation} refused (0x{Status:X8}). Driver={DriverInstanceId} Device={DeviceHostAddress} Reference={Reference} Address={Address} Reason={Reason}",
+ operation, status, _driverInstanceId, def.DeviceHostAddress, def.Name, def.Address, reason);
+ }
+
/// The create parameters for a tag's own runtime — shared by creation and the write-refusal check, so
/// the refusal is asked about exactly the runtime a write would otherwise create.
private AbLegacyTagCreateParams CreateParamsFor(DeviceState device, AbLegacyTagDefinition def, AbLegacyAddress parsed) =>
@@ -1101,30 +1687,35 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
private async Task EnsureTagRuntimeAsync(
DeviceState device, AbLegacyTagDefinition def, CancellationToken ct)
{
- // Fast path: runtime already cached.
if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing;
- // Slow path: serialise creation per tag name so concurrent callers for the same tag
- // (server read path + poll loop) don't both create a runtime and one gets leaked.
- var creationLock = device.GetCreationLock($"tag:{def.Name}");
- await creationLock.WaitAsync(ct).ConfigureAwait(false);
- try
- {
- if (device.Runtimes.TryGetValue(def.Name, out existing)) return existing;
+ var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily)
+ ?? throw new InvalidOperationException(
+ $"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}' for PLC family {device.Options.PlcFamily}.");
+ var createParams = CreateParamsFor(device, def, parsed);
+ return await CreateSingleFlightAsync(device, device.Runtimes, def.Name, () => _tagFactory.Create(createParams), ct)
+ .ConfigureAwait(false);
+ }
- var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily)
- ?? throw new InvalidOperationException(
- $"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}' for PLC family {device.Options.PlcFamily}.");
-
- var runtime = _tagFactory.Create(CreateParamsFor(device, def, parsed));
- await InitializeOrKeepRefusedAsync(runtime, () => device.Runtimes[def.Name] = runtime, ct)
- .ConfigureAwait(false);
- return runtime;
- }
- finally
- {
- creationLock.Release();
- }
+ ///
+ /// Re-resolves a tag's runtime once its operation gate is HELD (review 07 r0 M3): the handle fetched before
+ /// the wait may since have been evicted and disposed (a failed sibling call, or an indirect index sweep), and
+ /// using it would fail spuriously as a comms fault. Returns the live cached handle, or connects a fresh one.
+ ///
+ private async Task CurrentRuntimeUnderLockAsync(
+ DeviceState device, AbLegacyTagDefinition def, IAbLegacyTagRuntime fetched, CancellationToken ct)
+ {
+ if (device.Runtimes.TryGetValue(def.Name, out var live))
+ return live;
+ // A resolved INDIRECT key that is gone was swept because the index moved (review 07 r1 N3). Reconnecting it
+ // would resurrect a stale resolution under a gate the sweep already forgot (a later caller would get a fresh
+ // gate for the same handle). Null = the caller answers this one call neutrally; the next poll re-resolves.
+ if (def.Name.Contains("=>", StringComparison.Ordinal)) return null;
+ // Evicted between the fetch and the lock: a native runtime refused at init is cached too, so absence means
+ // it really was released. Connect again (single-flight per device).
+ // A retired (torn-down) state never reconnects: the in-flight call finishes on what it has.
+ if (device.IsRetired) return fetched;
+ return await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
}
///
@@ -1158,6 +1749,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
///
public void Dispose()
{
+ var (retired, teardownEpoch) = RetireTables(bumpEpoch: true);
// ValueTask.ConfigureAwait(false).GetAwaiter().GetResult() — drains the poll engine
// shutdown on the current thread without capturing the SynchronizationContext. The
// engine cancels every loop CTS up-front then either completes immediately (no
@@ -1165,20 +1757,17 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
// already-shutting-down loop tasks. We swallow exceptions so a buggy poll-loop teardown
// can't poison the rest of the disposal chain.
try { _poll.DisposeAsync().ConfigureAwait(false).GetAwaiter().GetResult(); } catch { }
- foreach (var state in _devices.Values)
+ foreach (var state in retired)
try { state.ProbeCts?.Cancel(); } catch { }
- JoinProbeLoopsSync();
- foreach (var state in _devices.Values)
- {
- state.ProbeCts?.Dispose();
- state.ProbeCts = null;
- state.ProbeTask = null;
- state.DisposeRuntimes();
- }
- _devices.Clear();
- _tagsByRawPath.Clear();
+ JoinProbeLoopsSync(retired);
+ foreach (var state in retired) state.Retire();
_resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses
- _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
+ // A newer InitializeAsync that ran while this teardown was still joining owns health now.
+ lock (_lifecycleLock)
+ {
+ if (teardownEpoch == _lifecycleEpoch)
+ _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
+ }
}
///
@@ -1222,7 +1811,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
/// avoids the check-then-act race present on a plain Dictionary: two concurrent
/// EnsureTagRuntimeAsync callers for the same key both miss the lookup on a
/// plain dict and both create + store a runtime, leaking the loser. Access is guarded
- /// by a per-key creation semaphore () so exactly one
+ /// by the per-device connect gate () so exactly one
/// runtime is created per tag name.
///
public System.Collections.Concurrent.ConcurrentDictionary Runtimes { get; } =
@@ -1240,21 +1829,62 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
public System.Collections.Concurrent.ConcurrentDictionary IndirectLastName { get; } =
new(StringComparer.OrdinalIgnoreCase);
- ///
- /// Per-key creation locks for and .
- /// A caller holds this before the TryGetValue + Create + InitializeAsync + TryAdd
- /// sequence so that a concurrent caller waits rather than creating a duplicate runtime
- /// that would be leaked on .
- ///
- private readonly System.Collections.Concurrent.ConcurrentDictionary _creationLocks = new(StringComparer.OrdinalIgnoreCase);
+ /// Indirect bit-write tag name to the parent word it last read-modify-wrote, so a moved index releases
+ /// the previous parent handle.
+ public System.Collections.Concurrent.ConcurrentDictionary IndirectLastParent { get; } =
+ new(StringComparer.OrdinalIgnoreCase);
///
- /// Gets or creates the creation lock for the specified key.
+ /// Per-device connect gate (review 07 P7): every COLD runtime create + initialise on this device runs under
+ /// it, so connects are single-flight per device — a down device sees one attempt at a time, not one per
+ /// concurrent caller — and a concurrent caller for the same key finds the winner's runtime instead of
+ /// creating (and leaking) a duplicate. A cached runtime never touches it. Not disposed on retirement, for
+ /// the same reason as the per-key gates (an in-flight caller may still release it).
///
- /// The lock key.
- /// The semaphore slim for the key.
- public SemaphoreSlim GetCreationLock(string key) =>
- _creationLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
+ public SemaphoreSlim ConnectGate { get; } = new(1, 1);
+
+ ///
+ /// Per-device reconnect throttle: opened after consecutive comms faults,
+ /// 1 s doubling to 30 s, reset by any answer from the device (data path or probe). Not internally
+ /// synchronised — every touch is under .
+ ///
+ private readonly ConnectionBackoff _backoff = new(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(30));
+
+ private readonly object _backoffLock = new();
+ private int _faultStreak;
+
+ /// True while the device's backoff window is open: operations fail fast without the wire.
+ /// The current UTC time.
+ /// when no attempt should be made now.
+ public bool IsThrottled(DateTime nowUtc)
+ {
+ lock (_backoffLock) return !_backoff.ShouldAttempt(nowUtc);
+ }
+
+ /// The device answered (a Good value or a deterministic refusal): reset the streak and the window.
+ public void RecordAnswered()
+ {
+ lock (_backoffLock)
+ {
+ _faultStreak = 0;
+ _backoff.RecordSuccess();
+ }
+ }
+
+ ///
+ /// A comms fault: the th consecutive one marks the device down — opens
+ /// (or, after a failed retry, lengthens) the backoff window and restarts the streak.
+ ///
+ /// The current UTC time (the window is measured from here).
+ public void RecordCommsFault(DateTime nowUtc)
+ {
+ lock (_backoffLock)
+ {
+ if (++_faultStreak < FaultsToMarkDeviceDown) return;
+ _faultStreak = 0;
+ _backoff.RecordFailure(nowUtc);
+ }
+ }
private readonly System.Collections.Concurrent.ConcurrentDictionary _rmwLocks = new();
@@ -1323,28 +1953,92 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
///
public bool FirstNonZeroStatusLogged { get; set; }
+ /// Coalesced block shapes the device refused; their members read singly from then on (bounded by the
+ /// distinct subscription shapes, released with the state).
+ public System.Collections.Concurrent.ConcurrentDictionary RefusedBlocks { get; } = new(StringComparer.Ordinal);
+
+ /// Consecutive refusals per coalesced block shape not yet remembered in
+ /// (a success clears the count).
+ public System.Collections.Concurrent.ConcurrentDictionary BlockRefusals { get; } = new(StringComparer.Ordinal);
+
+ /// Tag operations already warned about for a deterministic refusal (once-per-tag-and-operation log).
+ public System.Collections.Concurrent.ConcurrentDictionary RefusalLogged { get; } = new(StringComparer.Ordinal);
+
/// Tags already warned about for a malformed address (once-per-tag log).
public System.Collections.Concurrent.ConcurrentDictionary MalformedAddressLogged { get; } = new();
///
- /// Disposes all cached tag runtimes.
+ /// Drops the per-runtime and creation gates of a runtime key that is no longer in use (a moved indirect
+ /// resolution). The gates are removed, not disposed: a caller that already holds a reference finishes on
+ /// it, and the next user of the key simply gets a fresh gate.
///
- public void DisposeRuntimes()
+ /// The runtime key whose gates to drop.
+ public void ForgetGates(string runtimeKey)
{
- foreach (var r in Runtimes.Values) r.Dispose();
- Runtimes.Clear();
- foreach (var r in ParentRuntimes.Values) r.Dispose();
- ParentRuntimes.Clear();
+ _runtimeLocks.TryRemove(runtimeKey, out _);
+ }
+
+ /// The number of per-key gates currently held (runtime + RMW). Tests only.
+ internal int GateCount => _runtimeLocks.Count + _rmwLocks.Count;
+
+ /// Test seam: invoked with the runtime key just before an operation waits on its gate (after the
+ /// handle was fetched), so a test can act at exactly that point instead of sleeping.
+ internal Action? BeforeOperationGate { get; set; }
+
+ private volatile bool _retired;
+
+ /// True once this state has been unpublished and released; nothing may be cached on it any more.
+ public bool IsRetired => _retired;
+
+ ///
+ /// Caches a freshly initialised runtime under . A concurrent creator that won the
+ /// race keeps its runtime (the loser is disposed, never overwritten-and-leaked). A runtime cached AFTER
+ /// drained the table — a read that was already in flight when shutdown ran — is
+ /// removed and disposed here, because nothing would ever release it otherwise.
+ ///
+ /// The runtime table ( or ).
+ /// The runtime key.
+ /// The initialised runtime.
+ public void Cache(System.Collections.Concurrent.ConcurrentDictionary table,
+ string key, IAbLegacyTagRuntime runtime)
+ {
+ if (!table.TryAdd(key, runtime))
+ {
+ if (!ReferenceEquals(table.GetValueOrDefault(key), runtime))
+ {
+ try { runtime.Dispose(); } catch { /* best-effort */ }
+ return;
+ }
+ }
+ // The ConcurrentDictionary write above is a full fence, so this read cannot be satisfied before it:
+ // either Retire's drain sees the entry, or this check sees the retirement.
+ if (_retired && table.TryRemove(new KeyValuePair(key, runtime)))
+ {
+ try { runtime.Dispose(); } catch { /* best-effort */ }
+ }
+ }
+
+ ///
+ /// Retires the state: marks it, disposes the probe CTS and every cached runtime. Idempotent. The
+ /// per-key gates are deliberately NOT disposed: an operation that resolved
+ /// this state before it was unpublished may still be waiting on or releasing one, and disposing it under
+ /// that caller turned a clean teardown into s. A SemaphoreSlim whose
+ /// AvailableWaitHandle was never touched holds no unmanaged resource; the gates go with the state.
+ ///
+ public void Retire()
+ {
+ _retired = true;
+ var cts = ProbeCts;
+ ProbeCts = null;
+ ProbeTask = null;
+ try { cts?.Dispose(); } catch { /* best-effort */ }
+ foreach (var key in Runtimes.Keys)
+ if (Runtimes.TryRemove(key, out var r))
+ try { r.Dispose(); } catch { /* best-effort */ }
+ foreach (var key in ParentRuntimes.Keys)
+ if (ParentRuntimes.TryRemove(key, out var r))
+ try { r.Dispose(); } catch { /* best-effort */ }
IndirectLastName.Clear();
- // Dispose + clear the per-parent RMW gates and the per-runtime/creation locks so
- // ReinitializeAsync cycles don't orphan their SemaphoreSlim instances (each leaks a
- // wait handle once contended).
- foreach (var sem in _rmwLocks.Values) sem.Dispose();
- _rmwLocks.Clear();
- foreach (var sem in _creationLocks.Values) sem.Dispose();
- _creationLocks.Clear();
- foreach (var sem in _runtimeLocks.Values) sem.Dispose();
- _runtimeLocks.Clear();
}
}
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs
index 9dfd9707..418ead02 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs
@@ -97,6 +97,7 @@ public static class AbLegacyDriverFactoryExtensions
ProbeAddress = dto.Probe?.ProbeAddress ?? "S:0",
},
Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000),
+ CoalesceReads = dto.CoalesceReads,
// Absent ⇒ null ("use the default" = Libplctag, resolved by AbLegacyTransportResolver);
// a bad value still fails exactly as any other bad enum field does.
Transport = string.IsNullOrWhiteSpace(dto.Transport)
@@ -157,6 +158,9 @@ public static class AbLegacyDriverFactoryExtensions
///
public string? Transport { get; init; }
+ /// Gets or sets whether adjacent scalar reads are coalesced; absent means .
+ public bool? CoalesceReads { get; init; }
+
///
/// Gets or sets the list of devices to connect to.
///
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs
index 172e1dea..ac46d8a7 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs
@@ -55,6 +55,10 @@ public sealed class AbLegacyDriverProbe : IDriverProbe
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
if (opts is null) return new(false, "Config JSON deserialized to null.", null);
+ // The authored "Admin UI probe timeout" (probeTimeoutSeconds) tightens the caller's budget. It was parsed by
+ // nothing before: Test Connect always ran on the caller's budget alone.
+ timeout = EffectiveTimeout(timeout, opts.ProbeTimeoutSeconds);
+
var (parsed, firstDevice) = ExtractTarget(opts);
if (parsed is null || string.IsNullOrWhiteSpace(parsed.Gateway) || parsed.Port <= 0)
return new(false, "Config has no host/port to probe.", null);
@@ -169,6 +173,22 @@ public sealed class AbLegacyDriverProbe : IDriverProbe
/// Whether the status proves the controller answered.
internal static bool IsReachableStatus(AbStatus s) => s is AbStatus.Ok or AbStatus.NotFound;
+ ///
+ /// The deadline the probe runs under: the caller's budget, tightened by the config's
+ /// when that is positive and shorter (same rule as the
+ /// Galaxy probe). A non-positive caller budget is floored to 1 s first.
+ ///
+ /// The budget the host handed the probe.
+ /// The config's probeTimeoutSeconds; ignored when not positive.
+ /// The effective probe deadline.
+ internal static TimeSpan EffectiveTimeout(TimeSpan callerTimeout, int configuredSeconds)
+ {
+ var caller = callerTimeout > TimeSpan.Zero ? callerTimeout : TimeSpan.FromSeconds(1);
+ if (configuredSeconds <= 0) return caller;
+ var configured = TimeSpan.FromSeconds(configuredSeconds);
+ return configured < caller ? configured : caller;
+ }
+
private static (AbLegacyHostAddress? parsed, AbLegacyDeviceOptions? device) ExtractTarget(AbLegacyDriverOptions opts)
{
// Parse the first device's ab:// host address to extract the gateway IP + EIP port.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs
index 502f6038..e41152a1 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs
@@ -50,6 +50,20 @@ public interface IAbLegacyTagRuntime : IDisposable
/// Optional bit index for bit-level access.
/// The value to encode.
void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value);
+
+ ///
+ /// Encodes EVERY element of a multi-element tag for the next , at the element width
+ /// of . is already element-typed by the driver
+ /// (short[] for Int/AnalogInt, int[] for Long, float[] for Float) and its length must
+ /// equal the runtime's element count (), so no element of
+ /// the write comes from a stale read buffer. Bit and structured element arrays are not encodable.
+ ///
+ /// The PCCC element data type.
+ /// The typed elements, exactly element-count long.
+ /// The length differs from the element count, or the array's element type
+ /// does not match .
+ /// has no flat array encoding.
+ void EncodeArray(AbLegacyDataType type, Array values);
}
public interface IAbLegacyTagFactory
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyStatusTranslation.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyStatusTranslation.cs
index 3d762e84..45855904 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyStatusTranslation.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyStatusTranslation.cs
@@ -23,6 +23,15 @@ internal static class LibplctagLegacyStatusTranslation
Status.ErrorPartial => AbStatus.Partial,
Status.ErrorAbort => AbStatus.Abort,
Status.ErrorBadReply => AbStatus.BadReply,
+ // A reply ARRIVED but carries an error libplctag does not break out: it folds EVERY non-zero PCCC STS and CIP
+ // general status into PLCTAG_ERR_REMOTE_ERR / ERR_BAD_STATUS. That covers the PLC refusing (element past the
+ // end of the file) AND a routing hop reporting the end device is gone (CIP 0x01/0x0204 unconnected send timed
+ // out, 0x07 connection lost, PCCC STS 0x30 remote node missing). So it is neither a no-reply comms fault
+ // (review 07 r0 I1 — it must not fall into the BadConnection default, which black-outs coalesced neighbours and
+ // trips fail-fast) nor proof of reachability (review 07 r1 N1). AbStatus has no dedicated "opaque remote error"
+ // value and Driver.Ab.Transport is Lane K's, so this maps to the existing BadReply — "a reply we cannot
+ // classify" — which the driver treats as OPAQUE (AbLegacyDriver.IsOpaque). OPC UA: BadCommunicationError.
+ Status.ErrorRemoteErr or Status.ErrorBadStatus => AbStatus.BadReply,
_ => AbStatus.BadConnection,
};
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs
index 751e9701..3fd98a76 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs
@@ -15,6 +15,9 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
{
private readonly Tag _tag;
+ /// The element count the tag was created with (1 for a scalar).
+ private readonly int _count;
+
/// Why a write to this tag is refused before the wire (), else null.
private readonly string? _writeRefusal;
@@ -32,6 +35,7 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
public LibplctagLegacyTagRuntime(AbLegacyTagCreateParams p)
{
_tag = BuildTag(p);
+ _count = Math.Max(1, p.ElementCount);
_writeRefusal = WriteRefusal(p);
}
@@ -244,6 +248,32 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
}
}
+ ///
+ public void EncodeArray(AbLegacyDataType type, Array values)
+ {
+ ArgumentNullException.ThrowIfNull(values);
+ if (values.Length != _count)
+ throw new ArgumentException($"Array write of {values.Length} elements to a {_count}-element tag.", nameof(values));
+ // Same offsets DecodeArray reads. EVERY element is set, so the buffer libplctag sends holds nothing from the
+ // last read (a scalar Set*(0, ...) used to re-send elements 1..n-1 as they were last read).
+ switch (type, values)
+ {
+ case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt, short[] s):
+ for (var i = 0; i < s.Length; i++) _tag.SetInt16(i * 2, s[i]);
+ break;
+ case (AbLegacyDataType.Long, int[] l):
+ for (var i = 0; i < l.Length; i++) _tag.SetInt32(i * 4, l[i]);
+ break;
+ case (AbLegacyDataType.Float, float[] f):
+ for (var i = 0; i < f.Length; i++) _tag.SetFloat32(i * 4, f[i]);
+ break;
+ case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Long or AbLegacyDataType.Float, _):
+ throw new ArgumentException($"{values.GetType().Name} is not the element type of a {type} array.", nameof(values));
+ default:
+ throw new NotSupportedException($"AbLegacyDataType {type} has no array write encoding.");
+ }
+ }
+
/// Disposes the tag and releases its resources.
public void Dispose() => _tag.Dispose();
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs
index 9a20acc0..8c372e5d 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs
@@ -448,6 +448,32 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime
}
}
+ ///
+ public void EncodeArray(AbLegacyDataType type, Array values)
+ {
+ ArgumentNullException.ThrowIfNull(values);
+ if (values.Length != _count)
+ throw new ArgumentException($"Array write of {values.Length} elements to a {_count}-element tag.", nameof(values));
+ // Same layout as DecodeArray: element i at i * elementSize. Every element is overwritten, so nothing the
+ // write sends comes from the last read.
+ switch (type, values)
+ {
+ case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt, short[] s):
+ for (var i = 0; i < s.Length; i++) BinaryPrimitives.WriteInt16LittleEndian(SliceW(i * 2, 2), s[i]);
+ break;
+ case (AbLegacyDataType.Long, int[] l):
+ for (var i = 0; i < l.Length; i++) BinaryPrimitives.WriteInt32LittleEndian(SliceW(i * 4, 4), l[i]);
+ break;
+ case (AbLegacyDataType.Float, float[] f):
+ for (var i = 0; i < f.Length; i++) BinaryPrimitives.WriteSingleLittleEndian(SliceW(i * 4, 4), f[i]);
+ break;
+ case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Long or AbLegacyDataType.Float, _):
+ throw new ArgumentException($"{values.GetType().Name} is not the element type of a {type} array.", nameof(values));
+ default:
+ throw new NotSupportedException($"AbLegacyDataType {type} has no array write encoding.");
+ }
+ }
+
///
/// Releases the lease on the shared device connection (the last release closes it) without waiting:
/// closing does I/O (Forward Close / UnRegisterSession) and the driver disposes under a per-tag lock.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7AddressParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7AddressParser.cs
similarity index 100%
rename from src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7AddressParser.cs
rename to src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7AddressParser.cs
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagDefinitionFactory.cs
index a10c2c7d..94457fa5 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagDefinitionFactory.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagDefinitionFactory.cs
@@ -48,8 +48,23 @@ public static class S7TagDefinitionFactory
/// The mapped definition when this returns .
/// when is a valid S7 address object.
public static bool FromTagConfig(string tagConfig, string rawPath, out S7TagDefinition def)
+ => FromTagConfig(tagConfig, rawPath, out def, out _);
+
+ ///
+ /// , also reporting WHY a structurally
+ /// valid blob was refused by (an unparseable address, or a shape the
+ /// driver cannot serve). stays for a JSON-level
+ /// refusal (missing keys, bad enum), which explains on its own.
+ ///
+ /// The authored equipment-tag TagConfig JSON.
+ /// The tag's RawPath — becomes the definition's identity (Name).
+ /// The mapped definition when this returns .
+ /// The shape/address rejection reason, when that is why it refused.
+ /// when the driver can serve the tag.
+ public static bool FromTagConfig(string tagConfig, string rawPath, out S7TagDefinition def, out string? shapeRejection)
{
def = null!;
+ shapeRejection = null;
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
try
{
@@ -78,7 +93,7 @@ public static class S7TagDefinitionFactory
// "writable" defaults to true when absent (today's value); node-level authz still governs
// writes. Honouring the key makes read-only equipment tags authorable.
var writable = TagConfigJson.ReadWritable(root);
- def = new S7TagDefinition(
+ var mapped = new S7TagDefinition(
Name: rawPath,
Address: address,
DataType: dataType,
@@ -86,6 +101,15 @@ public static class S7TagDefinitionFactory
StringLength: stringLength == 0 ? MaxStringLength : stringLength,
WriteIdempotent: false,
ArrayCount: arrayCount);
+ // The address must parse and the shape must be one the driver can serve. These used to be
+ // init-time guards that THREW and faulted every tag on the PLC; refusing here skips only
+ // this tag, and the editor + deploy gate (which ask this method) refuse it too.
+ if (S7TagShapeRules.Validate(mapped, out _) is { } reason)
+ {
+ shapeRejection = reason;
+ return false;
+ }
+ def = mapped;
return true;
}
catch (JsonException) { return false; }
@@ -162,7 +186,9 @@ public static class S7TagDefinitionFactory
/// reject — a structurally unparseable or non-object document, an absent / non-string /
/// blank address, an absentdataType (see ), a
/// present-but-invalid dataType (the runtime enum read is STRICT, so a
- /// typo rejects the tag), or an out-of-range stringLength on a String tag. Such a tag
+ /// typo rejects the tag), an out-of-range stringLength on a String tag, or a shape
+ /// refuses (an unparseable address, a wrong access width, a mis-typed
+ /// Timer/Counter, an unsupported array). Such a tag
/// is skipped by the driver and reads BadNodeIdUnknown forever. Empty when the mapper accepts
/// the blob, when it is blank, or when it is not an equipment-tag object. Never throws.
///
@@ -184,7 +210,13 @@ public static class S7TagDefinitionFactory
// The accept gate: whatever the driver reads draws nothing. The RAW reference is handed to the
// mapper — the driver reads the stored blob, not a trimmed copy of it.
- if (FromTagConfig(reference, InspectionRawPath, out _)) return warnings;
+ if (FromTagConfig(reference, InspectionRawPath, out _, out var shapeRejection)) return warnings;
+
+ if (shapeRejection is not null)
+ {
+ warnings.Add(shapeRejection + " The driver will skip this tag and it will read BadNodeIdUnknown.");
+ return warnings;
+ }
try
{
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagShapeRules.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagShapeRules.cs
new file mode 100644
index 00000000..17a9bf48
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagShapeRules.cs
@@ -0,0 +1,149 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
+
+///
+/// The single authority on which S7 tag shapes the driver can serve. Consulted by
+/// , so the AdminUI editor, the deploy gate
+/// () and the driver's own init all refuse the same
+/// tags with the same words.
+///
+///
+///
+/// These rules used to be init-time guards in S7Driver that THREW. One mis-authored tag
+/// (DB1.DBZ0, DB1.DBW0 typed Float64, T5 typed Int32) faulted the
+/// whole driver, and the actor retried that same init every 10 s forever, darkening every tag on
+/// the PLC. The editor and the deploy gate checked none of it, and a CSV import or API blob
+/// bypasses the editor entirely. A rejected tag is now skipped on its own (it reads
+/// BadNodeIdUnknown) and the deploy gate warns about it.
+///
+///
+/// Every rule rejects a tag that could never be served correctly. None of them can newly break
+/// a tag that used to work: each one either faulted init, or failed (or silently mis-decoded)
+/// every read of that tag.
+///
+///
+public static class S7TagShapeRules
+{
+ ///
+ /// S7 data types addressed by START BYTE (the B suffix) rather than by an access-width
+ /// suffix. The driver decodes them from a contiguous byte buffer, so a wide tag authored against a
+ /// non-byte suffix (DBW/DBD/...) would mis-frame its value.
+ ///
+ private static readonly HashSet ByteAnchoredDataTypes =
+ [
+ S7DataType.Int64,
+ S7DataType.UInt64,
+ S7DataType.Float64,
+ S7DataType.String,
+ S7DataType.DateTime,
+ S7DataType.WString,
+ S7DataType.Char,
+ S7DataType.WChar,
+ S7DataType.Dtl,
+ S7DataType.Date,
+ S7DataType.Tod,
+ S7DataType.Time,
+ S7DataType.S5Time,
+ ];
+
+ /// True when is a wide/structured type read from a byte-anchored block.
+ /// The declared data type.
+ /// for the byte-anchored types.
+ public static bool IsByteAnchored(S7DataType dataType) => ByteAnchoredDataTypes.Contains(dataType);
+
+ ///
+ /// Judges one tag definition. Returns when the driver can serve it, or
+ /// the operator-facing reason it cannot.
+ ///
+ /// The mapped tag definition.
+ /// The parsed address when this returns .
+ /// The rejection reason, or when the tag is servable.
+ public static string? Validate(S7TagDefinition def, out S7ParsedAddress parsed)
+ {
+ ArgumentNullException.ThrowIfNull(def);
+ parsed = default;
+
+ S7ParsedAddress addr;
+ try
+ {
+ addr = S7AddressParser.Parse(def.Address);
+ }
+ catch (FormatException ex)
+ {
+ return $"S7 address '{def.Address}' does not parse: {ex.Message}";
+ }
+
+ var isWide = IsByteAnchored(def.DataType);
+ var isArray = def.ArrayCount is >= 1;
+
+ // Wide-type arrays have no decoder.
+ if (isWide && isArray)
+ return $"S7 {def.DataType} arrays are not supported. Use a scalar {def.DataType} tag, or an " +
+ "Int16/UInt16/Int32/UInt32/Float32/Byte/Bool element type for an array.";
+
+ // Timer / Counter type constraints: a timer decodes to Float64 seconds, a counter to an Int32 count.
+ if (addr.Area is S7Area.Timer && def.DataType != S7DataType.Float64)
+ return $"S7 Timer address '{def.Address}' is typed {def.DataType}. Timer tags must be " +
+ "DataType=Float64 (decoded to seconds, read-only).";
+ if (addr.Area is S7Area.Counter && def.DataType != S7DataType.Int32)
+ return $"S7 Counter address '{def.Address}' is typed {def.DataType}. Counter tags must be " +
+ "DataType=Int32 (decoded to a count, read-only).";
+ if (addr.Area is S7Area.Timer or S7Area.Counter)
+ {
+ if (isArray)
+ return $"S7 Timer/Counter address '{def.Address}' cannot be an array.";
+ if (def.Writable)
+ return $"S7 Timer/Counter address '{def.Address}' is declared writable. Timer/Counter tags " +
+ "are read-only; set Writable=false.";
+ parsed = addr;
+ return null;
+ }
+
+ // Wide/structured scalars on DB/M/I/Q must be byte-anchored (DBB/MB/IB/QB).
+ if (isWide)
+ {
+ if (addr.Size != S7Size.Byte)
+ return $"S7 {def.DataType} at '{def.Address}' is not byte-addressed. Wide/structured types " +
+ "must be byte-addressed (DBB/MB/IB/QB), e.g. 'DB1.DBB0', 'MB4'.";
+ parsed = addr;
+ return null;
+ }
+
+ // Narrow types: the declared type must match the address's access width, or every read fails
+ // (or decodes garbage) with a type mismatch.
+ var required = def.DataType switch
+ {
+ S7DataType.Bool => S7Size.Bit,
+ S7DataType.Byte => S7Size.Byte,
+ S7DataType.Int16 or S7DataType.UInt16 => S7Size.Word,
+ _ => S7Size.DWord, // Int32 / UInt32 / Float32
+ };
+ if (addr.Size != required)
+ return $"S7 {def.DataType} at '{def.Address}' has the wrong access width ({addr.Size}). " +
+ $"A {def.DataType} tag needs a {SuffixHint(required)} address.";
+
+ if (isArray && def.DataType == S7DataType.Bool)
+ {
+ // Element i of a Bool array is bit 0 of byte (start + i): the array is byte-granular. A
+ // non-zero bit offset used to be silently ignored (DB1.DBX0.3 returned bit 0 of bytes 0..n).
+ if (addr.BitOffset != 0)
+ return $"S7 Bool array at '{def.Address}' starts at bit {addr.BitOffset}. A Bool array reads " +
+ "one byte per element (bit 0 of each byte), so it must start at bit 0 (e.g. 'DB1.DBX0.0').";
+ // A byte-granular write would overwrite the 7 neighbouring bits of every byte it touches.
+ if (def.Writable)
+ return $"S7 Bool array at '{def.Address}' is declared writable. Bool array writes are not " +
+ "supported (a byte-granular write would clobber neighbouring bits); set Writable=false " +
+ "or write the bits as individual scalar Bool tags.";
+ }
+
+ parsed = addr;
+ return null;
+ }
+
+ private static string SuffixHint(S7Size size) => size switch
+ {
+ S7Size.Bit => "bit (DBX/M/I/Q n.b)",
+ S7Size.Byte => "byte (DBB/MB/IB/QB)",
+ S7Size.Word => "word (DBW/MW/IW/QW)",
+ _ => "double-word (DBD/MD/ID/QD)",
+ };
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
index 8659565a..cbf94e6b 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
@@ -59,7 +59,7 @@ public sealed class S7Driver
_plcFactory = plcFactory ?? new S7PlcFactory();
_logger = logger ?? NullLogger.Instance;
_resolver = new EquipmentTagRefResolver(
- r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
+ r => TagsByRawPath.TryGetValue(r, out var t) ? t : null);
// 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;
@@ -128,13 +128,6 @@ public sealed class S7Driver
/// OPC UA StatusCode used for a genuine device fault (CPU error, hardware fault).
private const uint StatusBadDeviceFailure = 0x808B0000u;
- // v3: the RawPath → definition table. Keyed by RawPath (the v3 tag identity, == def.Name),
- // case-sensitive ordinal. Built in InitializeAsync from _options.RawTags via the pure factory.
- private readonly Dictionary _tagsByRawPath = new(StringComparer.Ordinal);
- // Pre-parsed S7 address per tag, keyed by RawPath (== def.Name). Populated alongside
- // _tagsByRawPath at init so a config typo fails fast here rather than as a per-read fault.
- private readonly Dictionary _parsedByRawPath = new(StringComparer.Ordinal);
-
// Resolves a read/write/subscribe fullReference (a RawPath in v3) to a tag definition by a single
// authored-table hit; a miss is a miss (the driver surfaces BadNodeIdUnknown, never a parse attempt).
private readonly EquipmentTagRefResolver _resolver;
@@ -208,6 +201,12 @@ public sealed class S7Driver
/// Clock the connect backoff is measured on. Internal so tests step it deterministically.
internal Func BackoffClock { get; set; } = static () => DateTime.UtcNow;
+ ///
+ /// Clock the read/write passes and the init publish stamp timestamps and
+ /// LastSuccessfulRead with. Internal so a test can step it and assert a STRICT advance.
+ ///
+ internal Func UtcNow { get; set; } = static () => DateTime.UtcNow;
+
///
/// 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
@@ -215,6 +214,27 @@ public sealed class S7Driver
/// Guarded by .
///
private (DriverState State, string Message)? _writeFault;
+
+ ///
+ /// The message of the last Degraded the WRITE side published, or null. A clean write batch
+ /// retires a Degraded carrying exactly this message (a good write proves the comms fault it
+ /// reported is gone) and leaves any other Degraded to the read pass. Guarded by .
+ ///
+ private string? _writeOwnedError;
+
+ ///
+ /// Tags whose refusal has already been logged this generation — the once-per-tag Warning. Cleared
+ /// by every initialize publish.
+ ///
+ private readonly ConcurrentDictionary _refusalWarned = new(StringComparer.Ordinal);
+
+ ///
+ /// Tags the span planner must not group (R07:P2): members of a span the CPU refused as a block —
+ /// the ones that were refused on their own, or every member when none was. They are read
+ /// individually for the rest of the generation; a re-initialize (a corrected DB, a fixed tag)
+ /// groups everything again. Guarded by .
+ ///
+ private readonly HashSet _noGroup = new(StringComparer.Ordinal);
private bool _disposed;
///
@@ -225,62 +245,84 @@ public sealed class S7Driver
///
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
- // InitializeAsync is reachable on a live driver with no teardown first (the IDriver contract
- // allows it, and a reconnect retry can overlap a running attempt). Stop the previous probe loop and drop the previous connection
- // before they are overwritten below. Deliberately NOT ShutdownAsync: that also drops _poll and the
- // subscriptions, which are owned by the shutdown path (the actor re-subscribes after every successful init).
- await ReleaseConnectionAsync().ConfigureAwait(false);
- Health = new DriverHealth(DriverState.Initializing, null, null);
+ // Serialised against every other lifecycle operation: DriverInstanceActor dispatches initialize
+ // on a fire-and-forget Task.Run and a reconnect retry can start a second attempt while the
+ // first is still inside OpenAsync. Without the gate attempt A's freshly opened Plc and probe
+ // loop were overwritten by attempt B and never released (R07:H5) — the Modbus pattern.
+ await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false);
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);
+ await InitializeCoreAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ _lifecycleGate.Release();
+ }
+ }
- // v3: build the RawPath → definition table from the authored raw tags BEFORE the config
- // guards run (they now iterate the mapped table). Each entry's TagConfig blob is mapped by
- // the pure factory; the entry's WriteIdempotent flag is threaded onto the def (it lives on
- // the RawTagEntry, not inside the blob). A mapper miss is skipped (logged), never thrown —
- // one bad tag must not fail the whole driver init.
- _tagsByRawPath.Clear();
- _parsedByRawPath.Clear();
- _resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
- _skippedTags.BeginRebuild(_options.RawTags.Count);
- foreach (var entry in _options.RawTags)
+ /// The lifecycle-gated body of .
+ /// The driver config.
+ /// Caller cancellation.
+ /// A task that completes when the attempt has published, been superseded, or failed.
+ private async Task InitializeCoreAsync(string driverConfigJson, CancellationToken cancellationToken)
+ {
+ // This attempt's generation. Every health write and table/connection publish below is stamped
+ // with it and dropped once the generation has moved on: an attempt abandoned by its caller can
+ // still be running when a ShutdownAsync (which only waits a bounded time for the lifecycle
+ // gate) starts, and it must never publish over it.
+ var gen = BeginGeneration();
+
+ // InitializeAsync is reachable on a live driver with no teardown first (the IDriver contract
+ // allows it). Stop the previous probe loop and drop the previous connection before they are
+ // replaced below. Deliberately NOT a full teardown: that also drops _poll and the subscriptions,
+ // which are owned by the shutdown path (the actor re-subscribes after every successful init).
+ await ReleaseConnectionAsync(gen).ConfigureAwait(false);
+ TryWriteHealth(gen, new DriverHealth(DriverState.Initializing, null, null));
+
+ // 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. Local until published: a superseded attempt must
+ // not leave its options behind for the live connection to reopen against.
+ var options = _options;
+ IS7Plc? plc = null;
+ try
+ {
+ if (HasConfigBody(driverConfigJson))
+ options = S7DriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
+
+ // v3: build the RawPath → definition table from the authored raw tags. Each entry's TagConfig
+ // blob is mapped by the pure factory, which also applies S7TagShapeRules (the address must
+ // parse; wide arrays, mis-typed Timer/Counter, wrong access widths, writable Timer/Counter or
+ // Bool arrays and offset Bool arrays are refused). A refused tag is SKIPPED (logged, reported
+ // through ISkippedTagReporter, reads BadNodeIdUnknown), never thrown: these used to be init
+ // guards that faulted every tag on the PLC and made the actor retry the same init forever.
+ // The entry's WriteIdempotent flag is threaded onto the def (it lives on the RawTagEntry).
+ // Built into fresh dictionaries and published by ONE reference swap — a retained poll pass
+ // reading the old table never sees it half-cleared.
+ var tags = new Dictionary(StringComparer.Ordinal);
+ var parsed = new Dictionary(StringComparer.Ordinal);
+ var skipped = new List<(string RawPath, string Reason)>();
+ foreach (var entry in options.RawTags)
{
- if (S7TagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
- _tagsByRawPath[entry.RawPath] = def with { WriteIdempotent = entry.WriteIdempotent };
+ if (S7TagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def, out var shapeRejection)
+ && S7AddressParser.TryParse(def.Address, out var parsedAddress))
+ {
+ tags[entry.RawPath] = def with { WriteIdempotent = entry.WriteIdempotent };
+ parsed[entry.RawPath] = parsedAddress;
+ }
else
{
- _skippedTags.Skip(entry.RawPath, "TagConfig did not map to a definition");
- _logger.LogWarning(
- "S7 tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
- _driverInstanceId, entry.RawPath);
+ skipped.Add((entry.RawPath, shapeRejection ?? "TagConfig did not map to a definition"));
}
}
- _skippedTags.Commit();
-
- // 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);
+ plc = _plcFactory.Create(options);
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
- cts.CancelAfter(_options.Timeout);
+ cts.CancelAfter(options.Timeout);
await plc.OpenAsync(cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
@@ -288,97 +330,160 @@ public sealed class S7Driver
// 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 (for 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.");
+ $"S7 connect to {options.Host}:{options.Port} timed out after " +
+ $"{(int)options.Timeout.TotalMilliseconds} ms.");
}
- catch
+
+ // Publish everything this attempt built under _gate (no pass can be mid-flight against a
+ // half-published state) and only while this attempt is still the current generation.
+ await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
{
- // 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;
+ if (!IsCurrentGeneration(gen))
+ {
+ // The outer finally disposes the unpublished plc; the catch writes no health (the
+ // generation moved on). Thrown, not returned: a caller must never read "initialized"
+ // into an attempt that published nothing.
+ throw new InvalidOperationException(
+ "S7 initialize was superseded by a newer lifecycle operation before it published; " +
+ "its connection was discarded.");
+ }
+
+ _options = options;
+ Volatile.Write(ref _tables, new TagTables(tags, parsed));
+ _refusalWarned.Clear();
+ _noGroup.Clear();
+ _skippedTags.BeginRebuild(options.RawTags.Count);
+ foreach (var (rawPath, reason) in skipped)
+ {
+ _skippedTags.Skip(rawPath, reason);
+ _logger.LogWarning(
+ "S7 tag skipped: {Reason} Driver={DriverInstanceId} RawPath={RawPath}",
+ reason, _driverInstanceId, rawPath);
+ }
+ _skippedTags.Commit();
+
+ Plc = plc;
+ plc = null; // published — no longer this method's to dispose
+ _plcDead = false;
+ _connectBackoff.RecordSuccess();
+ Volatile.Write(ref _negotiatedPduSize, Plc.MaxPduSize);
+ _initialized = true;
+ TryWriteHealth(gen, new DriverHealth(DriverState.Healthy, UtcNow(), null));
+
+ // 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. Tracked (not fire-and-forget) so
+ // a teardown can await it before disposing its CTS; Task.Run gets None so the handle
+ // is always awaitable — the loop's own token check handles cancellation.
+ if (options.Probe.Enabled)
+ {
+ var probeCts = new CancellationTokenSource();
+ _probeCts = probeCts;
+ _probeTask = Task.Run(() => ProbeLoopAsync(probeCts.Token), CancellationToken.None);
+ }
}
-
- Plc = plc;
- _plcDead = false;
- _connectBackoff.RecordSuccess();
- Volatile.Write(ref _negotiatedPduSize, plc.MaxPduSize);
-
- // 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. Keyed by RawPath
- // (== def.Name) so Read/Write can pick up the pre-parsed address.
- foreach (var t in _tagsByRawPath.Values)
+ finally
{
- _parsedByRawPath[t.Name] = S7AddressParser.Parse(t.Address); // throws FormatException
+ _gate.Release();
}
- _initialized = true;
- Health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
_logger.LogInformation("S7Driver connected. Driver={DriverInstanceId} Host={Host} CPU={CpuType} PduSize={PduSize} Tags={TagCount}",
- _driverInstanceId, _options.Host, _options.CpuType, _negotiatedPduSize, _tagsByRawPath.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);
- }
+ _driverInstanceId, options.Host, options.CpuType, _negotiatedPduSize, tags.Count);
}
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;
- Volatile.Write(ref _negotiatedPduSize, 0);
- _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);
+ // A superseded attempt reports nothing: the generation that replaced it owns health.
+ if (TryWriteHealth(gen, new DriverHealth(DriverState.Faulted, null, ex.Message)))
+ {
+ _options = options;
+ _logger.LogError(ex, "S7Driver connect failed. Driver={DriverInstanceId} Host={Host}", _driverInstanceId, options.Host);
+ }
throw;
}
+ finally
+ {
+ // A connection this attempt opened but did not publish (failed or superseded) is released
+ // here, so a retry from the caller never leaks the TcpClient.
+ if (plc is not null) { try { plc.Dispose(); } catch { /* best-effort */ } }
+ }
}
///
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
- // InitializeAsync re-parses driverConfigJson, so a config change delivered here is
+ // Holds the lifecycle gate across BOTH halves so no other lifecycle operation slips between
+ // them. InitializeCoreAsync 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);
+ await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await ShutdownCoreAsync().ConfigureAwait(false);
+ await InitializeCoreAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ _lifecycleGate.Release();
+ }
}
///
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.
+ // Bounded wait for the lifecycle gate: a wedged initialize (a connect that never returns, or an
+ // attempt its caller abandoned) must not turn shutdown into a hang. Past the bound we tear down
+ // anyway — the generation bump makes the still-running attempt discard instead of publish.
+ var gated = false;
+ try { gated = await _lifecycleGate.WaitAsync(LifecycleGateTimeout, cancellationToken).ConfigureAwait(false); }
+ catch (OperationCanceledException) { /* the caller stopped waiting for the gate — still tear down */ }
+ if (!gated)
+ _logger.LogWarning(
+ "S7 lifecycle gate still held at shutdown; tearing down anyway. Driver={DriverInstanceId}",
+ _driverInstanceId);
+ try
+ {
+ await ShutdownCoreAsync().ConfigureAwait(false);
+ }
+ finally
+ {
+ if (gated) _lifecycleGate.Release();
+ }
+ }
+
+ ///
+ /// The complete teardown: the poll engine (every subscription), the probe loop, the connection,
+ /// the sticky write fault and the per-generation memos. It is its own generation, so its final
+ /// health write can never clobber an initialize that started after it.
+ ///
+ /// A task that completes once everything is released.
+ private async Task ShutdownCoreAsync()
+ {
+ var gen = BeginGeneration();
// Drain the polling overlay first — the engine cancels each loop and awaits it before
- // disposing its CTS (drain-before-dispose), so no poll iteration touches _gate
- // after this returns.
- await _poll.DisposeAsync().ConfigureAwait(false);
- await ReleaseConnectionAsync().ConfigureAwait(false);
- _writeFault = null;
- _connectBackoff.RecordSuccess();
- Health = new DriverHealth(DriverState.Unknown, Health.LastSuccessfulRead, null);
+ // disposing its CTS (drain-before-dispose), so no poll iteration touches _gate after this.
+ try { await _poll.DisposeAsync().ConfigureAwait(false); }
+ catch (Exception ex) { _logger.LogWarning(ex, "S7 poll engine drain failed. Driver={DriverInstanceId}", _driverInstanceId); }
+ await ReleaseConnectionAsync(gen).ConfigureAwait(false);
+ lock (_healthLock)
+ {
+ if (_generation != gen) return; // a newer initialize owns the state now
+ _writeFault = null;
+ _writeOwnedError = null;
+ _connectBackoff.RecordSuccess();
+ Health = new DriverHealth(DriverState.Unknown, Health.LastSuccessfulRead, null);
+ }
}
///
/// Cancels + bounded-joins the probe loop, then disposes its CTS and the PLC connection. The
- /// connection part of (everything but the poll engine and health).
+ /// connection part of a teardown (everything but the poll engine and health). Releases only
+ /// while is still current — a teardown that ran past the lifecycle gate
+ /// must not dispose a connection a NEWER initialize already published.
///
- private async Task ReleaseConnectionAsync()
+ /// The generation of the lifecycle operation doing the release.
+ /// A task that completes once the prior state is released.
+ private async Task ReleaseConnectionAsync(long gen)
{
var probeCts = _probeCts;
var probeTask = _probeTask;
@@ -393,11 +498,6 @@ public sealed class S7Driver
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;
-
// Swap the connection out under _gate so a Read/Write/poll pass that already holds the gate (they
// re-read Plc inside it) can never see a disposed Plc mid-swap. Bounded: a wedged holder must not hang
// teardown, in which case we proceed exactly as the un-gated path always did.
@@ -409,6 +509,24 @@ public sealed class S7Driver
_driverInstanceId, DrainTimeout);
try
{
+ if (!IsCurrentGeneration(gen)) return; // superseded: the newer generation released (or owns) this state
+
+ // The probe loop has now observed cancellation and released _gate — safe to dispose the CTS.
+ if (ReferenceEquals(_probeCts, probeCts))
+ {
+ probeCts?.Dispose();
+ }
+ else
+ {
+ // An initialize published (and started a probe loop) after this release took its
+ // snapshot — a teardown whose generation bump raced the publish block. Cancel that
+ // loop too; it exits on its next gate wait / delay. Not joined: it needs _gate, which
+ // we hold. Its CTS is left to the GC rather than disposed under a running loop.
+ try { _probeCts?.Cancel(); } catch { /* best-effort */ }
+ probeCts?.Dispose();
+ }
+ _probeCts = null;
+ _probeTask = null;
_initialized = false;
_plcDead = false;
try { Plc?.Dispose(); } catch { /* best-effort — tearing down anyway */ }
@@ -421,6 +539,101 @@ public sealed class S7Driver
}
}
+ // ---- lifecycle generation ----
+
+ ///
+ /// Guards / /
+ /// / against each other (R07:H5, the
+ /// Modbus _lifecycleGate pattern). Distinct from , which serialises I/O.
+ /// Never disposed: a Dispose racing a still-running ShutdownAsync must not make it throw.
+ ///
+ private readonly SemaphoreSlim _lifecycleGate = new(1, 1);
+
+ ///
+ /// Bounded wait the teardown paths take on before tearing down anyway.
+ /// Internal so a test can shorten it instead of waiting the production 10 s.
+ ///
+ internal TimeSpan LifecycleGateTimeout { get; set; } = TimeSpan.FromSeconds(10);
+
+ /// Serialises generation bumps against generation-stamped health writes.
+ private readonly object _healthLock = new();
+
+ ///
+ /// Lifecycle generation: bumped by every initialize and teardown. Health writes and
+ /// table/connection publishes carry the generation they were computed under and are dropped
+ /// once it has moved on. Written under .
+ ///
+ private long _generation;
+
+ /// Starts a new lifecycle generation.
+ /// The new generation.
+ private long BeginGeneration()
+ {
+ lock (_healthLock) return ++_generation;
+ }
+
+ /// The current lifecycle generation.
+ private long CurrentGeneration => Interlocked.Read(ref _generation);
+
+ /// True while is still the current lifecycle generation.
+ /// The generation to test.
+ /// when nothing has superseded it.
+ private bool IsCurrentGeneration(long gen) => CurrentGeneration == gen;
+
+ /// Writes only while is current.
+ /// The generation the health was computed under.
+ /// The health to publish.
+ /// when written.
+ private bool TryWriteHealth(long gen, DriverHealth health)
+ {
+ lock (_healthLock)
+ {
+ if (_generation != gen) return false;
+ Health = health;
+ return true;
+ }
+ }
+
+ ///
+ /// Derives and writes a new health from the current one, atomically, only while
+ /// is current — for the passes that preserve LastSuccessfulRead.
+ ///
+ /// The generation the update was computed under.
+ /// Maps the current health to the new one; returning null writes nothing.
+ /// when written.
+ private bool TryUpdateHealth(long gen, Func update)
+ {
+ lock (_healthLock)
+ {
+ if (_generation != gen) return false;
+ if (update(Health) is not { } next) return false;
+ Health = next;
+ return true;
+ }
+ }
+
+ /// The published RawPath → definition and RawPath → parsed-address tables, swapped as one.
+ /// RawPath → definition.
+ /// RawPath → pre-parsed address.
+ private sealed record TagTables(
+ IReadOnlyDictionary Tags,
+ IReadOnlyDictionary Parsed)
+ {
+ /// The empty tables a driver starts with.
+ public static readonly TagTables Empty = new(
+ new Dictionary(StringComparer.Ordinal),
+ new Dictionary(StringComparer.Ordinal));
+ }
+
+ /// The live tag tables; replaced by reference at init, never mutated in place.
+ private TagTables _tables = TagTables.Empty;
+
+ /// The live RawPath → definition table.
+ private IReadOnlyDictionary TagsByRawPath => Volatile.Read(ref _tables).Tags;
+
+ /// The live RawPath → pre-parsed address table.
+ private IReadOnlyDictionary ParsedByRawPath => Volatile.Read(ref _tables).Parsed;
+
///
public DriverHealth GetHealth() => Health;
@@ -437,13 +650,6 @@ public sealed class S7Driver
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
- /// .
- ///
///
/// PDU size the CPU negotiated on the most recent successful connect, in bytes; 0 before the first
/// connect and after shutdown. Snapshotted at connect so it stays readable while the handle is
@@ -474,143 +680,6 @@ public sealed class S7Driver
: new Dictionary();
}
- private static readonly HashSet ByteAnchoredDataTypes = new()
- {
- S7DataType.Int64,
- S7DataType.UInt64,
- S7DataType.Float64,
- S7DataType.String,
- S7DataType.DateTime,
- S7DataType.WString,
- S7DataType.Char,
- S7DataType.WChar,
- S7DataType.Dtl,
- S7DataType.Date,
- S7DataType.Tod,
- S7DataType.Time,
- S7DataType.S5Time,
- };
-
- ///
- /// 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 _tagsByRawPath.Values)
- {
- 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) 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). 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 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.");
- }
- }
- }
-
- ///
- /// 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 _tagsByRawPath.Values)
- {
- 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();
-
// ---- IReadable ----
// The batch is served in two passes under ONE gate acquisition. First S7ReadSpanPlanner groups
@@ -639,15 +708,22 @@ public sealed class S7Driver
// InvalidOperationException from the not-initialized check.
ArgumentNullException.ThrowIfNull(fullReferences);
RequireInitialized();
- var now = DateTime.UtcNow;
+ var now = UtcNow();
var results = new DataValueSnapshot[fullReferences.Count];
// Health is decided ONCE, at the end of the pass, from every outcome the pass observed —
// never per item. See ReadPassOutcome.
var outcome = new ReadPassOutcome();
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ // The lifecycle generation this pass runs in — its health write is dropped if a teardown or
+ // re-initialize has moved on by the time it lands. Captured under _gate, where the init
+ // publish happens, and re-checked against _initialized: a pass that passed RequireInitialized
+ // and then waited out a teardown must not reopen a connection on a shut-down driver.
+ var gen = CurrentGeneration;
try
{
+ RequireInitialized();
+
// (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.
@@ -664,7 +740,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);
+ TryUpdateHealth(gen, h => new DriverHealth(DriverState.Degraded, h.LastSuccessfulRead, ex.Message));
return results;
}
@@ -682,6 +758,15 @@ public sealed class S7Driver
// Indexes the per-tag path must serve: everything, when grouping is off; otherwise the
// planner's leftovers (Timer/Counter) plus any tag whose address won't parse.
var perTag = new List();
+ // Indexes that must take the per-tag path WITHOUT the multi-var pass: tags the planner may
+ // not group (R07:P2) and the members of a span refused this pass. Batching them into a
+ // multi-var request would just refuse the whole chunk again.
+ List? forcedPerTag = null;
+ // Members of each span refused this pass, and which of them were then refused on their own.
+ List>? refusedSpans = null;
+ // Members of each multi-var chunk that fell back per tag this pass (R07:P2's multi-var sibling).
+ List>? multiVarFallbacks = null;
+ HashSet? refusedAlone = null;
if (_options.SpanReadMaxGapBytes >= 0)
{
@@ -689,6 +774,11 @@ public sealed class S7Driver
for (var i = 0; i < fullReferences.Count; i++)
{
if (resolved[i] is not { } tag) continue;
+ if (_noGroup.Contains(tag.Name))
+ {
+ (forcedPerTag ??= []).Add(i);
+ continue;
+ }
if (!TryGetParsedAddress(tag, out var addr))
{
// Defensive: every tag pre-parses at init. A tag that somehow didn't goes
@@ -712,7 +802,19 @@ public sealed class S7Driver
perTag.Add(span.Members[0].OriginalIndex);
continue;
}
- await ReadSpanAsync(plc, span, results, now, outcome, cancellationToken).ConfigureAwait(false);
+ // Fail fast once an earlier span marked the handle dead (R07:P1): every
+ // further wire op on it would pay the full deadline under the gate.
+ if (_plcDead)
+ {
+ foreach (var member in span.Members) results[member.OriginalIndex] = DeadHandleSnapshot(now);
+ continue;
+ }
+ if (await ReadSpanAsync(plc, span, results, now, outcome, cancellationToken).ConfigureAwait(false)
+ is { } refusedMembers)
+ {
+ (refusedSpans ??= []).Add(refusedMembers);
+ (forcedPerTag ??= []).AddRange(refusedMembers);
+ }
}
perTag.AddRange(plan.LeftoverIndexes);
}
@@ -735,11 +837,23 @@ public sealed class S7Driver
if (_options.SpanReadMaxGapBytes >= 0)
{
perTag = await ReadLeftoversByMultiVarAsync(
- plc, resolved, perTag, results, now, outcome, cancellationToken).ConfigureAwait(false);
+ plc, resolved, perTag, results, now, outcome, cancellationToken,
+ fallback => (multiVarFallbacks ??= []).Add(fallback)).ConfigureAwait(false);
+ }
+ if (forcedPerTag is not null)
+ {
+ perTag.AddRange(forcedPerTag);
+ perTag.Sort();
}
foreach (var i in perTag)
{
+ // Fail fast once the handle is dead (R07:P1) — see the span loop.
+ if (_plcDead)
+ {
+ results[i] = DeadHandleSnapshot(now);
+ continue;
+ }
try
{
var value = await ReadOneAsync(plc, resolved[i]!, cancellationToken).ConfigureAwait(false);
@@ -760,7 +874,38 @@ public sealed class S7Driver
}
catch (Exception ex)
{
- results[i] = new DataValueSnapshot(null, RecordReadFailure(ex, outcome), null, now);
+ if ((refusedSpans is not null || multiVarFallbacks is not null) && IsRefusal(ex)) (refusedAlone ??= []).Add(i);
+ results[i] = new DataValueSnapshot(null, RecordReadFailure(ex, outcome, [resolved[i]!.Name]), null, now);
+ }
+ }
+
+ // Remember what must not be grouped again (R07:P2). Skipped when the handle died mid-pass:
+ // the per-member reads then proved nothing about which member the CPU refuses.
+ // A multi-var chunk that fell back: memo only the members refused ON THEIR OWN — without one,
+ // the chunk's failure explains nothing about its members, and they keep batching.
+ if (multiVarFallbacks is not null && refusedAlone is not null && !_plcDead)
+ {
+ foreach (var members in multiVarFallbacks)
+ {
+ var bad = members.Where(refusedAlone.Contains).ToList();
+ if (bad.Count == 0) continue;
+ foreach (var m in bad) _noGroup.Add(resolved[m]!.Name);
+ _logger.LogInformation(
+ "S7 CPU refused a multi-var read of {MemberCount} tag(s); reading {Tags} individually from now on. Driver={DriverInstanceId}",
+ members.Count, string.Join(", ", bad.Select(m => resolved[m]!.Name)), _driverInstanceId);
+ }
+ }
+
+ if (refusedSpans is not null && !_plcDead)
+ {
+ foreach (var members in refusedSpans)
+ {
+ var bad = members.Where(m => refusedAlone?.Contains(m) == true).ToList();
+ var memo = bad.Count > 0 ? bad : [.. members];
+ foreach (var m in memo) _noGroup.Add(resolved[m]!.Name);
+ _logger.LogInformation(
+ "S7 CPU refused a grouped read of {MemberCount} tag(s); reading {Tags} individually from now on. Driver={DriverInstanceId}",
+ members.Count, string.Join(", ", memo.Select(m => resolved[m]!.Name)), _driverInstanceId);
}
}
}
@@ -793,10 +938,10 @@ public sealed class S7Driver
state = wf.State;
error = wf.Message;
}
- Health = new DriverHealth(
+ TryUpdateHealth(gen, h => new DriverHealth(
state,
- outcome.AnySuccess ? now : Health.LastSuccessfulRead,
- error);
+ outcome.AnySuccess ? now : h.LastSuccessfulRead,
+ error));
}
_gate.Release();
@@ -866,6 +1011,13 @@ public sealed class S7Driver
/// Records that one tag read successfully.
public void RecordSuccess() => AnySuccess = true;
+ ///
+ /// Records that the CPU answered for one tag without serving it (a refusal, an undecodable
+ /// buffer). Not a failure — it ranks nothing — but it IS proof of a live device, so it
+ /// advances LastSuccessfulRead exactly like a success.
+ ///
+ public void RecordAnswered() => AnySuccess = true;
+
///
/// Records one failed tag, keeping the worst state and the message that explains it.
///
@@ -973,6 +1125,8 @@ public sealed class S7Driver
/// The batch timestamp, shared by every tag in the batch.
/// The pass's outcome accumulator; every decoded item records into it.
/// Cancellation token for the reads.
+ /// Receives the member indexes of each chunk that fell back per tag, so
+ /// the caller can memo the members then refused on their own (the multi-var sibling of R07:P2).
/// The indexes still owed an individual read, ascending.
private async Task> ReadLeftoversByMultiVarAsync(
IS7Plc plc,
@@ -981,7 +1135,8 @@ public sealed class S7Driver
DataValueSnapshot[] results,
DateTime now,
ReadPassOutcome outcome,
- CancellationToken ct)
+ CancellationToken ct,
+ Action> onChunkFallback)
{
var stillPerTag = new List();
List<(int Index, S7TagDefinition Tag, S7ParsedAddress Addr)>? batchable = null;
@@ -1012,6 +1167,13 @@ public sealed class S7Driver
{
var chunk = batchable.GetRange(start, Math.Min(MultiVarReadMaxItems, batchable.Count - start));
+ // Fail fast once the handle is dead (R07:P1) — see ReadAsync's span loop.
+ if (_plcDead)
+ {
+ foreach (var (index, _, _) in chunk) results[index] = DeadHandleSnapshot(now);
+ continue;
+ }
+
IReadOnlyList answered;
try
{
@@ -1037,12 +1199,24 @@ public sealed class S7Driver
_plcDead = true;
throw;
}
+ catch (Exception ex) when (IsS7TransportLoss(ex))
+ {
+ // A transport loss is the connection's fault, not the batch's: falling back would
+ // send one more doomed per-tag read per member, each paying the full deadline
+ // (R07:P1). Mark the handle dead and fail the chunk (and, via the guards, the rest
+ // of the pass) without I/O.
+ _plcDead = true;
+ outcome.RecordFailure(DriverState.Degraded, ex.Message);
+ foreach (var (index, _, _) in chunk) results[index] = DeadHandleSnapshot(now);
+ continue;
+ }
catch (Exception ex)
{
_logger.LogDebug(ex,
"S7 driver {DriverInstanceId}: multi-var read of {ItemCount} leftover tag(s) failed; " +
"falling back to per-tag reads for that chunk", _driverInstanceId, chunk.Count);
foreach (var (index, _, _) in chunk) stillPerTag.Add(index);
+ onChunkFallback([.. chunk.Select(c => c.Index)]);
continue;
}
@@ -1061,7 +1235,7 @@ public sealed class S7Driver
{
// Decoding is pure and synchronous — no cancellation can surface here, so there
// is deliberately no OperationCanceledException arm to shadow the request one.
- results[index] = new DataValueSnapshot(null, RecordReadFailure(ex, outcome), null, now);
+ results[index] = new DataValueSnapshot(null, RecordReadFailure(ex, outcome, [tag.Name]), null, now);
}
}
}
@@ -1134,8 +1308,12 @@ public sealed class S7Driver
/// The batch timestamp, shared by every tag in the batch.
/// The pass's outcome accumulator; every member records into it.
/// Cancellation token for the read.
- /// A task that completes once every member of the span has been stamped.
- private async Task ReadSpanAsync(
+ ///
+ /// once every member has been stamped; or, when the CPU REFUSED the block
+ /// of a multi-member span (R07:P2 — one member past the DB's end refuses them all), the members'
+ /// original indexes, unstamped, for the caller to read one at a time.
+ ///
+ private async Task?> ReadSpanAsync(
IS7Plc plc, S7ReadSpanPlanner.Span span, DataValueSnapshot[] results, DateTime now,
ReadPassOutcome outcome, CancellationToken ct)
{
@@ -1156,12 +1334,24 @@ public sealed class S7Driver
_plcDead = true;
throw;
}
+ catch (Exception ex) when (span.Members.Count > 1 && IsRefusal(ex))
+ {
+ // The planner's old invariant ("a grouped read cannot fault where the per-tag reads would
+ // have succeeded") is false: the block is the UNION of the members' ranges, so one member
+ // running past the DB's real end, or over-declaring a STRING, makes the CPU refuse every
+ // neighbour with it. Hand the members back for individual reads.
+ _logger.LogDebug(ex,
+ "S7 driver {DriverInstanceId}: CPU refused the block read of {Area} db{Db} @{Start}+{Length}; " +
+ "re-reading its {Count} member(s) individually", _driverInstanceId, span.Area, span.DbNumber,
+ span.StartByte, span.LengthBytes, span.Members.Count);
+ return [.. span.Members.Select(m => m.OriginalIndex)];
+ }
catch (Exception ex)
{
- var status = RecordReadFailure(ex, outcome);
+ var status = RecordReadFailure(ex, outcome, span.Members.Select(m => m.Tag.Name));
foreach (var member in span.Members)
results[member.OriginalIndex] = new DataValueSnapshot(null, status, null, now);
- return;
+ return null;
}
foreach (var member in span.Members)
@@ -1177,9 +1367,10 @@ public sealed class S7Driver
// Decoding is pure and synchronous — no cancellation can surface here, so there is
// deliberately no OperationCanceledException arm to shadow the block-read one above.
results[member.OriginalIndex] =
- new DataValueSnapshot(null, RecordReadFailure(ex, outcome), null, now);
+ new DataValueSnapshot(null, RecordReadFailure(ex, outcome, [member.Tag.Name]), null, now);
}
}
+ return null;
}
///
@@ -1226,7 +1417,7 @@ public sealed class S7Driver
/// The parsed address, valid only when this returns true.
/// true when the address is known or parses.
private bool TryGetParsedAddress(S7TagDefinition tag, out S7ParsedAddress addr)
- => _parsedByRawPath.TryGetValue(tag.Name, out addr) || S7AddressParser.TryParse(tag.Address, out addr);
+ => ParsedByRawPath.TryGetValue(tag.Name, out addr) || S7AddressParser.TryParse(tag.Address, out addr);
///
/// The read-failure classification ladder, shared by the per-tag path and the span path so
@@ -1252,8 +1443,10 @@ public sealed class S7Driver
///
/// The exception observed on a read.
/// The pass's outcome accumulator this failure is recorded into.
+ /// The tags the failure belongs to (every member of a failed span), for the
+ /// once-per-tag refusal Warning.
/// The OPC UA status code every affected tag must be stamped with.
- private uint RecordReadFailure(Exception ex, ReadPassOutcome outcome)
+ private uint RecordReadFailure(Exception ex, ReadPassOutcome outcome, IEnumerable tagNames)
{
switch (ex)
{
@@ -1262,19 +1455,38 @@ public sealed class S7Driver
case S7ValueRangeException:
// A PLC buffer the type codec refuses (uninitialised DTL, non-BCD S5TIME…) is a data
- // problem, not a transport fault — no health change, no connection teardown.
+ // problem, not a transport fault — no health change, no connection teardown. The
+ // bytes came off the wire, so the CPU answered.
+ outcome.RecordAnswered();
return StatusBadOutOfRange;
- case PlcException pex when IsAccessDenied(pex):
+ case var _ when IsAccessDenied(ex):
// 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.
+ // Degraded — per driver-specs.md §5. Matched over the whole chain: S7.Net's READ
+ // paths raise the CPU's item error as a bare Exception, not a PlcException.
+ // It is ALSO an answer from a reachable CPU: LastSuccessfulRead must advance, or a
+ // factory-default CPU (every read denied) trips the comms-loss watchdog into a
+ // reconnect + re-subscribe every 90 s that can never fix a PUT/GET setting.
+ // Faulted still wins the pass (worst state), so the config alert is kept.
+ outcome.RecordAnswered();
outcome.RecordFailure(DriverState.Faulted,
"S7 access denied — enable PUT/GET communication in TIA Portal " +
- $"(Protection & Security) for this CPU. PLC reported: {pex.Message}");
+ $"(Protection & Security) for this CPU. PLC reported: {ex.Message}");
return StatusBadNotSupported;
+ case var _ when IsRefusal(ex):
+ // The CPU answered and refused the item (object does not exist, address out of
+ // range, type inconsistent...) — deterministic, so a retry, a reopen or a Degraded
+ // would all be wrong: per-tag Bad, a once-per-tag Warning, no health write, and it
+ // counts as answered so LastSuccessfulRead advances (R07:H4). An all-refused driver
+ // (wrong DB number) used to sit Degraded with a frozen timestamp and trip the
+ // comms-loss watchdog every 90 s against a reachable PLC.
+ outcome.RecordAnswered();
+ foreach (var name in tagNames) WarnRefusalOnce(name, "read", ex);
+ return StatusBadDeviceFailure;
+
case PlcException pex:
// A genuine device-layer fault (CPU error, hardware fault) — transient enough to
// keep retrying; report BadDeviceFailure and degrade health. A connection-fatal
@@ -1295,10 +1507,10 @@ public sealed class S7Driver
private async Task