From 42472b55493cd57623c8c813c9920d92c2f786fe Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 03:50:47 -0400 Subject: [PATCH] =?UTF-8?q?Auto:=20ablegacy-10=20=E2=80=94=20diagnostic=20?= =?UTF-8?q?counters=20as=20tags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #253 --- docs/Driver.AbLegacy.Cli.md | 9 + docs/drivers/AbLegacy-Diagnostics.md | 112 ++++++ docs/drivers/AbLegacy-Test-Fixture.md | 10 + scripts/e2e/test-ablegacy.ps1 | 48 ++- scripts/smoke/seed-ablegacy-smoke.sql | 7 + .../AbLegacyDiagnosticTags.cs | 261 ++++++++++++++ .../AbLegacyDriver.cs | 153 +++++++- .../AbLegacyDiagnosticsIntegrationTests.cs | 56 +++ .../AbLegacyCapabilityTests.cs | 12 +- .../AbLegacyDiagnosticsTests.cs | 337 ++++++++++++++++++ 10 files changed, 1000 insertions(+), 5 deletions(-) create mode 100644 docs/drivers/AbLegacy-Diagnostics.md create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDiagnosticTags.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyDiagnosticsIntegrationTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDiagnosticsTests.cs diff --git a/docs/Driver.AbLegacy.Cli.md b/docs/Driver.AbLegacy.Cli.md index 44e9f24..b0fe64f 100644 --- a/docs/Driver.AbLegacy.Cli.md +++ b/docs/Driver.AbLegacy.Cli.md @@ -101,8 +101,17 @@ otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a L19:0 -t Long # Timer ACC otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a T4:0.ACC -t TimerElement + +# Diagnostic counter (PR ablegacy-10 / #253). The seven _Diagnostics/ +# addresses live alongside user tags — short-circuit serves them straight from +# the in-process counter store, so no PCCC frame is sent to the PLC. +otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 --address _Diagnostics/RequestCount ``` +The diagnostic surface auto-emits per device — no config required. See +`docs/drivers/AbLegacy-Diagnostics.md` for the full counter table + reset +semantics + collision-rejection rules. + ### `write` ```powershell diff --git a/docs/drivers/AbLegacy-Diagnostics.md b/docs/drivers/AbLegacy-Diagnostics.md new file mode 100644 index 0000000..e5307e3 --- /dev/null +++ b/docs/drivers/AbLegacy-Diagnostics.md @@ -0,0 +1,112 @@ +# AB Legacy diagnostic counters + +Per-device diagnostic counters surface as auto-generated read-only OPC UA +variables under each device's synthetic `_Diagnostics/` folder. HMIs can bind +directly without going through a separate diagnostics RPC. Mirrors the AB CIP +`_System/` pattern from PR abcip-4.3. + +Closes #253 (PR ablegacy-10). + +## The seven counters + +Each device managed by the `AbLegacyDriver` exposes seven read-only nodes under +`AbLegacy//_Diagnostics/`: + +| Name | Type | Semantics | +|---|---|---| +| `RequestCount` | Int64 | Total `ReadAsync` requests issued against this device. One increment per non-diagnostic reference per call, success or failure. | +| `ResponseCount` | Int64 | Successful read responses. | +| `ErrorCount` | Int64 | Failed read responses (any non-Good status). | +| `RetryCount` | Int64 | Retry attempts beyond the first per the PR 9 retry loop. A single read with two retries adds two. | +| `LastErrorCode` | Int32 | Most recent libplctag status code on a failed read; `0` when no error has been seen since the last reset. | +| `LastErrorMessage` | String | Most recent libplctag error message on a failed read; empty when no error has been seen since the last reset. | +| `CommFailures` | Int64 | Count of read failures mapped to `BadCommunicationError`. Spans transient libplctag throws + retried-out chains so operators see a single "wire fell off" counter. | + +**Address shape**: `_Diagnostics//` — +e.g. `_Diagnostics/ab://10.0.0.5/1,0/RequestCount`. + +The `` segment is the canonical `ab://host[:port]/cip-path` +string from `AbLegacyDeviceOptions.HostAddress`. The browse path looks like +`AbLegacy//_Diagnostics/` — the same shape as a +user-config tag node, just under a reserved sibling folder. + +## Reset behaviour + +| Trigger | Effect | +|---|---| +| `ReinitializeAsync` | Every counter for every device resets to zero, plus `LastErrorMessage` clears to empty. | +| `ShutdownAsync` | Same as Reinitialize — counters drop with the device map. | +| Driver process restart | Counters start at zero. | +| Probe transition Stopped→Running | **No automatic reset** — counters are cumulative across reconnect events so operators can spot intermittent links by watching `CommFailures` keep climbing. | + +There is no in-process "reset" RPC at the time of writing. If you need to +clear counters without a redeploy, kick a `ReinitializeAsync` from the Admin +RPC surface — the driver re-EnsureDevice's each host so the freshly registered +counters start at zero. + +## What does *not* increment counters + +Reads against `_Diagnostics//` are **driver-local observability**, +not field traffic — they short-circuit before the libplctag dispatch and do +NOT increment `RequestCount` or any other counter. Otherwise a 1 Hz HMI poll +of `RequestCount` would make the counter chase its own tail. + +Writes against `_Diagnostics/*` are rejected with `BadNotWritable` because +every diagnostic node is `SecurityClassification.ViewOnly` — a misbehaving +SCADA template can't accidentally clobber the diagnostic surface. + +## Collision with user tags + +User-config tags must not shadow the seven reserved diagnostic names and +must not live under the synthetic `_Diagnostics/` folder. Both shapes are +rejected at `InitializeAsync` time with a clear `InvalidOperationException`: + +- A tag named `RequestCount` (or any of the other six reserved names) is + rejected because it would silently never resolve at read time — the + diagnostics short-circuit wins. +- A tag whose `Address` starts with `_Diagnostics/` is rejected because the + whole prefix is owned by the auto-emitted counters. + +Pick a different name (`SiteRequestCount`, `MachineRequestCount`) or a +different address path (real PCCC files like `N7:0`). + +## HMI binding examples + +### OPC UA Client CLI + +```powershell +dotnet run --project src/ZB.MOM.WW.OtOpcUa.Client.CLI -- read ` + -u opc.tcp://localhost:4840 ` + -n "ns=2;s=AbLegacy/ab://10.0.0.5/1,0/_Diagnostics/RequestCount" +``` + +### AB Legacy CLI (driver-direct, no OPC UA layer) + +```powershell +dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli -- read ` + -g "ab://10.0.0.5/1,0" -P Slc500 ` + --address "_Diagnostics/RequestCount" +``` + +The driver-direct path lets you sanity-check the counter without standing up +an OPC UA server — useful when triaging a wire-level issue on the bench. + +### Subscription pattern + +Subscribe to all seven counters at a slow rate (e.g. 5–10 s) on a long-lived +overview dashboard, plus a faster rate (1 s) on `LastErrorMessage` / +`LastErrorCode` when actively debugging a flapping link. The diagnostics +short-circuit makes every read O(1) — there's no penalty for fast polling +of the counter itself, only the OPC UA subscription bookkeeping. + +## Cross-references + +- [`AbLegacyDiagnosticTags.cs`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDiagnosticTags.cs) + — counter store + read short-circuit +- [`AbLegacyDriver.cs`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs) + — increment sites in `ReadAsync`, discovery emission in `DiscoverAsync` +- [`AbLegacy-Test-Fixture.md`](AbLegacy-Test-Fixture.md) — `AbLegacyDiagnosticsTests` + + collision-rejection contract +- [AB CIP `_System/` parallel](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipSystemTagSource.cs) + — same pattern with the CIP-specific six entries (incl. writeable + `_RefreshTagDb` trigger) diff --git a/docs/drivers/AbLegacy-Test-Fixture.md b/docs/drivers/AbLegacy-Test-Fixture.md index de488c5..af9a33a 100644 --- a/docs/drivers/AbLegacy-Test-Fixture.md +++ b/docs/drivers/AbLegacy-Test-Fixture.md @@ -49,6 +49,16 @@ supplies a `FakeAbLegacyTag`. - `AbLegacyHostAndStatusTests` — probe + host-status transitions driven by fake-returned statuses - `AbLegacyDriverTests` — `IDriver` lifecycle +- `AbLegacyDiagnosticsTests` — PR ablegacy-10 / #253 per-device diagnostic + counters: 5 reads (3 ok / 2 fail) → `RequestCount=5`, `ResponseCount=3`, + `ErrorCount=2`; `LastErrorCode` reflects the most recent libplctag status; + `RetryCount` increments per retry attempt beyond the first; counters reset + on `ReinitializeAsync`; discovery emits exactly 7 diagnostic variables per + device under `_Diagnostics/`; collision rejection at `InitializeAsync` for + user tags shadowing reserved names or `_Diagnostics/` addresses; the + `_Diagnostics//` short-circuit returns the live snapshot through + `ReadAsync` without bumping `RequestCount`; two devices keep counters + independent. - `AbLegacyDeadbandTests` — PR 8 per-tag deadband / change filter: absolute-only suppression sequence `[10.0, 10.5, 11.5, 11.6] -> [10.0, 11.5]`, percent-only suppression with a zero-prev short-circuit, both-set logical-OR diff --git a/scripts/e2e/test-ablegacy.ps1 b/scripts/e2e/test-ablegacy.ps1 index 10a43f8..a979a04 100644 --- a/scripts/e2e/test-ablegacy.ps1 +++ b/scripts/e2e/test-ablegacy.ps1 @@ -29,6 +29,16 @@ .PARAMETER BridgeNodeId NodeId at which the server publishes the Address. + +.PARAMETER DiagnosticsRequestCountNodeId + Optional NodeId for the synthetic _Diagnostics//RequestCount variable + emitted by AB Legacy discovery (PR ablegacy-10 / #253). When supplied, the + script runs the DiagnosticsRequestCount assertion: reads the user-tag + BridgeNodeId N times through the OPC UA server, then reads the diagnostic + counter and asserts the value is at least N (a probe loop or a parallel + client may have bumped it by more, so the comparison is `>=`). NodeId form: + ns=;s=AbLegacy//_Diagnostics/RequestCount. Mirrors the + -SystemConnectionStatusNodeId knob on test-abcip.ps1. #> param( @@ -36,7 +46,8 @@ param( [string]$PlcType = "Slc500", [string]$Address = "N7:5", [string]$OpcUaUrl = "opc.tcp://localhost:4840", - [Parameter(Mandatory)] [string]$BridgeNodeId + [Parameter(Mandatory)] [string]$BridgeNodeId, + [string]$DiagnosticsRequestCountNodeId ) $ErrorActionPreference = "Stop" @@ -150,5 +161,40 @@ if ($notifyLines.Count -eq 1) { $results += @{ Passed = $false; Reason = "deadband notify count $($notifyLines.Count)" } } +# PR ablegacy-10 / #253 — diagnostic-counter round-trip assertion. After N reads +# against the user-tag BridgeNodeId the auto-emitted _Diagnostics//RequestCount +# counter must be >= N. The exact equality isn't asserted because a probe loop / +# parallel client may have bumped the counter — the spec is "every read counts". +if ($DiagnosticsRequestCountNodeId) { + Write-Header "DiagnosticsRequestCount (_Diagnostics/RequestCount from $DiagnosticsRequestCountNodeId)" + $diagN = 5 + # Read the first counter snapshot to baseline; the assertion compares delta against + # the N OPC UA reads we issue between snapshots so a noisy probe loop doesn't + # invalidate the test. + $baselineOut = & $opcUaCli.File @($opcUaCli.PrefixArgs) ` + @("read", "-u", $OpcUaUrl, "-n", $DiagnosticsRequestCountNodeId) 2>&1 + $baseline = 0 + if (($baselineOut -join "`n") -match '(\d+)') { $baseline = [int64]$Matches[1] } + + for ($i = 0; $i -lt $diagN; $i++) { + & $opcUaCli.File @($opcUaCli.PrefixArgs) ` + @("read", "-u", $OpcUaUrl, "-n", $BridgeNodeId) | Out-Null + } + + $afterOut = & $opcUaCli.File @($opcUaCli.PrefixArgs) ` + @("read", "-u", $OpcUaUrl, "-n", $DiagnosticsRequestCountNodeId) 2>&1 + $after = 0 + if (($afterOut -join "`n") -match '(\d+)') { $after = [int64]$Matches[1] } + + $delta = $after - $baseline + if ($delta -ge $diagN) { + Write-Pass "DiagnosticsRequestCount delta $delta >= $diagN OPC UA reads" + $results += @{ Passed = $true } + } else { + Write-Fail "DiagnosticsRequestCount delta $delta < $diagN OPC UA reads (baseline=$baseline after=$after)" + $results += @{ Passed = $false; Reason = "diag delta $delta < $diagN" } + } +} + Write-Summary -Title "AB Legacy e2e" -Results $results if ($results | Where-Object { -not $_.Passed }) { exit 1 } diff --git a/scripts/smoke/seed-ablegacy-smoke.sql b/scripts/smoke/seed-ablegacy-smoke.sql index 43387be..4915c11 100644 --- a/scripts/smoke/seed-ablegacy-smoke.sql +++ b/scripts/smoke/seed-ablegacy-smoke.sql @@ -152,3 +152,10 @@ PRINT 'NOTE: default points at the ab_server slc500 Docker fixture with a /1,0'; PRINT ' cip-path (required by ab_server). For real SLC/MicroLogix/PLC-5'; PRINT ' hardware, edit the DriverConfig HostAddress to end with /'; PRINT ' e.g. "ab://:44818/" and re-run this seed.'; +PRINT ''; +PRINT 'PR ablegacy-10 / #253 — diagnostic counters auto-emit per device under'; +PRINT ' AbLegacy//_Diagnostics/. No dbo.Tag rows needed — the'; +PRINT ' driver registers them at DiscoverAsync time. Seven counters per device:'; +PRINT ' RequestCount, ResponseCount, ErrorCount, RetryCount, LastErrorCode,'; +PRINT ' LastErrorMessage, CommFailures. See docs/drivers/AbLegacy-Diagnostics.md'; +PRINT ' for the full surface + reset semantics.'; diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDiagnosticTags.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDiagnosticTags.cs new file mode 100644 index 0000000..4df009f --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDiagnosticTags.cs @@ -0,0 +1,261 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; + +/// +/// PR ablegacy-10 / #253 — diagnostic-counter tag source. Holds per-device live +/// counters (request / response / error / retry / last-error / comm-failures) that +/// the driver surfaces under each device's synthetic _Diagnostics folder. The +/// read path short-circuits before the libplctag dispatch when the incoming +/// reference targets a _Diagnostics/<host>/<name> address — the +/// values come straight from the driver-local counters. +/// +/// +/// Mirrors AbCip's AbCipSystemTagSource pattern (the abcip-4.3 PR that +/// just merged) — same per-device folder, same read-only semantics, but the seven +/// names + their counter shape match the AB-Legacy plan: numerical counters that +/// HMIs can bind directly without a separate diagnostics RPC. Counters are +/// long (Int64) so a long-running deployment can't roll an +/// RequestCount over inside a maintenance window. +/// +/// RequestCount — total +/// requests issued against this device (each non-diagnostic reference counts +/// once per call, success or fail). +/// ResponseCount — successful read responses. +/// ErrorCount — failed read responses (any non-Good status). +/// RetryCount — retry attempts beyond the first per the PR 9 retry +/// loop. Incremented once per extra attempt, not per successful retry. +/// LastErrorCode — most recent libplctag status code on a failed +/// read (0 when no error has been seen since reset). +/// LastErrorMessage — most recent libplctag error message on a +/// failed read (empty when no error has been seen). +/// CommFailures — count of read failures mapped to +/// . Spans transient +/// exceptions + retried-out chains so operators see a single "wire fell off" +/// counter without having to sum across error-code subtotals. +/// +/// +public sealed class AbLegacyDiagnosticTags +{ + /// Address-space prefix the driver stamps on every diagnostic variable's + /// . + public const string DiagnosticsFolderPrefix = "_Diagnostics/"; + + /// Canonical names the diagnostics folder exposes. Keep in lockstep with discovery. + public static readonly IReadOnlyList DiagnosticTagNames = + [ + "RequestCount", + "ResponseCount", + "ErrorCount", + "RetryCount", + "LastErrorCode", + "LastErrorMessage", + "CommFailures", + ]; + + private static readonly HashSet DiagnosticTagNameSet = + new(DiagnosticTagNames, StringComparer.Ordinal); + + private readonly Dictionary _counters = + new(StringComparer.OrdinalIgnoreCase); + private readonly object _lock = new(); + + /// + /// Make sure a slot exists for . Called from + /// so the counters are zero-initialised + /// by the time the first read or probe iteration fires. + /// + public void EnsureDevice(string deviceHostAddress) + { + ArgumentNullException.ThrowIfNull(deviceHostAddress); + lock (_lock) + { + if (!_counters.ContainsKey(deviceHostAddress)) + _counters[deviceHostAddress] = new DiagnosticsCounters(); + } + } + + private DiagnosticsCounters GetOrCreate(string deviceHostAddress) + { + // Fast path: already-tracked device. Slow path: lazy add when a caller hits an + // unregistered host (defensive — production callers all go through EnsureDevice). + lock (_lock) + { + if (!_counters.TryGetValue(deviceHostAddress, out var c)) + { + c = new DiagnosticsCounters(); + _counters[deviceHostAddress] = c; + } + return c; + } + } + + /// Increment RequestCount for . + public void RecordRequest(string deviceHostAddress) + { + ArgumentNullException.ThrowIfNull(deviceHostAddress); + var c = GetOrCreate(deviceHostAddress); + Interlocked.Increment(ref c.Request); + } + + /// Increment ResponseCount for a successful read. + public void RecordResponse(string deviceHostAddress) + { + ArgumentNullException.ThrowIfNull(deviceHostAddress); + var c = GetOrCreate(deviceHostAddress); + Interlocked.Increment(ref c.Response); + } + + /// + /// Increment ErrorCount + record the latest libplctag status code + + /// message for a failed read. = true also bumps + /// CommFailures when the failure mapped to BadCommunicationError. + /// + public void RecordError( + string deviceHostAddress, int libplctagStatus, string? errorMessage, bool commFailure) + { + ArgumentNullException.ThrowIfNull(deviceHostAddress); + var c = GetOrCreate(deviceHostAddress); + Interlocked.Increment(ref c.Error); + if (commFailure) Interlocked.Increment(ref c.CommFailures); + // Atomic int32 store on a 32-bit-aligned field; .NET reference-write atomicity + // covers the message swap. Last-write-wins matches the spec. + Interlocked.Exchange(ref c.LastErrorCode, libplctagStatus); + c.LastErrorMessage = errorMessage ?? string.Empty; + } + + /// Increment RetryCount per retry attempt beyond the first. + public void RecordRetry(string deviceHostAddress) + { + ArgumentNullException.ThrowIfNull(deviceHostAddress); + var c = GetOrCreate(deviceHostAddress); + Interlocked.Increment(ref c.Retry); + } + + /// Snapshot the current counters for a device. Returns zeros for unknown hosts. + public DiagnosticsSnapshot Snapshot(string deviceHostAddress) + { + ArgumentNullException.ThrowIfNull(deviceHostAddress); + DiagnosticsCounters? c; + lock (_lock) + { + _counters.TryGetValue(deviceHostAddress, out c); + } + if (c is null) return new DiagnosticsSnapshot(0, 0, 0, 0, 0, string.Empty, 0); + return new DiagnosticsSnapshot( + Request: Interlocked.Read(ref c.Request), + Response: Interlocked.Read(ref c.Response), + Error: Interlocked.Read(ref c.Error), + Retry: Interlocked.Read(ref c.Retry), + LastErrorCode: Volatile.Read(ref c.LastErrorCode), + LastErrorMessage: c.LastErrorMessage ?? string.Empty, + CommFailures: Interlocked.Read(ref c.CommFailures)); + } + + /// + /// Reset every counter for back to zero. Called + /// from so a config redeploy starts + /// with a clean diagnostic surface. + /// + public void Reset(string deviceHostAddress) + { + ArgumentNullException.ThrowIfNull(deviceHostAddress); + var c = GetOrCreate(deviceHostAddress); + Interlocked.Exchange(ref c.Request, 0); + Interlocked.Exchange(ref c.Response, 0); + Interlocked.Exchange(ref c.Error, 0); + Interlocked.Exchange(ref c.Retry, 0); + Interlocked.Exchange(ref c.LastErrorCode, 0); + c.LastErrorMessage = string.Empty; + Interlocked.Exchange(ref c.CommFailures, 0); + } + + /// Reset every tracked device. Called on full ShutdownAsync. + public void ResetAll() + { + lock (_lock) + { + _counters.Clear(); + } + } + + /// + /// Resolve a _Diagnostics/<host>/<name> reference into a counter + /// value. Returns true when the reference shape matches; + /// carries the counter (or empty string for LastErrorMessage) on success. + /// + public bool TryRead(string fullReference, out object? value) + { + ArgumentNullException.ThrowIfNull(fullReference); + if (!IsDiagnosticAddress(fullReference)) { value = null; return false; } + + var withoutPrefix = fullReference[DiagnosticsFolderPrefix.Length..]; + var slashIdx = withoutPrefix.LastIndexOf('/'); + if (slashIdx <= 0 || slashIdx >= withoutPrefix.Length - 1) { value = null; return false; } + + var host = withoutPrefix[..slashIdx]; + var name = withoutPrefix[(slashIdx + 1)..]; + if (!IsReservedName(name)) { value = null; return false; } + + var snapshot = Snapshot(host); + value = name switch + { + "RequestCount" => snapshot.Request, + "ResponseCount" => snapshot.Response, + "ErrorCount" => snapshot.Error, + "RetryCount" => snapshot.Retry, + "LastErrorCode" => snapshot.LastErrorCode, + "LastErrorMessage" => snapshot.LastErrorMessage, + "CommFailures" => snapshot.CommFailures, + _ => null, + }; + return true; + } + + /// + /// true when targets a node under the synthetic + /// _Diagnostics/ folder. The driver's read path uses this to bypass the + /// libplctag runtime and dispatch to directly. + /// + public static bool IsDiagnosticAddress(string? reference) => + !string.IsNullOrEmpty(reference) + && reference.StartsWith(DiagnosticsFolderPrefix, StringComparison.Ordinal); + + /// + /// true when matches one of the seven reserved + /// diagnostic names. Used by to reject + /// user-config tags that would shadow the driver-emitted counters. + /// + public static bool IsReservedName(string? name) => + !string.IsNullOrEmpty(name) && DiagnosticTagNameSet.Contains(name); + + private sealed class DiagnosticsCounters + { + public long Request; + public long Response; + public long Error; + public long Retry; + public int LastErrorCode; + public string? LastErrorMessage = string.Empty; + public long CommFailures; + } +} + +/// +/// PR ablegacy-10 / #253 — immutable snapshot of one device's diagnostic counters. +/// Returned through when an OPC UA client +/// reads any of the seven _Diagnostics/<host>/<name> variables. +/// +/// Total ReadAsync requests issued against this device. +/// Successful read responses. +/// Failed read responses (any non-Good status). +/// Retry attempts beyond the first per the PR 9 retry loop. +/// Most recent libplctag status code on a failed read. +/// Most recent libplctag error message on a failed read. +/// Count of read failures mapped to BadCommunicationError. +public sealed record DiagnosticsSnapshot( + long Request, + long Response, + long Error, + long Retry, + int LastErrorCode, + string LastErrorMessage, + long CommFailures); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs index 62640c6..a7c2ceb 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -29,6 +29,18 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover private readonly Dictionary _lastPublished = new(StringComparer.OrdinalIgnoreCase); private readonly object _lastPublishedLock = new(); + + /// + /// PR ablegacy-10 / #253 — per-device diagnostic counters surfaced as + /// _Diagnostics/<host>/<name> read-only variables. Updated on + /// every call (success, failure, retry) so HMIs can bind + /// directly without a separate diagnostics RPC. + /// + private readonly AbLegacyDiagnosticTags _diagnosticTags = new(); + + /// Test seam — exposes the live diagnostic-tag source so unit tests can poke counters. + internal AbLegacyDiagnosticTags DiagnosticTags => _diagnosticTags; + private DriverHealth _health = new(DriverState.Unknown, null, null); public event EventHandler? OnDataChange; @@ -153,8 +165,35 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover $"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); + // PR ablegacy-10 / #253 — pre-allocate the diagnostic-counter slot so the + // first read against this device sees zero-initialised counters instead of + // having to lazy-add on the request path. + _diagnosticTags.EnsureDevice(device.HostAddress); + } + foreach (var tag in _options.Tags) + { + // PR ablegacy-10 / #253 — collision rejection. User-config tags must not + // shadow the seven driver-emitted diagnostic names, and they must not live + // under the synthetic _Diagnostics/ folder. Both shapes would silently + // never resolve at read time (the diagnostics short-circuit wins) so we + // reject up front with a clear error rather than letting the operator wonder + // why their tag returns BadNodeIdUnknown. + if (AbLegacyDiagnosticTags.IsDiagnosticAddress(tag.Address)) + { + throw new InvalidOperationException( + $"AbLegacy tag '{tag.Name}' has Address '{tag.Address}' under the reserved " + + $"'_Diagnostics/' namespace; that prefix is owned by the auto-emitted " + + $"diagnostic counters. Choose a different address."); + } + if (AbLegacyDiagnosticTags.IsReservedName(tag.Name)) + { + throw new InvalidOperationException( + $"AbLegacy tag name '{tag.Name}' collides with a reserved diagnostic " + + $"counter ({string.Join(", ", AbLegacyDiagnosticTags.DiagnosticTagNames)}). " + + $"Rename the tag."); + } + _tagsByName[tag.Name] = tag; } - foreach (var tag in _options.Tags) _tagsByName[tag.Name] = tag; // Probe loops — one per device when enabled + probe address configured. if (_options.Probe.Enabled && !string.IsNullOrWhiteSpace(_options.Probe.ProbeAddress)) @@ -179,6 +218,11 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { await ShutdownAsync(cancellationToken).ConfigureAwait(false); + // PR ablegacy-10 / #253 — counters were dropped along with the device map when + // ShutdownAsync called ResetAll; the InitializeAsync below re-EnsureDevice's each + // host so the freshly registered counters start at zero. Belt-and-braces clear + // here in case a downstream override of either method skips the cycle. + _diagnosticTags.ResetAll(); await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false); } @@ -198,6 +242,10 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover // reconnect-driven shutdown) doesn't suppress the very first post-reconnect sample // by comparing it against pre-disconnect state. lock (_lastPublishedLock) { _lastPublished.Clear(); } + // PR ablegacy-10 / #253 — drop every per-device counter so a reinit / redeploy + // starts with a clean diagnostic surface. Reset (per-host) is also exposed so a + // future "clear counters" admin RPC can reach in without a full shutdown. + _diagnosticTags.ResetAll(); _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); } @@ -239,6 +287,25 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover for (var i = 0; i < fullReferences.Count; i++) { var reference = fullReferences[i]; + + // PR ablegacy-10 / #253 — synthetic _Diagnostics// reference; + // serve from the in-process counter store and skip the libplctag dispatch + // entirely. Diagnostic reads do NOT bump RequestCount — they're driver-local + // observability, not field traffic, and counting them would make the + // counter chase its own tail when a subscription polls at 1 Hz. + if (AbLegacyDiagnosticTags.IsDiagnosticAddress(reference)) + { + if (_diagnosticTags.TryRead(reference, out var diagValue)) + { + results[i] = new DataValueSnapshot(diagValue, AbLegacyStatusMapper.Good, now, now); + } + else + { + results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNodeIdUnknown, null, now); + } + continue; + } + if (!_tagsByName.TryGetValue(reference, out var def)) { results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNodeIdUnknown, null, now); @@ -250,6 +317,12 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover continue; } + // PR ablegacy-10 / #253 — bump RequestCount once per non-diagnostic reference, + // success or fail. The retry loop below counts retries through RecordRetry so + // operators can spot a flapping link via the RetryCount counter without us + // double-counting the original attempt as a retry. + _diagnosticTags.RecordRequest(def.DeviceHostAddress); + // PR 9 — per-device retry loop: on transient BadCommunicationError (libplctag throw // OR a non-zero status that maps to BadCommunicationError) retry up to N times. A // terminal mapped status (e.g. BadNodeIdUnknown for a missing PLC tag, BadTypeMismatch @@ -259,6 +332,11 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover DataValueSnapshot? snapshot = null; for (var attempt = 0; attempt <= retries; attempt++) { + // PR ablegacy-10 / #253 — second + later attempts count as retries for the + // diagnostic counter. Increment BEFORE the work so a thrown exception still + // shows up in the retry tally. + if (attempt > 0) _diagnosticTags.RecordRetry(def.DeviceHostAddress); + try { var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false); @@ -273,6 +351,15 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover { continue; } + // PR ablegacy-10 / #253 — terminal failure: bump the error counter + // + record the libplctag status. CommFailure tally rolls only when + // the mapped status is BadCommunicationError so operators see a + // single "wire fell off" counter independent of other error codes. + _diagnosticTags.RecordError( + def.DeviceHostAddress, + status, + $"libplctag status {status} reading {reference}", + commFailure: mappedStatus == AbLegacyStatusMapper.BadCommunicationError); snapshot = new DataValueSnapshot(null, mappedStatus, null, now); _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, $"libplctag status {status} reading {reference}"); @@ -296,6 +383,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover var arr = DecodeArrayAs(runtime, def.DataType, arrayCount); snapshot = new DataValueSnapshot(arr, AbLegacyStatusMapper.Good, now, now); _health = new DriverHealth(DriverState.Healthy, now, null); + // PR ablegacy-10 / #253 — successful array read. + _diagnosticTags.RecordResponse(def.DeviceHostAddress); break; } @@ -307,6 +396,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover var value = runtime.DecodeValue(def.DataType, decodeBit); snapshot = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now); _health = new DriverHealth(DriverState.Healthy, now, null); + // PR ablegacy-10 / #253 — successful scalar / sub-element / bit read. + _diagnosticTags.RecordResponse(def.DeviceHostAddress); break; } catch (OperationCanceledException) { throw; } @@ -314,6 +405,15 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover { // Transient — exhaust retries before reporting BadCommunicationError. if (attempt < retries) continue; + // PR ablegacy-10 / #253 — exhausted retries surface as a comm + // failure. Pass libplctag status 0 because the throw means we never + // got a status code back, but record the exception message so the + // LastErrorMessage diagnostic still has actionable text. + _diagnosticTags.RecordError( + def.DeviceHostAddress, + libplctagStatus: 0, + errorMessage: ex.Message, + commFailure: true); snapshot = new DataValueSnapshot(null, AbLegacyStatusMapper.BadCommunicationError, null, now); _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); @@ -456,10 +556,61 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover IsAlarm: false, WriteIdempotent: tag.WriteIdempotent)); } + + // PR ablegacy-10 / #253 — auto-emit the per-device _Diagnostics folder + its + // seven read-only counter variables. FullName carries the synthetic + // _Diagnostics// reference so ReadAsync can short-circuit before + // EnsureTagRuntimeAsync. Mirrors AbCip's _System/ pattern from abcip-4.3. + EmitDiagnosticsFolder(deviceFolder, device.HostAddress); } return Task.CompletedTask; } + /// + /// PR ablegacy-10 / #253 — emit the per-device _Diagnostics folder + its + /// seven read-only diagnostic-counter variables. The FullName on each + /// variable encodes the owning device's host address + /// (_Diagnostics/<host>/<name>) so the read path can route to + /// without a separate registry. Names + /// + types stay in lockstep with . + /// + private static void EmitDiagnosticsFolder(IAddressSpaceBuilder deviceFolder, string deviceHostAddress) + { + var diag = deviceFolder.Folder("_Diagnostics", "_Diagnostics"); + EmitDiagnosticVariable(diag, deviceHostAddress, "RequestCount", DriverDataType.Int64, + "Total ReadAsync requests issued against this device (one per non-diagnostic reference per call, success or fail)."); + EmitDiagnosticVariable(diag, deviceHostAddress, "ResponseCount", DriverDataType.Int64, + "Successful read responses for this device."); + EmitDiagnosticVariable(diag, deviceHostAddress, "ErrorCount", DriverDataType.Int64, + "Failed read responses for this device (any non-Good status)."); + EmitDiagnosticVariable(diag, deviceHostAddress, "RetryCount", DriverDataType.Int64, + "Retry attempts beyond the first per the AbLegacy retry loop. Bumps once per extra attempt — a single read with two retries adds two."); + EmitDiagnosticVariable(diag, deviceHostAddress, "LastErrorCode", DriverDataType.Int32, + "Most recent libplctag status code on a failed read; 0 when no error has been seen since the last reset."); + EmitDiagnosticVariable(diag, deviceHostAddress, "LastErrorMessage", DriverDataType.String, + "Most recent libplctag error message on a failed read; empty when no error has been seen since the last reset."); + EmitDiagnosticVariable(diag, deviceHostAddress, "CommFailures", DriverDataType.Int64, + "Count of read failures mapped to BadCommunicationError. Spans transient libplctag throws + retried-out chains so operators see a single 'wire fell off' counter."); + } + + private static void EmitDiagnosticVariable( + IAddressSpaceBuilder folder, string deviceHostAddress, string name, + DriverDataType type, string description) + { + var fullName = $"{AbLegacyDiagnosticTags.DiagnosticsFolderPrefix}{deviceHostAddress}/{name}"; + folder.Variable(name, name, new DriverAttributeInfo( + FullName: fullName, + DriverDataType: type, + IsArray: false, + ArrayDim: null, + // Read-only — operators can't write the diagnostic surface from a SCADA template. + SecurityClass: SecurityClassification.ViewOnly, + IsHistorized: false, + IsAlarm: false, + WriteIdempotent: false, + Description: description)); + } + // ---- ISubscribable (polling overlay via shared engine) ---- public Task SubscribeAsync( diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyDiagnosticsIntegrationTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyDiagnosticsIntegrationTests.cs new file mode 100644 index 0000000..7352e42 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyDiagnosticsIntegrationTests.cs @@ -0,0 +1,56 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests; + +/// +/// PR ablegacy-10 / #253 — wire-level smoke against ab_server PCCC: after N reads +/// against the live runtime, the auto-emitted _Diagnostics/<host>/RequestCount +/// short-circuit must round-trip the same N value through ReadAsync. Skipped +/// when ab_server isn't reachable; otherwise builds the same way the existing read +/// smoke tests do. +/// +[Collection(AbLegacyServerCollection.Name)] +[Trait("Category", "Integration")] +[Trait("Simulator", "ab_server-PCCC")] +public sealed class AbLegacyDiagnosticsIntegrationTests(AbLegacyServerFixture sim) +{ + [AbLegacyFact] + public async Task RequestCount_diagnostic_matches_read_invocations() + { + if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); + + var deviceUri = $"ab://{sim.Host}:{sim.Port}/{sim.CipPath}"; + await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions(deviceUri, AbLegacyPlcFamily.Slc500)], + Tags = + [ + new AbLegacyTagDefinition( + Name: "IntCounter", + DeviceHostAddress: deviceUri, + Address: "N7:0", + DataType: AbLegacyDataType.Int), + ], + Timeout = TimeSpan.FromSeconds(5), + Probe = new AbLegacyProbeOptions { Enabled = false }, + }, driverInstanceId: "ablegacy-smoke-diagnostics"); + + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + + const int N = 5; + for (var i = 0; i < N; i++) + await drv.ReadAsync(["IntCounter"], TestContext.Current.CancellationToken); + + // Diagnostic short-circuit returns the live counter through ReadAsync without a + // libplctag round-trip. Verifies both that the discovery path emitted the + // address + that the read path serves it locally with Good status. + var diagRef = $"{AbLegacyDiagnosticTags.DiagnosticsFolderPrefix}{deviceUri}/RequestCount"; + var snapshots = await drv.ReadAsync([diagRef], TestContext.Current.CancellationToken); + + snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + Convert.ToInt64(snapshots.Single().Value).ShouldBe(N); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyCapabilityTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyCapabilityTests.cs index 38f85e9..b5635d7 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyCapabilityTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyCapabilityTests.cs @@ -31,9 +31,15 @@ public sealed class AbLegacyCapabilityTests builder.Folders.ShouldContain(f => f.BrowseName == "AbLegacy"); builder.Folders.ShouldContain(f => f.BrowseName == "ab://10.0.0.5/1,0" && f.DisplayName == "Press-SLC-1"); - builder.Variables.Count.ShouldBe(2); - builder.Variables.Single(v => v.BrowseName == "Speed").Info.SecurityClass.ShouldBe(SecurityClassification.Operate); - builder.Variables.Single(v => v.BrowseName == "Temperature").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly); + // PR ablegacy-10 / #253 — discovery now also emits a `_Diagnostics` folder + 7 + // diagnostic-counter variables per device. Filter the recording so this older + // assertion still focuses on the user-declared variables. + var userVars = builder.Variables + .Where(v => !v.Info.FullName.StartsWith(AbLegacyDiagnosticTags.DiagnosticsFolderPrefix)) + .ToList(); + userVars.Count.ShouldBe(2); + userVars.Single(v => v.BrowseName == "Speed").Info.SecurityClass.ShouldBe(SecurityClassification.Operate); + userVars.Single(v => v.BrowseName == "Temperature").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly); } // ---- ISubscribable ---- diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDiagnosticsTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDiagnosticsTests.cs new file mode 100644 index 0000000..3bae820 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDiagnosticsTests.cs @@ -0,0 +1,337 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; + +/// +/// PR ablegacy-10 / #253 — verifies the per-device diagnostic-counter surface that +/// auto-emits under each device's _Diagnostics/ folder. Tests cover: +/// - counter increments for success / fail / retry sequences, +/// - LastErrorCode / LastErrorMessage capture on failed reads, +/// - reset on ReinitializeAsync, +/// - 7-variable discovery emission per device, +/// - InitializeAsync collision rejection for user tags shadowing reserved names / +/// _Diagnostics/ addresses, +/// - read-time short-circuit returning the live snapshot via ReadAsync, +/// - independent counters across two devices. +/// +[Trait("Category", "Unit")] +public sealed class AbLegacyDiagnosticsTests +{ + private const string DeviceA = "ab://10.0.0.5/1,0"; + private const string DeviceB = "ab://10.0.0.6/1,0"; + + private static (AbLegacyDriver drv, FakeAbLegacyTagFactory factory) NewDriver( + params AbLegacyTagDefinition[] tags) + => NewDriver(devices: [new AbLegacyDeviceOptions(DeviceA)], tags: tags); + + private static (AbLegacyDriver drv, FakeAbLegacyTagFactory factory) NewDriver( + IReadOnlyList devices, + IReadOnlyList tags, + int? retries = null) + { + var factory = new FakeAbLegacyTagFactory(); + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = devices, + Tags = tags, + Retries = retries, + }, "drv-1", factory); + return (drv, factory); + } + + // ---- counter increments ---- + + [Fact] + public async Task Five_reads_three_ok_two_fail_record_correct_counters() + { + var (drv, factory) = NewDriver( + new AbLegacyTagDefinition("X", DeviceA, "N7:0", AbLegacyDataType.Int)); + await drv.InitializeAsync("{}", CancellationToken.None); + + // Seed the runtime once — each ReadAsync flips Status before the call so we drive + // success / failure deterministically. Status -14 maps to BadNodeIdUnknown (terminal, + // not retried) so each failure is exactly one Request + one Error with no retries. + factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1, Status = 0 }; + + // 3 OK reads. + await drv.ReadAsync(["X"], CancellationToken.None); + await drv.ReadAsync(["X"], CancellationToken.None); + await drv.ReadAsync(["X"], CancellationToken.None); + + // 2 failed reads — flip the fake to BadNodeIdUnknown (terminal, no retries). + factory.Tags["N7:0"].Status = -14; + await drv.ReadAsync(["X"], CancellationToken.None); + await drv.ReadAsync(["X"], CancellationToken.None); + + var snapshot = drv.DiagnosticTags.Snapshot(DeviceA); + snapshot.Request.ShouldBe(5); + snapshot.Response.ShouldBe(3); + snapshot.Error.ShouldBe(2); + snapshot.Retry.ShouldBe(0); // terminal failures don't retry + } + + [Fact] + public async Task LastErrorCode_reflects_most_recent_failed_read() + { + var (drv, factory) = NewDriver( + new AbLegacyTagDefinition("X", DeviceA, "N7:0", AbLegacyDataType.Int)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1, Status = 0 }; + + await drv.ReadAsync(["X"], CancellationToken.None); // success — clears nothing + factory.Tags["N7:0"].Status = -14; + await drv.ReadAsync(["X"], CancellationToken.None); + factory.Tags["N7:0"].Status = -16; // BadNotWritable maps but still terminal + await drv.ReadAsync(["X"], CancellationToken.None); + + var snapshot = drv.DiagnosticTags.Snapshot(DeviceA); + snapshot.LastErrorCode.ShouldBe(-16); + snapshot.LastErrorMessage.ShouldContain("libplctag status -16"); + } + + [Fact] + public async Task RetryCount_increments_per_retry_attempt() + { + // Driver-wide Retries = 2 — one bad-comm read becomes 1 original + 2 retries = 3 attempts. + // Each retry beyond the first bumps the RetryCount counter exactly once. + var (drv, factory) = NewDriver( + devices: [new AbLegacyDeviceOptions(DeviceA)], + tags: [new AbLegacyTagDefinition("X", DeviceA, "N7:0", AbLegacyDataType.Int)], + retries: 2); + await drv.InitializeAsync("{}", CancellationToken.None); + // -7 maps to BadCommunicationError → eligible for retry. The fake's GetStatus returns + // the seeded Status on every attempt; all three attempts fail and exhaust retries. + factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1, Status = -7 }; + + await drv.ReadAsync(["X"], CancellationToken.None); + + var snapshot = drv.DiagnosticTags.Snapshot(DeviceA); + snapshot.Request.ShouldBe(1); + snapshot.Retry.ShouldBe(2); + snapshot.Error.ShouldBe(1); + snapshot.CommFailures.ShouldBe(1); // BadCommunicationError counts as a comm failure + } + + [Fact] + public async Task ReinitializeAsync_resets_counters() + { + var (drv, factory) = NewDriver( + new AbLegacyTagDefinition("X", DeviceA, "N7:0", AbLegacyDataType.Int)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1, Status = 0 }; + await drv.ReadAsync(["X"], CancellationToken.None); + await drv.ReadAsync(["X"], CancellationToken.None); + drv.DiagnosticTags.Snapshot(DeviceA).Request.ShouldBe(2); + + await drv.ReinitializeAsync("{}", CancellationToken.None); + + var snapshot = drv.DiagnosticTags.Snapshot(DeviceA); + snapshot.Request.ShouldBe(0); + snapshot.Response.ShouldBe(0); + snapshot.Error.ShouldBe(0); + snapshot.Retry.ShouldBe(0); + snapshot.LastErrorCode.ShouldBe(0); + snapshot.LastErrorMessage.ShouldBeEmpty(); + snapshot.CommFailures.ShouldBe(0); + } + + // ---- discovery emission ---- + + [Fact] + public async Task DiscoverAsync_emits_seven_diagnostic_variables_per_device() + { + var (drv, _) = NewDriver( + devices: + [ + new AbLegacyDeviceOptions(DeviceA), + new AbLegacyDeviceOptions(DeviceB), + ], + tags: []); + await drv.InitializeAsync("{}", CancellationToken.None); + + var builder = new RecordingBuilder(); + await drv.DiscoverAsync(builder, CancellationToken.None); + + // Both devices emit a _Diagnostics folder. + builder.Folders.Count(f => f.BrowseName == "_Diagnostics").ShouldBe(2); + + // Each device emits the seven canonical names; FullName carries the device host. + foreach (var host in new[] { DeviceA, DeviceB }) + { + foreach (var name in AbLegacyDiagnosticTags.DiagnosticTagNames) + { + var fullName = $"{AbLegacyDiagnosticTags.DiagnosticsFolderPrefix}{host}/{name}"; + builder.Variables.Any(v => v.Info.FullName == fullName) + .ShouldBeTrue($"expected variable for {fullName}"); + } + } + + // Diagnostic vars are read-only. + var diagVars = builder.Variables + .Where(v => v.Info.FullName.StartsWith(AbLegacyDiagnosticTags.DiagnosticsFolderPrefix)) + .ToList(); + diagVars.Count.ShouldBe(14); // 7 names × 2 devices + diagVars.ShouldAllBe(v => v.Info.SecurityClass == SecurityClassification.ViewOnly); + } + + // ---- collision rejection ---- + + [Fact] + public async Task InitializeAsync_rejects_user_tag_with_reserved_name() + { + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions(DeviceA)], + // RequestCount is one of the seven reserved diagnostic names. + Tags = [new AbLegacyTagDefinition("RequestCount", DeviceA, "N7:0", AbLegacyDataType.Int)], + }, "drv-1", new FakeAbLegacyTagFactory()); + + var ex = await Should.ThrowAsync( + () => drv.InitializeAsync("{}", CancellationToken.None)); + ex.Message.ShouldContain("RequestCount"); + drv.GetHealth().State.ShouldBe(DriverState.Faulted); + } + + [Fact] + public async Task InitializeAsync_rejects_user_tag_with_diagnostics_address() + { + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions(DeviceA)], + Tags = + [ + new AbLegacyTagDefinition("RogueTag", DeviceA, + $"{AbLegacyDiagnosticTags.DiagnosticsFolderPrefix}whatever", AbLegacyDataType.Int), + ], + }, "drv-1", new FakeAbLegacyTagFactory()); + + var ex = await Should.ThrowAsync( + () => drv.InitializeAsync("{}", CancellationToken.None)); + ex.Message.ShouldContain("_Diagnostics/"); + } + + // ---- read short-circuit ---- + + [Fact] + public async Task ReadAsync_short_circuits_for_diagnostic_address_returning_snapshot() + { + var (drv, factory) = NewDriver( + new AbLegacyTagDefinition("X", DeviceA, "N7:0", AbLegacyDataType.Int)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1, Status = 0 }; + await drv.ReadAsync(["X"], CancellationToken.None); + await drv.ReadAsync(["X"], CancellationToken.None); + await drv.ReadAsync(["X"], CancellationToken.None); + + var diagRef = $"{AbLegacyDiagnosticTags.DiagnosticsFolderPrefix}{DeviceA}/RequestCount"; + var diagResponseRef = $"{AbLegacyDiagnosticTags.DiagnosticsFolderPrefix}{DeviceA}/ResponseCount"; + + var snapshots = await drv.ReadAsync([diagRef, diagResponseRef], CancellationToken.None); + + snapshots[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + snapshots[0].Value.ShouldBe(3L); + snapshots[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + snapshots[1].Value.ShouldBe(3L); + } + + [Fact] + public async Task Diagnostic_reads_do_not_increment_RequestCount() + { + var (drv, factory) = NewDriver( + new AbLegacyTagDefinition("X", DeviceA, "N7:0", AbLegacyDataType.Int)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1, Status = 0 }; + await drv.ReadAsync(["X"], CancellationToken.None); + + // Fire a bunch of diagnostic reads — the counter must stay at 1 because the + // diagnostics short-circuit is driver-local observability, not field traffic. + var diagRef = $"{AbLegacyDiagnosticTags.DiagnosticsFolderPrefix}{DeviceA}/RequestCount"; + for (var i = 0; i < 10; i++) + await drv.ReadAsync([diagRef], CancellationToken.None); + + drv.DiagnosticTags.Snapshot(DeviceA).Request.ShouldBe(1); + } + + // ---- multi-device isolation ---- + + [Fact] + public async Task Two_devices_have_independent_counters() + { + var (drv, factory) = NewDriver( + devices: + [ + new AbLegacyDeviceOptions(DeviceA), + new AbLegacyDeviceOptions(DeviceB), + ], + tags: + [ + new AbLegacyTagDefinition("A", DeviceA, "N7:0", AbLegacyDataType.Int), + new AbLegacyTagDefinition("B", DeviceB, "N7:0", AbLegacyDataType.Int), + ]); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1, Status = 0 }; + + await drv.ReadAsync(["A"], CancellationToken.None); + await drv.ReadAsync(["A"], CancellationToken.None); + await drv.ReadAsync(["A"], CancellationToken.None); + await drv.ReadAsync(["B"], CancellationToken.None); + + drv.DiagnosticTags.Snapshot(DeviceA).Request.ShouldBe(3); + drv.DiagnosticTags.Snapshot(DeviceB).Request.ShouldBe(1); + } + + // ---- TryRead / IsDiagnosticAddress / IsReservedName plumbing ---- + + [Fact] + public void IsDiagnosticAddress_recognises_prefix() + { + AbLegacyDiagnosticTags.IsDiagnosticAddress("_Diagnostics/foo/RequestCount").ShouldBeTrue(); + AbLegacyDiagnosticTags.IsDiagnosticAddress("AbLegacy/foo/RequestCount").ShouldBeFalse(); + AbLegacyDiagnosticTags.IsDiagnosticAddress(null).ShouldBeFalse(); + AbLegacyDiagnosticTags.IsDiagnosticAddress("").ShouldBeFalse(); + } + + [Fact] + public void IsReservedName_covers_all_seven_canonical_names() + { + foreach (var n in AbLegacyDiagnosticTags.DiagnosticTagNames) + AbLegacyDiagnosticTags.IsReservedName(n).ShouldBeTrue(); + AbLegacyDiagnosticTags.IsReservedName("RandomTag").ShouldBeFalse(); + AbLegacyDiagnosticTags.IsReservedName(null).ShouldBeFalse(); + } + + [Fact] + public void TryRead_returns_false_for_unrecognised_shape() + { + var d = new AbLegacyDiagnosticTags(); + d.TryRead("AbLegacy/foo", out _).ShouldBeFalse(); + d.TryRead("_Diagnostics/host/UnknownName", out _).ShouldBeFalse(); + d.TryRead("_Diagnostics/no-name-segment", out _).ShouldBeFalse(); + } + + // ---- helpers ---- + + private sealed class RecordingBuilder : IAddressSpaceBuilder + { + public List<(string BrowseName, string DisplayName)> Folders { get; } = new(); + public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new(); + + public IAddressSpaceBuilder Folder(string browseName, string displayName) + { Folders.Add((browseName, displayName)); return this; } + + public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info) + { Variables.Add((browseName, info)); return new Handle(info.FullName); } + + public void AddProperty(string _, DriverDataType __, object? ___) { } + + private sealed class Handle(string fullRef) : IVariableHandle + { + public string FullReference => fullRef; + public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink(); + } + private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } } + } +} -- 2.49.1