@@ -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
|
||||||
|
|||||||
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
|
- `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
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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.';
|
||||||
|
|||||||
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 =
|
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/<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);
|
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/<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) ----
|
// ---- ISubscribable (polling overlay via shared engine) ----
|
||||||
|
|
||||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
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 == "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 ----
|
||||||
|
|||||||
@@ -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