@@ -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/<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`
|
||||
|
||||
```powershell
|
||||
|
||||
112
docs/drivers/AbLegacy-Diagnostics.md
Normal file
112
docs/drivers/AbLegacy-Diagnostics.md
Normal 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. 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)
|
||||
@@ -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/<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:
|
||||
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
|
||||
|
||||
@@ -29,6 +29,16 @@
|
||||
|
||||
.PARAMETER BridgeNodeId
|
||||
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(
|
||||
@@ -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/<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
|
||||
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
||||
|
||||
@@ -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 /<empty>';
|
||||
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.';
|
||||
|
||||
261
src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDiagnosticTags.cs
Normal file
261
src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDiagnosticTags.cs
Normal 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/<host>/<name></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/<host>/<name></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/<host>/<name></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);
|
||||
@@ -29,6 +29,18 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
private readonly Dictionary<string, (object? Value, uint StatusCode)> _lastPublished =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly object _lastPublishedLock = new();
|
||||
|
||||
/// <summary>
|
||||
/// PR ablegacy-10 / #253 — per-device diagnostic counters surfaced as
|
||||
/// <c>_Diagnostics/<host>/<name></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);
|
||||
|
||||
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'.");
|
||||
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/<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))
|
||||
{
|
||||
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/<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;
|
||||
}
|
||||
|
||||
/// <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/<host>/<name></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) ----
|
||||
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||
|
||||
@@ -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/<host>/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);
|
||||
}
|
||||
}
|
||||
@@ -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 ----
|
||||
|
||||
@@ -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) { } }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user