Auto: ablegacy-10 — diagnostic counters as tags

Closes #253
This commit is contained in:
Joseph Doherty
2026-04-26 03:50:47 -04:00
parent 14876ea210
commit 42472b5549
10 changed files with 1000 additions and 5 deletions

View File

@@ -101,8 +101,17 @@ otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a L19:0 -t Long
# Timer ACC # Timer ACC
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a T4:0.ACC -t TimerElement 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/<name>
# 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` ### `write`
```powershell ```powershell

View File

@@ -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/<host>/_Diagnostics/<name>`:
| 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/<deviceHostAddress>/<name>`
e.g. `_Diagnostics/ab://10.0.0.5/1,0/RequestCount`.
The `<deviceHostAddress>` segment is the canonical `ab://host[:port]/cip-path`
string from `AbLegacyDeviceOptions.HostAddress`. The browse path looks like
`AbLegacy/<deviceHostAddress>/_Diagnostics/<name>` — 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/<host>/<name>` 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. 510 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)

View File

@@ -49,6 +49,16 @@ supplies a `FakeAbLegacyTag`.
- `AbLegacyHostAndStatusTests` — probe + host-status transitions driven by - `AbLegacyHostAndStatusTests` — probe + host-status transitions driven by
fake-returned statuses fake-returned statuses
- `AbLegacyDriverTests``IDriver` lifecycle - `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/<host>/<name>` short-circuit returns the live snapshot through
`ReadAsync` without bumping `RequestCount`; two devices keep counters
independent.
- `AbLegacyDeadbandTests` — PR 8 per-tag deadband / change filter: - `AbLegacyDeadbandTests` — PR 8 per-tag deadband / change filter:
absolute-only suppression sequence `[10.0, 10.5, 11.5, 11.6] -> [10.0, 11.5]`, 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 percent-only suppression with a zero-prev short-circuit, both-set logical-OR

View File

@@ -29,6 +29,16 @@
.PARAMETER BridgeNodeId .PARAMETER BridgeNodeId
NodeId at which the server publishes the Address. NodeId at which the server publishes the Address.
.PARAMETER DiagnosticsRequestCountNodeId
Optional NodeId for the synthetic _Diagnostics/<host>/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=<n>;s=AbLegacy/<gateway>/_Diagnostics/RequestCount. Mirrors the
-SystemConnectionStatusNodeId knob on test-abcip.ps1.
#> #>
param( param(
@@ -36,7 +46,8 @@ param(
[string]$PlcType = "Slc500", [string]$PlcType = "Slc500",
[string]$Address = "N7:5", [string]$Address = "N7:5",
[string]$OpcUaUrl = "opc.tcp://localhost:4840", [string]$OpcUaUrl = "opc.tcp://localhost:4840",
[Parameter(Mandatory)] [string]$BridgeNodeId [Parameter(Mandatory)] [string]$BridgeNodeId,
[string]$DiagnosticsRequestCountNodeId
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
@@ -150,5 +161,40 @@ if ($notifyLines.Count -eq 1) {
$results += @{ Passed = $false; Reason = "deadband notify count $($notifyLines.Count)" } $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/<host>/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 Write-Summary -Title "AB Legacy e2e" -Results $results
if ($results | Where-Object { -not $_.Passed }) { exit 1 } if ($results | Where-Object { -not $_.Passed }) { exit 1 }

View File

@@ -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 ' cip-path (required by ab_server). For real SLC/MicroLogix/PLC-5';
PRINT ' hardware, edit the DriverConfig HostAddress to end with /<empty>'; PRINT ' hardware, edit the DriverConfig HostAddress to end with /<empty>';
PRINT ' e.g. "ab://<plc-ip>:44818/" and re-run this seed.'; PRINT ' e.g. "ab://<plc-ip>:44818/" and re-run this seed.';
PRINT '';
PRINT 'PR ablegacy-10 / #253 — diagnostic counters auto-emit per device under';
PRINT ' AbLegacy/<host>/_Diagnostics/<name>. 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.';

View File

@@ -0,0 +1,261 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>
/// 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 <c>_Diagnostics</c> folder. The
/// read path short-circuits before the libplctag dispatch when the incoming
/// reference targets a <c>_Diagnostics/&lt;host&gt;/&lt;name&gt;</c> address — the
/// values come straight from the driver-local counters.
/// </summary>
/// <remarks>
/// <para>Mirrors AbCip's <c>AbCipSystemTagSource</c> 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
/// <c>long</c> (Int64) so a long-running deployment can't roll an
/// <c>RequestCount</c> over inside a maintenance window.</para>
/// <list type="bullet">
/// <item><c>RequestCount</c> — total <see cref="AbLegacyDriver.ReadAsync"/>
/// requests issued against this device (each non-diagnostic reference counts
/// once per call, success or fail).</item>
/// <item><c>ResponseCount</c> — successful read responses.</item>
/// <item><c>ErrorCount</c> — failed read responses (any non-Good status).</item>
/// <item><c>RetryCount</c> — retry attempts beyond the first per the PR 9 retry
/// loop. Incremented once per extra attempt, not per successful retry.</item>
/// <item><c>LastErrorCode</c> — most recent libplctag status code on a failed
/// read (0 when no error has been seen since reset).</item>
/// <item><c>LastErrorMessage</c> — most recent libplctag error message on a
/// failed read (empty when no error has been seen).</item>
/// <item><c>CommFailures</c> — count of read failures mapped to
/// <see cref="AbLegacyStatusMapper.BadCommunicationError"/>. Spans transient
/// exceptions + retried-out chains so operators see a single "wire fell off"
/// counter without having to sum across error-code subtotals.</item>
/// </list>
/// </remarks>
public sealed class AbLegacyDiagnosticTags
{
/// <summary>Address-space prefix the driver stamps on every diagnostic variable's
/// <see cref="ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverAttributeInfo.FullName"/>.</summary>
public const string DiagnosticsFolderPrefix = "_Diagnostics/";
/// <summary>Canonical names the diagnostics folder exposes. Keep in lockstep with discovery.</summary>
public static readonly IReadOnlyList<string> DiagnosticTagNames =
[
"RequestCount",
"ResponseCount",
"ErrorCount",
"RetryCount",
"LastErrorCode",
"LastErrorMessage",
"CommFailures",
];
private static readonly HashSet<string> DiagnosticTagNameSet =
new(DiagnosticTagNames, StringComparer.Ordinal);
private readonly Dictionary<string, DiagnosticsCounters> _counters =
new(StringComparer.OrdinalIgnoreCase);
private readonly object _lock = new();
/// <summary>
/// Make sure a slot exists for <paramref name="deviceHostAddress"/>. Called from
/// <see cref="AbLegacyDriver.InitializeAsync"/> so the counters are zero-initialised
/// by the time the first read or probe iteration fires.
/// </summary>
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;
}
}
/// <summary>Increment <c>RequestCount</c> for <paramref name="deviceHostAddress"/>.</summary>
public void RecordRequest(string deviceHostAddress)
{
ArgumentNullException.ThrowIfNull(deviceHostAddress);
var c = GetOrCreate(deviceHostAddress);
Interlocked.Increment(ref c.Request);
}
/// <summary>Increment <c>ResponseCount</c> for a successful read.</summary>
public void RecordResponse(string deviceHostAddress)
{
ArgumentNullException.ThrowIfNull(deviceHostAddress);
var c = GetOrCreate(deviceHostAddress);
Interlocked.Increment(ref c.Response);
}
/// <summary>
/// Increment <c>ErrorCount</c> + record the latest libplctag status code +
/// message for a failed read. <paramref name="commFailure"/> = true also bumps
/// <c>CommFailures</c> when the failure mapped to <c>BadCommunicationError</c>.
/// </summary>
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;
}
/// <summary>Increment <c>RetryCount</c> per retry attempt beyond the first.</summary>
public void RecordRetry(string deviceHostAddress)
{
ArgumentNullException.ThrowIfNull(deviceHostAddress);
var c = GetOrCreate(deviceHostAddress);
Interlocked.Increment(ref c.Retry);
}
/// <summary>Snapshot the current counters for a device. Returns zeros for unknown hosts.</summary>
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));
}
/// <summary>
/// Reset every counter for <paramref name="deviceHostAddress"/> back to zero. Called
/// from <see cref="AbLegacyDriver.ReinitializeAsync"/> so a config redeploy starts
/// with a clean diagnostic surface.
/// </summary>
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);
}
/// <summary>Reset every tracked device. Called on full <c>ShutdownAsync</c>.</summary>
public void ResetAll()
{
lock (_lock)
{
_counters.Clear();
}
}
/// <summary>
/// Resolve a <c>_Diagnostics/&lt;host&gt;/&lt;name&gt;</c> reference into a counter
/// value. Returns <c>true</c> when the reference shape matches; <paramref name="value"/>
/// carries the counter (or empty string for <c>LastErrorMessage</c>) on success.
/// </summary>
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;
}
/// <summary>
/// <c>true</c> when <paramref name="reference"/> targets a node under the synthetic
/// <c>_Diagnostics/</c> folder. The driver's read path uses this to bypass the
/// libplctag runtime and dispatch to <see cref="TryRead"/> directly.
/// </summary>
public static bool IsDiagnosticAddress(string? reference) =>
!string.IsNullOrEmpty(reference)
&& reference.StartsWith(DiagnosticsFolderPrefix, StringComparison.Ordinal);
/// <summary>
/// <c>true</c> when <paramref name="name"/> matches one of the seven reserved
/// diagnostic names. Used by <see cref="AbLegacyDriver.InitializeAsync"/> to reject
/// user-config tags that would shadow the driver-emitted counters.
/// </summary>
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;
}
}
/// <summary>
/// PR ablegacy-10 / #253 — immutable snapshot of one device's diagnostic counters.
/// Returned through <see cref="AbLegacyDriver.ReadAsync"/> when an OPC UA client
/// reads any of the seven <c>_Diagnostics/&lt;host&gt;/&lt;name&gt;</c> variables.
/// </summary>
/// <param name="Request">Total <c>ReadAsync</c> requests issued against this device.</param>
/// <param name="Response">Successful read responses.</param>
/// <param name="Error">Failed read responses (any non-Good status).</param>
/// <param name="Retry">Retry attempts beyond the first per the PR 9 retry loop.</param>
/// <param name="LastErrorCode">Most recent libplctag status code on a failed read.</param>
/// <param name="LastErrorMessage">Most recent libplctag error message on a failed read.</param>
/// <param name="CommFailures">Count of read failures mapped to <c>BadCommunicationError</c>.</param>
public sealed record DiagnosticsSnapshot(
long Request,
long Response,
long Error,
long Retry,
int LastErrorCode,
string LastErrorMessage,
long CommFailures);

View File

@@ -29,6 +29,18 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
private readonly Dictionary<string, (object? Value, uint StatusCode)> _lastPublished = private readonly Dictionary<string, (object? Value, uint StatusCode)> _lastPublished =
new(StringComparer.OrdinalIgnoreCase); new(StringComparer.OrdinalIgnoreCase);
private readonly object _lastPublishedLock = new(); private readonly object _lastPublishedLock = new();
/// <summary>
/// PR ablegacy-10 / #253 — per-device diagnostic counters surfaced as
/// <c>_Diagnostics/&lt;host&gt;/&lt;name&gt;</c> read-only variables. Updated on
/// every <see cref="ReadAsync"/> call (success, failure, retry) so HMIs can bind
/// directly without a separate diagnostics RPC.
/// </summary>
private readonly AbLegacyDiagnosticTags _diagnosticTags = new();
/// <summary>Test seam — exposes the live diagnostic-tag source so unit tests can poke counters.</summary>
internal AbLegacyDiagnosticTags DiagnosticTags => _diagnosticTags;
private DriverHealth _health = new(DriverState.Unknown, null, null); private DriverHealth _health = new(DriverState.Unknown, null, null);
public event EventHandler<DataChangeEventArgs>? OnDataChange; public event EventHandler<DataChangeEventArgs>? 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'."); $"AbLegacy device has invalid HostAddress '{device.HostAddress}' — expected 'ab://gateway[:port]/cip-path'.");
var profile = AbLegacyPlcFamilyProfile.ForFamily(device.PlcFamily); var profile = AbLegacyPlcFamilyProfile.ForFamily(device.PlcFamily);
_devices[device.HostAddress] = new DeviceState(addr, device, profile); _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. // Probe loops — one per device when enabled + probe address configured.
if (_options.Probe.Enabled && !string.IsNullOrWhiteSpace(_options.Probe.ProbeAddress)) 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) public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{ {
await ShutdownAsync(cancellationToken).ConfigureAwait(false); 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); 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 // reconnect-driven shutdown) doesn't suppress the very first post-reconnect sample
// by comparing it against pre-disconnect state. // by comparing it against pre-disconnect state.
lock (_lastPublishedLock) { _lastPublished.Clear(); } 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); _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++) for (var i = 0; i < fullReferences.Count; i++)
{ {
var reference = fullReferences[i]; var reference = fullReferences[i];
// PR ablegacy-10 / #253 — synthetic _Diagnostics/<host>/<name> 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)) if (!_tagsByName.TryGetValue(reference, out var def))
{ {
results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNodeIdUnknown, null, now); results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNodeIdUnknown, null, now);
@@ -250,6 +317,12 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
continue; 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 // 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 // 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 // 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; DataValueSnapshot? snapshot = null;
for (var attempt = 0; attempt <= retries; attempt++) 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 try
{ {
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false); var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
@@ -273,6 +351,15 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
{ {
continue; 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); snapshot = new DataValueSnapshot(null, mappedStatus, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"libplctag status {status} reading {reference}"); $"libplctag status {status} reading {reference}");
@@ -296,6 +383,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
var arr = DecodeArrayAs(runtime, def.DataType, arrayCount); var arr = DecodeArrayAs(runtime, def.DataType, arrayCount);
snapshot = new DataValueSnapshot(arr, AbLegacyStatusMapper.Good, now, now); snapshot = new DataValueSnapshot(arr, AbLegacyStatusMapper.Good, now, now);
_health = new DriverHealth(DriverState.Healthy, now, null); _health = new DriverHealth(DriverState.Healthy, now, null);
// PR ablegacy-10 / #253 — successful array read.
_diagnosticTags.RecordResponse(def.DeviceHostAddress);
break; break;
} }
@@ -307,6 +396,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
var value = runtime.DecodeValue(def.DataType, decodeBit); var value = runtime.DecodeValue(def.DataType, decodeBit);
snapshot = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now); snapshot = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now);
_health = new DriverHealth(DriverState.Healthy, now, null); _health = new DriverHealth(DriverState.Healthy, now, null);
// PR ablegacy-10 / #253 — successful scalar / sub-element / bit read.
_diagnosticTags.RecordResponse(def.DeviceHostAddress);
break; break;
} }
catch (OperationCanceledException) { throw; } catch (OperationCanceledException) { throw; }
@@ -314,6 +405,15 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
{ {
// Transient — exhaust retries before reporting BadCommunicationError. // Transient — exhaust retries before reporting BadCommunicationError.
if (attempt < retries) continue; 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, snapshot = new DataValueSnapshot(null,
AbLegacyStatusMapper.BadCommunicationError, null, now); AbLegacyStatusMapper.BadCommunicationError, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
@@ -456,10 +556,61 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
IsAlarm: false, IsAlarm: false,
WriteIdempotent: tag.WriteIdempotent)); 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/<host>/<name> reference so ReadAsync can short-circuit before
// EnsureTagRuntimeAsync. Mirrors AbCip's _System/ pattern from abcip-4.3.
EmitDiagnosticsFolder(deviceFolder, device.HostAddress);
} }
return Task.CompletedTask; return Task.CompletedTask;
} }
/// <summary>
/// PR ablegacy-10 / #253 — emit the per-device <c>_Diagnostics</c> folder + its
/// seven read-only diagnostic-counter variables. The <c>FullName</c> on each
/// variable encodes the owning device's host address
/// (<c>_Diagnostics/&lt;host&gt;/&lt;name&gt;</c>) so the read path can route to
/// <see cref="AbLegacyDiagnosticTags.TryRead"/> without a separate registry. Names
/// + types stay in lockstep with <see cref="AbLegacyDiagnosticTags.DiagnosticTagNames"/>.
/// </summary>
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) ---- // ---- ISubscribable (polling overlay via shared engine) ----
public Task<ISubscriptionHandle> SubscribeAsync( public Task<ISubscriptionHandle> SubscribeAsync(

View File

@@ -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;
/// <summary>
/// PR ablegacy-10 / #253 — wire-level smoke against ab_server PCCC: after N reads
/// against the live runtime, the auto-emitted <c>_Diagnostics/&lt;host&gt;/RequestCount</c>
/// short-circuit must round-trip the same N value through <c>ReadAsync</c>. Skipped
/// when ab_server isn't reachable; otherwise builds the same way the existing read
/// smoke tests do.
/// </summary>
[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);
}
}

View File

@@ -31,9 +31,15 @@ public sealed class AbLegacyCapabilityTests
builder.Folders.ShouldContain(f => f.BrowseName == "AbLegacy"); 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.Folders.ShouldContain(f => f.BrowseName == "ab://10.0.0.5/1,0" && f.DisplayName == "Press-SLC-1");
builder.Variables.Count.ShouldBe(2); // PR ablegacy-10 / #253 — discovery now also emits a `_Diagnostics` folder + 7
builder.Variables.Single(v => v.BrowseName == "Speed").Info.SecurityClass.ShouldBe(SecurityClassification.Operate); // diagnostic-counter variables per device. Filter the recording so this older
builder.Variables.Single(v => v.BrowseName == "Temperature").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly); // 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 ---- // ---- ISubscribable ----

View File

@@ -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;
/// <summary>
/// PR ablegacy-10 / #253 — verifies the per-device diagnostic-counter surface that
/// auto-emits under each device's <c>_Diagnostics/</c> 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 /
/// <c>_Diagnostics/</c> addresses,
/// - read-time short-circuit returning the live snapshot via <c>ReadAsync</c>,
/// - independent counters across two devices.
/// </summary>
[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<AbLegacyDeviceOptions> devices,
IReadOnlyList<AbLegacyTagDefinition> 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<InvalidOperationException>(
() => 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<InvalidOperationException>(
() => 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) { } }
}
}