Merge pull request '[abcip] AbCip — Diagnostic / system tags as browseable variables' (#383) from auto/abcip/4.3 into auto/driver-gaps

This commit was merged in pull request #383.
This commit is contained in:
2026-04-26 02:58:40 -04:00
10 changed files with 915 additions and 10 deletions

View File

@@ -56,6 +56,21 @@ otopcua-abcip-cli read -g ab://10.0.0.5/1,0 -t "Recipe[3]" --type Real
otopcua-abcip-cli read -g ab://10.0.0.5/1,0 -t "Motor01.Speed" --type Real
```
#### Diagnostic / system tags
PR abcip-4.3 exposes five read-only diagnostic variables per device under
`AbCip/<device>/_System/` in the OPC UA address space (see
[AbCip-Operability §System tags](drivers/AbCip-Operability.md#system-tags--_system-folder)
for the full table). These are not reachable through the AB CIP CLI — they
live on the OPC UA server side, not the libplctag wire — so to read one,
point the **OPC UA client** CLI at the running OtOpcUa server:
```powershell
# Read _ConnectionStatus for one device through the OPC UA server
otopcua-client-cli read -u opc.tcp://localhost:4840 \
-n "ns=2;s=AbCip/ab://10.0.0.5/1,0/_System/_ConnectionStatus"
```
### `write` — single Logix tag
Same shape as `read` plus `-v`. Values parse per `--type` using invariant

View File

@@ -297,3 +297,68 @@ the equality threshold too loose; revisit the per-tag config.
- Modbus driver — read-side deadband in `ModbusDriver` predates this
write-side equivalent; the config shape is intentionally similar.
- Kepware "Deadband (write)" knob — this is the AB CIP equivalent.
## System tags / `_System` folder
PR abcip-4.3 surfaces five read-only diagnostic variables under
`AbCip/<device>/_System/` so SCADA / Admin clients can pivot from "is the
wire up?" to "what's our scan rate / tag count?" without leaving the OPC UA
address space. The values come straight from the live
`IHostConnectivityProbe` + `DriverHealth` surfaces — reads bypass libplctag
and are served from the in-memory snapshot the probe loop / read loop
updates.
### What it ships
| Variable | Type | Source | Notes |
|---|---|---|---|
| `_ConnectionStatus` | String | `HostState` | `Running` / `Stopped` / `Unknown` / `Faulted`. Mirrors what the connectivity probe sees. |
| `_ScanRate` | Float64 | `AbCipProbeOptions.Interval` | Configured probe interval in milliseconds — compare against `_LastScanTimeMs` to spot wire stretch. |
| `_TagCount` | Int32 | `_tagsByName` | Discovered tag count for this device, excluding `_System/*`. |
| `_DeviceError` | String | `DriverHealth.LastError` | Most recent error message; empty when the device is healthy. |
| `_LastScanTimeMs` | Float64 | `ReadAsync` wall-clock | Duration of the most-recent `ReadAsync` iteration on this device. |
### When the snapshot updates
- **Probe transitions** — every `Running ↔ Stopped` flip refreshes the
device's snapshot inline, so a client subscribed to
`_System/_ConnectionStatus` sees the new state on the next OPC UA
publish tick.
- **Read iterations** — `ReadAsync` recomputes `_LastScanTimeMs` per
device that owned at least one reference in the batch + writes a fresh
snapshot before returning.
- **Driver init** — every device gets a seeded snapshot
(`Unknown` / `0` / `""`) before the probe loop spins up so a read that
arrives before the first probe iteration returns a stable shape rather
than null.
### Browse + read example
```powershell
# Browse the synthetic folder
otopcua-client-cli browse -u opc.tcp://localhost:4840 \
-n "ns=2;s=AbCip/ab://10.0.0.5/1,0/_System"
# Read the connection status
otopcua-client-cli read -u opc.tcp://localhost:4840 \
-n "ns=2;s=AbCip/ab://10.0.0.5/1,0/_System/_ConnectionStatus"
```
The driver-side reference embeds the device host address (the
`_System/<device>/<name>` form) so the dispatcher can route by device
without an additional registry. PR abcip-4.4 will turn `_RefreshTagDb` into
a writeable refresh trigger; everything 4.3 ships is `ViewOnly`.
### Verification
- **Unit**: `AbCipSystemTagSourceTests`
(`tests/.../AbCip.Tests`) — covers snapshot round-trip, two-device
isolation, recognised-name lookup, default-shape on unseeded devices,
discovery emits the five canonical nodes, and `ReadAsync` dispatches
through the source instead of libplctag.
- **Integration**: `AbCipSystemTagDiscoveryTests`
(`tests/.../AbCip.IntegrationTests`) — `[AbServerFact]` connects to a
real `ab_server`, browses `_System/`, reads each variable, asserts
every one returns Good with a non-null value.
- **E2E**: `scripts/e2e/test-abcip.ps1` — see the *SystemTagBrowse*
assertion.

View File

@@ -146,7 +146,14 @@ No smoke test for:
atomic-write coverage end-to-end is still unit-only.
- `ITagDiscovery.DiscoverAsync` (`@tags` walker)
- `ISubscribable.SubscribeAsync` (poll-group engine)
- `IHostConnectivityProbe` state transitions under wire failure
- ~~`IHostConnectivityProbe` state transitions under wire failure~~ —
covered as of PR abcip-4.3. `AbCipSystemTagDiscoveryTests` connects to
`ab_server`, drives the discovery + read path against the synthetic
`_System/_ConnectionStatus` variable, and asserts the live snapshot
reflects the probe-driven `HostState`. Wire-failure transitions still
rely on unit-level `ThrowOnRead` injection rather than a real wire pull,
but the end-to-end probe → snapshot → OPC UA address-space link is
exercised against `ab_server`.
- `IPerCallHostResolver` multi-device routing
The driver implements all of these + they have unit coverage, but the only

View File

@@ -39,6 +39,13 @@
.PARAMETER SlowBridgeNodeId
Optional NodeId for a Tag declared with ScanRateMs >= 1000. Pair with
FastBridgeNodeId to enable the scan-rate assertion.
.PARAMETER SystemConnectionStatusNodeId
Optional NodeId for the synthetic _System/_ConnectionStatus variable
emitted by AB CIP discovery (PR abcip-4.3). When supplied, the script
runs the SystemTagBrowse assertion — reads the value through the OPC UA
server + asserts it surfaces one of the canonical HostState strings.
NodeId form: ns=<n>;s=AbCip/<gateway>/_System/_ConnectionStatus.
#>
param(
@@ -48,7 +55,12 @@ param(
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
[Parameter(Mandatory)] [string]$BridgeNodeId,
[string]$FastBridgeNodeId,
[string]$SlowBridgeNodeId
[string]$SlowBridgeNodeId,
# PR abcip-4.3 — NodeId for the synthetic _System/_ConnectionStatus variable that
# discovery emits under each device. Optional — when wired, runs the
# SystemTagBrowse assertion that browses + reads the system folder through the OPC UA
# server. NodeId form: ns=<n>;s=AbCip/<gateway>/_System/_ConnectionStatus.
[string]$SystemConnectionStatusNodeId
)
$ErrorActionPreference = "Stop"
@@ -208,5 +220,28 @@ $results += [PSCustomObject]@{
}
}
# PR abcip-4.3 — _System/_ConnectionStatus browse-and-read assertion. Reads the live
# diagnostic snapshot via the OPC UA Client CLI; the value comes straight from the
# AbCipSystemTagSource (no libplctag round-trip). When the probe loop is healthy + the
# gateway is reachable, the value should be "Running"; on a stopped fixture it would be
# "Stopped". The assertion accepts any of the four canonical states, plus the "Unknown"
# transient that surfaces before the first probe iteration completes.
if ($SystemConnectionStatusNodeId) {
Write-Header "SystemTagBrowse (_System/_ConnectionStatus from $SystemConnectionStatusNodeId)"
$sysReadArgs = @($opcUaCli.PrefixArgs) + @("read", "-u", $OpcUaUrl, "-n", $SystemConnectionStatusNodeId)
$sysOut = & $opcUaCli.File @sysReadArgs 2>&1
$sysJoined = ($sysOut -join "`n")
$sysMatched = $sysJoined -match "Running|Stopped|Unknown|Faulted"
$results += [PSCustomObject]@{
Name = "SystemTagBrowse"
Passed = $sysMatched
Detail = if ($sysMatched) {
"_ConnectionStatus surfaced one of Running / Stopped / Unknown / Faulted via OPC UA"
} else {
"_ConnectionStatus did not surface a recognised HostState — got '$sysJoined'"
}
}
}
Write-Summary -Title "AB CIP e2e" -Results $results
if ($results | Where-Object { -not $_.Passed }) { exit 1 }

View File

@@ -36,6 +36,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private readonly AbCipAlarmProjection _alarmProjection;
private readonly SemaphoreSlim _discoverySemaphore = new(1, 1);
private readonly AbCipWriteCoalescer _writeCoalescer = new();
private readonly AbCipSystemTagSource _systemTagSource = new();
private DriverHealth _health = new(DriverState.Unknown, null, null);
public event EventHandler<DataChangeEventArgs>? OnDataChange;
@@ -225,6 +226,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
}
// PR abcip-4.3 — seed each device's system-tag snapshot before the probe / read loops
// start so an immediate _System read returns a stable shape (Unknown / 0 / "") instead
// of "no snapshot recorded yet". TransitionDeviceState + ReadAsync refresh from here.
foreach (var state in _devices.Values)
RefreshSystemTagSnapshot(state, lastScanTimeMs: 0.0);
// Probe loops — one per device when enabled + a ProbeTagPath is configured.
if (_options.Probe.Enabled && !string.IsNullOrWhiteSpace(_options.Probe.ProbeTagPath))
{
@@ -649,10 +656,46 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// full round-trip + the coalescer rebuilds its cache from the new baseline.
if (newState == HostState.Stopped || newState == HostState.Running)
_writeCoalescer.Reset(state.Options.HostAddress);
// PR abcip-4.3 — refresh the diagnostic-tag snapshot on every transition so a client
// subscribed to _System/_ConnectionStatus sees the new state immediately + the
// _DeviceError mirror the driver's most-recent fault message. Pass newState explicitly
// so the refresh doesn't race the lock-release with state.HostState.
RefreshSystemTagSnapshot(state, overrideHostState: newState);
OnHostStatusChanged?.Invoke(this,
new HostStatusChangedEventArgs(state.Options.HostAddress, old, newState));
}
/// <summary>
/// PR abcip-4.3 — rebuild a single device's <see cref="SystemTagSnapshot"/> from the
/// live <see cref="DeviceState.HostState"/>, the configured probe interval, the
/// count of discovered tags excluding <c>_System/*</c>, the most-recent driver-error
/// message, and the supplied last-scan duration. Called from probe transitions, the
/// end of <see cref="ReadAsync"/>, and at <see cref="InitializeAsync"/> seed time.
/// </summary>
private void RefreshSystemTagSnapshot(
DeviceState state, double? lastScanTimeMs = null, HostState? overrideHostState = null)
{
var tagCount = 0;
foreach (var t in _tagsByName.Values)
{
if (string.Equals(t.DeviceHostAddress, state.Options.HostAddress, StringComparison.OrdinalIgnoreCase)
&& !AbCipSystemTagSource.IsSystemReference(t.Name))
tagCount++;
}
var scanRateMs = _options.Probe.Interval.TotalMilliseconds;
var deviceError = _health.LastError ?? string.Empty;
// Caller can pass overrideHostState to dodge the read-from-volatile-state race that
// would otherwise sit between TransitionDeviceState's lock release + this refresh.
var connectionStatus = (overrideHostState ?? state.HostState).ToString();
var resolvedScan = lastScanTimeMs ?? state.LastScanTimeMs;
_systemTagSource.Update(state.Options.HostAddress, new SystemTagSnapshot(
ConnectionStatus: connectionStatus,
ScanRateMs: scanRateMs,
TagCount: tagCount,
DeviceError: deviceError,
LastScanTimeMs: resolvedScan));
}
// ---- IPerCallHostResolver ----
/// <summary>
@@ -665,11 +708,48 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// </summary>
public string ResolveHost(string fullReference)
{
// PR abcip-4.3 — _System/<deviceHostAddress>/<name> carries the device in its
// address path, so route on the embedded host directly rather than falling back
// to "first configured device" + having the bulkhead key collide across devices.
if (AbCipSystemTagSource.IsSystemReference(fullReference))
{
var host = ExtractSystemDeviceHost(fullReference);
if (host is not null) return host;
}
if (_tagsByName.TryGetValue(fullReference, out var def))
return def.DeviceHostAddress;
return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId;
}
/// <summary>
/// PR abcip-4.3 — pull the device host address out of a <c>_System/&lt;host&gt;/&lt;name&gt;</c>
/// reference. Splits on the last <c>'/'</c> so device hosts that themselves contain a
/// forward-slash (the canonical <c>ab://gateway/cip-path</c> form does) survive the
/// round-trip. Returns <c>null</c> when the reference doesn't match the expected shape.
/// </summary>
internal static string? ExtractSystemDeviceHost(string reference)
{
if (!AbCipSystemTagSource.IsSystemReference(reference)) return null;
var withoutPrefix = reference[AbCipSystemTagSource.SystemFolderPrefix.Length..];
var lastSlash = withoutPrefix.LastIndexOf('/');
if (lastSlash <= 0) return null;
return withoutPrefix[..lastSlash];
}
/// <summary>
/// PR abcip-4.3 — pull the trailing system-tag name (e.g. <c>_ConnectionStatus</c>) out
/// of a <c>_System/&lt;host&gt;/&lt;name&gt;</c> reference. Pairs with
/// <see cref="ExtractSystemDeviceHost"/>.
/// </summary>
internal static string? ExtractSystemTagName(string reference)
{
if (!AbCipSystemTagSource.IsSystemReference(reference)) return null;
var withoutPrefix = reference[AbCipSystemTagSource.SystemFolderPrefix.Length..];
var lastSlash = withoutPrefix.LastIndexOf('/');
if (lastSlash <= 0 || lastSlash >= withoutPrefix.Length - 1) return null;
return withoutPrefix[(lastSlash + 1)..];
}
// ---- IReadable ----
/// <summary>
@@ -685,6 +765,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
ArgumentNullException.ThrowIfNull(fullReferences);
var now = DateTime.UtcNow;
var results = new DataValueSnapshot[fullReferences.Count];
var scanStart = System.Diagnostics.Stopwatch.GetTimestamp();
// PR abcip-3.2 — first-read symbol-walk for Logical-mode devices. Each device that
// resolved to Logical fires one @tags walk; subsequent reads consult the cached
@@ -704,6 +785,30 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// Auto — per-group heuristic on subscribedMembers / totalMembers.
await ExecuteReadPlanAsync(fullReferences, results, now, cancellationToken).ConfigureAwait(false);
// PR abcip-4.3 — track wall-clock scan time per device that owned at least one ref in
// this batch. Surfaces as _System/_LastScanTimeMs; the snapshot refresh also picks up
// any health transitions / error messages that happened during the read.
var elapsedMs = (System.Diagnostics.Stopwatch.GetTimestamp() - scanStart)
* 1000.0 / System.Diagnostics.Stopwatch.Frequency;
var touched = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var fr in fullReferences)
{
string? deviceHost = null;
if (AbCipSystemTagSource.IsSystemReference(fr))
{
deviceHost = ExtractSystemDeviceHost(fr);
}
else if (_tagsByName.TryGetValue(fr, out var def))
{
deviceHost = def.DeviceHostAddress;
}
if (deviceHost is null || !touched.Add(deviceHost)) continue;
if (_devices.TryGetValue(deviceHost, out var state))
{
state.LastScanTimeMs = elapsedMs;
RefreshSystemTagSnapshot(state, elapsedMs);
}
}
return results;
}
@@ -973,6 +1078,24 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private async Task ReadSingleAsync(
AbCipUdtReadFallback fb, string reference, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
{
// PR abcip-4.3 — synthetic _System/<deviceHost>/<name> reference; serve from the
// diagnostic snapshot instead of materialising a libplctag runtime.
if (AbCipSystemTagSource.IsSystemReference(reference))
{
var deviceHost = ExtractSystemDeviceHost(reference);
var nameUnderSystem = ExtractSystemTagName(reference);
if (deviceHost is not null && nameUnderSystem is not null
&& _systemTagSource.TryRead(nameUnderSystem, deviceHost, out var sysValue))
{
results[fb.OriginalIndex] = new DataValueSnapshot(sysValue, AbCipStatusMapper.Good, now, now);
}
else
{
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
}
return;
}
if (!_tagsByName.TryGetValue(reference, out var def))
{
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
@@ -1606,6 +1729,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var deviceLabel = device.DeviceName ?? device.HostAddress;
var deviceFolder = root.Folder(device.HostAddress, deviceLabel);
// PR abcip-4.3 — diagnostic / system tags. Five read-only variables under
// _System/, each FullName-prefixed with _System/<deviceHostAddress>/ so the
// ReadAsync dispatcher can route by device without an additional registry. PR 4.4
// will turn _RefreshTagDb into a writeable refresh trigger; everything 4.3 ships
// is ViewOnly.
EmitSystemTagFolder(deviceFolder, device.HostAddress);
// Pre-declared tags — always emitted; the primary config path. UDT tags with declared
// Members fan out into a sub-folder + one Variable per member instead of a single
// Structure Variable (Structure has no useful scalar value + member-addressable paths
@@ -1710,6 +1840,53 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
}
/// <summary>
/// PR abcip-4.3 — emit the per-device <c>_System</c> folder + its five read-only
/// diagnostic variables. The <c>FullName</c> on each variable encodes the owning
/// device's host address (<c>_System/&lt;host&gt;/&lt;name&gt;</c>) so the read path
/// can route to <see cref="AbCipSystemTagSource.TryRead"/> without a separate
/// registry. Names + types stay in lockstep with
/// <see cref="AbCipSystemTagSource.SystemTagNames"/>.
/// </summary>
private static void EmitSystemTagFolder(IAddressSpaceBuilder deviceFolder, string deviceHostAddress)
{
var systemFolder = deviceFolder.Folder("_System", "_System");
EmitSystemVariable(systemFolder, deviceHostAddress, "_ConnectionStatus", DriverDataType.String);
EmitSystemVariable(systemFolder, deviceHostAddress, "_ScanRate", DriverDataType.Float64);
EmitSystemVariable(systemFolder, deviceHostAddress, "_TagCount", DriverDataType.Int32);
EmitSystemVariable(systemFolder, deviceHostAddress, "_DeviceError", DriverDataType.String);
EmitSystemVariable(systemFolder, deviceHostAddress, "_LastScanTimeMs", DriverDataType.Float64);
}
private static void EmitSystemVariable(
IAddressSpaceBuilder systemFolder, string deviceHostAddress, string name, DriverDataType type)
{
var fullName = $"{AbCipSystemTagSource.SystemFolderPrefix}{deviceHostAddress}/{name}";
systemFolder.Variable(name, name, new DriverAttributeInfo(
FullName: fullName,
DriverDataType: type,
IsArray: false,
ArrayDim: null,
// Read-only for now — PR abcip-4.4 will flip _RefreshTagDb to Operate when the
// refresh trigger lands. Today the AbCip system folder has no writeable members.
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false,
Description: name switch
{
"_ConnectionStatus" => "Live HostState (Running / Stopped / Unknown / Faulted) — driven by the connectivity probe.",
"_ScanRate" => "Configured probe / poll interval in milliseconds.",
"_TagCount" => "Count of discovered tags on this device, excluding _System.",
"_DeviceError" => "Most recent driver-error message; empty when the device is healthy.",
"_LastScanTimeMs" => "Wall-clock duration of the most recent ReadAsync iteration on this device, in milliseconds.",
_ => null,
}));
}
/// <summary>Test seam — exposes the live system-tag source so unit tests can poke the snapshot directly.</summary>
internal AbCipSystemTagSource SystemTagSource => _systemTagSource;
private static DriverAttributeInfo ToAttributeInfo(AbCipTagDefinition tag) => new(
FullName: tag.Name,
DriverDataType: tag.DataType.ToDriverDataType(),
@@ -1822,6 +1999,14 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public CancellationTokenSource? ProbeCts { get; set; }
public bool ProbeInitialized { get; set; }
/// <summary>
/// PR abcip-4.3 — wall-clock duration of the most recent <see cref="AbCipDriver.ReadAsync"/>
/// iteration that touched any tag on this device, in milliseconds. Surfaces as
/// <c>_System/_LastScanTimeMs</c>; <c>0.0</c> until the first read completes so an
/// unread device shows a stable zero rather than a stale value.
/// </summary>
public double LastScanTimeMs;
public Dictionary<string, PlcTagHandle> TagHandles { get; } =
new(StringComparer.OrdinalIgnoreCase);

View File

@@ -0,0 +1,166 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// PR abcip-4.3 — diagnostic / system-tag source. Holds the latest health snapshot for
/// each device, served back through <see cref="AbCipDriver.ReadAsync"/> when the
/// incoming reference points at the synthetic <c>_System/&lt;name&gt;</c> address. The
/// driver bypasses libplctag for these reads — values come straight from the
/// <see cref="IHostConnectivityProbe"/> + <see cref="DriverHealth"/> surfaces.
/// </summary>
/// <remarks>
/// <para>Design parity with Modbus' <c>ModbusSystemTags</c> — the same five canonical
/// names are exposed under each device's <c>_System</c> folder so the Admin UI / SCADA
/// clients can pivot from "is the wire up?" to "what's our scan rate / tag count?"
/// without leaving the OPC UA address space. PR 4.4 will turn <c>_RefreshTagDb</c>
/// into a writeable refresh trigger; everything 4.3 ships is read-only.</para>
/// <list type="bullet">
/// <item><c>_ConnectionStatus</c> — string, mirrors the device's <see cref="HostState"/>.</item>
/// <item><c>_ScanRate</c> — double, the configured probe interval in milliseconds
/// (operators can compare against <c>_LastScanTimeMs</c> to spot wire stretch).</item>
/// <item><c>_TagCount</c> — int, count of discovered tags excluding the
/// <c>_System</c> folder itself.</item>
/// <item><c>_DeviceError</c> — string, the most recent driver-error message or empty.</item>
/// <item><c>_LastScanTimeMs</c> — double, wall-clock ms of the last poll-loop
/// iteration on this device.</item>
/// </list>
/// </remarks>
public sealed class AbCipSystemTagSource
{
/// <summary>Canonical names the system folder exposes — keep in lockstep with discovery.</summary>
public static readonly IReadOnlyList<string> SystemTagNames =
[
"_ConnectionStatus",
"_ScanRate",
"_TagCount",
"_DeviceError",
"_LastScanTimeMs",
];
/// <summary>
/// Address-space prefix the driver stamps on each system variable's
/// <see cref="ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverAttributeInfo.FullName"/> so
/// <see cref="AbCipDriver.ReadAsync"/> can dispatch to <see cref="TryRead"/> instead
/// of materialising a libplctag runtime.
/// </summary>
public const string SystemFolderPrefix = "_System/";
private readonly Dictionary<string, SystemTagSnapshot> _snapshots =
new(StringComparer.OrdinalIgnoreCase);
private readonly object _lock = new();
/// <summary>
/// Replace the snapshot for one device. Called on every health transition + every
/// successful read iteration so the surfaced values track the live driver loop
/// without piling up extra timers.
/// </summary>
public void Update(string deviceHostAddress, SystemTagSnapshot snapshot)
{
ArgumentNullException.ThrowIfNull(deviceHostAddress);
ArgumentNullException.ThrowIfNull(snapshot);
lock (_lock)
{
_snapshots[deviceHostAddress] = snapshot;
}
}
/// <summary>
/// Look up the current snapshot for a device. Returns <c>null</c> when no snapshot
/// has been recorded yet (the driver is still in <see cref="DriverState.Initializing"/>
/// or no probe / read iteration has fired).
/// </summary>
public SystemTagSnapshot? TryGet(string deviceHostAddress)
{
lock (_lock)
{
return _snapshots.TryGetValue(deviceHostAddress, out var s) ? s : null;
}
}
/// <summary>
/// Resolve a <c>_System/&lt;name&gt;</c> address against the current snapshot for
/// <paramref name="deviceHostAddress"/>. <paramref name="addressUnderSystem"/> may be
/// either the bare name (<c>_ConnectionStatus</c>) or the prefixed form
/// (<c>_System/_ConnectionStatus</c>) — both shapes the driver might pass in.
/// Returns <c>true</c> when the name is recognised; <paramref name="value"/> is
/// <c>null</c> when no snapshot has been recorded yet so the caller can stamp the
/// read with <c>UncertainNoCommunicationLastUsableValue</c> if it cares to.
/// </summary>
public bool TryRead(string addressUnderSystem, string deviceHostAddress, out object? value)
{
ArgumentNullException.ThrowIfNull(addressUnderSystem);
ArgumentNullException.ThrowIfNull(deviceHostAddress);
var name = addressUnderSystem.StartsWith(SystemFolderPrefix, StringComparison.Ordinal)
? addressUnderSystem[SystemFolderPrefix.Length..]
: addressUnderSystem;
// Recognised name?
var matched = false;
for (var i = 0; i < SystemTagNames.Count; i++)
{
if (string.Equals(SystemTagNames[i], name, StringComparison.Ordinal))
{
matched = true;
break;
}
}
if (!matched)
{
value = null;
return false;
}
var snapshot = TryGet(deviceHostAddress);
if (snapshot is null)
{
// Recognised name but no data yet — surface a sensible default per the type so
// clients see a stable shape instead of nulls flickering across the address space.
value = name switch
{
"_ConnectionStatus" => "Unknown",
"_DeviceError" => string.Empty,
"_TagCount" => 0,
_ => 0.0,
};
return true;
}
value = name switch
{
"_ConnectionStatus" => snapshot.ConnectionStatus,
"_ScanRate" => snapshot.ScanRateMs,
"_TagCount" => snapshot.TagCount,
"_DeviceError" => snapshot.DeviceError,
"_LastScanTimeMs" => snapshot.LastScanTimeMs,
_ => null,
};
return true;
}
/// <summary>
/// <c>true</c> when <paramref name="reference"/> targets a node under the synthetic
/// <c>_System/</c> folder. The driver's read path uses this to bypass the libplctag
/// runtime + dispatch to <see cref="TryRead"/> directly.
/// </summary>
public static bool IsSystemReference(string reference) =>
!string.IsNullOrEmpty(reference)
&& reference.StartsWith(SystemFolderPrefix, StringComparison.Ordinal);
}
/// <summary>
/// PR abcip-4.3 — immutable snapshot of one device's diagnostic surface. Five fields
/// match the five system-tag variables the discovery emits.
/// </summary>
/// <param name="ConnectionStatus">Stringified <c>HostState</c> (Running / Stopped / Unknown / Faulted).</param>
/// <param name="ScanRateMs">Configured probe / poll interval in milliseconds.</param>
/// <param name="TagCount">Count of discovered tags on this device, excluding <c>_System</c>.</param>
/// <param name="DeviceError">Most recent error message; empty when the device is healthy.</param>
/// <param name="LastScanTimeMs">Wall-clock ms the last poll iteration took on this device.</param>
public sealed record SystemTagSnapshot(
string ConnectionStatus,
double ScanRateMs,
int TagCount,
string DeviceError,
double LastScanTimeMs);

View File

@@ -0,0 +1,95 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
/// <summary>
/// PR abcip-4.3 — end-to-end coverage that the synthetic <c>_System</c> folder + its five
/// diagnostic variables ride a real ab_server lifecycle. Skipped when the binary isn't
/// on PATH (<see cref="AbServerFactAttribute"/>).
/// </summary>
[Trait("Category", "Integration")]
[Trait("Requires", "AbServer")]
public sealed class AbCipSystemTagDiscoveryTests
{
[AbServerFact]
public async Task System_folder_browses_and_each_variable_reads_non_empty()
{
var profile = KnownProfiles.ControlLogix;
var fixture = new AbServerFixture(profile);
await fixture.InitializeAsync();
try
{
var deviceUri = $"ab://127.0.0.1:{fixture.Port}/1,0";
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(deviceUri, profile.Family)],
Tags = [new AbCipTagDefinition("Counter", deviceUri, "TestDINT", AbCipDataType.DInt)],
Timeout = TimeSpan.FromSeconds(5),
}, "drv-system-tags");
await drv.InitializeAsync("{}", CancellationToken.None);
// Discovery — five system variables exposed under _System/ for the device.
var builder = new RecordingBuilder();
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldContain(f => f.BrowseName == "_System");
var systemVars = builder.Variables
.Where(v => v.Info.FullName.StartsWith("_System/"))
.Select(v => v.BrowseName)
.ToList();
systemVars.ShouldContain("_ConnectionStatus");
systemVars.ShouldContain("_ScanRate");
systemVars.ShouldContain("_TagCount");
systemVars.ShouldContain("_DeviceError");
systemVars.ShouldContain("_LastScanTimeMs");
// Read — each system variable returns Good with a non-null value, with no
// libplctag round-trip required.
var refs = systemVars
.Select(name => $"_System/{deviceUri}/{name}")
.ToList();
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
for (var i = 0; i < snaps.Count; i++)
{
snaps[i].StatusCode.ShouldBe(AbCipStatusMapper.Good,
$"system variable {refs[i]} should read Good");
snaps[i].Value.ShouldNotBeNull(
$"system variable {refs[i]} should not be null");
}
await drv.ShutdownAsync(CancellationToken.None);
}
finally
{
await fixture.DisposeAsync();
}
}
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) { }
}
}
}

View File

@@ -28,9 +28,11 @@ public sealed class AbCipDriverDiscoveryTests
builder.Folders.ShouldContain(f => f.BrowseName == "AbCip");
builder.Folders.ShouldContain(f => f.BrowseName == "ab://10.0.0.5/1,0" && f.DisplayName == "Line1-PLC");
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 abcip-4.3 — exclude the synthetic _System/ folder vars from the count.
var userVars = builder.Variables.Where(v => !v.Info.FullName.StartsWith("_System/")).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);
}
[Fact]
@@ -67,7 +69,10 @@ public sealed class AbCipDriverDiscoveryTests
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.Select(v => v.BrowseName).ShouldBe(["UserTag"]);
builder.Variables
.Where(v => !v.Info.FullName.StartsWith("_System/"))
.Select(v => v.BrowseName)
.ShouldBe(["UserTag"]);
}
[Fact]
@@ -83,7 +88,7 @@ public sealed class AbCipDriverDiscoveryTests
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.ShouldBeEmpty();
builder.Variables.Where(v => !v.Info.FullName.StartsWith("_System/")).ShouldBeEmpty();
}
[Fact]
@@ -126,7 +131,10 @@ public sealed class AbCipDriverDiscoveryTests
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.Select(v => v.Info.FullName).ShouldBe(["KeepMe"]);
builder.Variables
.Where(v => !v.Info.FullName.StartsWith("_System/"))
.Select(v => v.Info.FullName)
.ShouldBe(["KeepMe"]);
}
[Fact]
@@ -145,7 +153,10 @@ public sealed class AbCipDriverDiscoveryTests
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.Single().Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
builder.Variables
.Where(v => !v.Info.FullName.StartsWith("_System/"))
.Single()
.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
}
[Fact]

View File

@@ -0,0 +1,322 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// PR abcip-4.3 unit coverage for the diagnostic / system-tag source. Tests both the
/// standalone <see cref="AbCipSystemTagSource"/> + the <see cref="AbCipDriver"/>
/// integration: discovery emits the five canonical nodes, ReadAsync routes
/// <c>_System/...</c> through the source instead of libplctag, and snapshot updates
/// follow probe transitions.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipSystemTagSourceTests
{
[Fact]
public void Update_then_TryRead_returns_the_snapshot()
{
var src = new AbCipSystemTagSource();
const string host = "ab://10.0.0.5/1,0";
src.Update(host, new SystemTagSnapshot("Running", 500.0, 12, "", 4.7));
src.TryRead("_ConnectionStatus", host, out var status).ShouldBeTrue();
status.ShouldBe("Running");
src.TryRead("_ScanRate", host, out var rate).ShouldBeTrue();
rate.ShouldBe(500.0);
src.TryRead("_TagCount", host, out var count).ShouldBeTrue();
count.ShouldBe(12);
src.TryRead("_DeviceError", host, out var err).ShouldBeTrue();
err.ShouldBe("");
src.TryRead("_LastScanTimeMs", host, out var last).ShouldBeTrue();
last.ShouldBe(4.7);
}
[Fact]
public void TryRead_accepts_either_bare_or_prefixed_form()
{
var src = new AbCipSystemTagSource();
const string host = "ab://10.0.0.5/1,0";
src.Update(host, new SystemTagSnapshot("Running", 500.0, 1, "", 0.0));
src.TryRead("_ConnectionStatus", host, out var bare).ShouldBeTrue();
src.TryRead("_System/_ConnectionStatus", host, out var prefixed).ShouldBeTrue();
bare.ShouldBe(prefixed);
}
[Fact]
public void TryRead_unknown_name_returns_false()
{
var src = new AbCipSystemTagSource();
src.Update("h", new SystemTagSnapshot("Running", 500, 0, "", 0));
src.TryRead("_NotARealName", "h", out var v).ShouldBeFalse();
v.ShouldBeNull();
}
[Fact]
public void TryRead_without_snapshot_returns_typed_default()
{
var src = new AbCipSystemTagSource();
src.TryRead("_ConnectionStatus", "missing", out var status).ShouldBeTrue();
status.ShouldBe("Unknown");
src.TryRead("_ScanRate", "missing", out var rate).ShouldBeTrue();
rate.ShouldBe(0.0);
src.TryRead("_TagCount", "missing", out var count).ShouldBeTrue();
count.ShouldBe(0);
src.TryRead("_DeviceError", "missing", out var err).ShouldBeTrue();
err.ShouldBe(string.Empty);
src.TryRead("_LastScanTimeMs", "missing", out var last).ShouldBeTrue();
last.ShouldBe(0.0);
}
[Fact]
public void Two_devices_keep_independent_snapshots()
{
var src = new AbCipSystemTagSource();
const string a = "ab://10.0.0.5/1,0";
const string b = "ab://10.0.0.6/1,0";
src.Update(a, new SystemTagSnapshot("Running", 500, 10, "", 1.0));
src.Update(b, new SystemTagSnapshot("Stopped", 1000, 3, "boom", 99.9));
src.TryRead("_ConnectionStatus", a, out var sa).ShouldBeTrue();
src.TryRead("_ConnectionStatus", b, out var sb).ShouldBeTrue();
sa.ShouldBe("Running");
sb.ShouldBe("Stopped");
src.TryRead("_DeviceError", a, out var ea).ShouldBeTrue();
src.TryRead("_DeviceError", b, out var eb).ShouldBeTrue();
ea.ShouldBe("");
eb.ShouldBe("boom");
}
[Fact]
public void IsSystemReference_matches_only_the_System_prefix()
{
AbCipSystemTagSource.IsSystemReference("_System/foo/_ConnectionStatus").ShouldBeTrue();
AbCipSystemTagSource.IsSystemReference("_System/").ShouldBeTrue();
AbCipSystemTagSource.IsSystemReference("Motor.Speed").ShouldBeFalse();
AbCipSystemTagSource.IsSystemReference("").ShouldBeFalse();
AbCipSystemTagSource.IsSystemReference("MySystem/foo").ShouldBeFalse();
}
[Fact]
public async Task Discovery_emits_five_system_nodes_per_device()
{
var builder = new RecordingBuilder();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", DeviceName: "PLC-A")],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldContain(f => f.BrowseName == "_System");
var systemVars = builder.Variables
.Where(v => v.Info.FullName.StartsWith("_System/"))
.Select(v => v.BrowseName)
.OrderBy(s => s)
.ToList();
systemVars.ShouldBe(new[]
{
"_ConnectionStatus", "_DeviceError", "_LastScanTimeMs",
"_ScanRate", "_TagCount",
});
// All five carry the device host inside the FullName.
builder.Variables
.Where(v => v.Info.FullName.StartsWith("_System/"))
.ShouldAllBe(v => v.Info.FullName.StartsWith("_System/ab://10.0.0.5/1,0/"));
// PR 4.4 will flip _RefreshTagDb to writeable; today every system var is ViewOnly.
builder.Variables
.Where(v => v.Info.FullName.StartsWith("_System/"))
.ShouldAllBe(v => v.Info.SecurityClass == SecurityClassification.ViewOnly);
}
[Fact]
public async Task Discovery_emits_System_folder_for_each_device()
{
var builder = new RecordingBuilder();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions("ab://10.0.0.5/1,0"),
new AbCipDeviceOptions("ab://10.0.0.6/1,0"),
],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-2");
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.Count(f => f.BrowseName == "_System").ShouldBe(2);
builder.Variables.Count(v => v.Info.FullName.StartsWith("_System/")).ShouldBe(10);
builder.Variables
.Where(v => v.Info.FullName.StartsWith("_System/"))
.Select(v => v.Info.FullName)
.ShouldContain("_System/ab://10.0.0.5/1,0/_ConnectionStatus");
builder.Variables
.Where(v => v.Info.FullName.StartsWith("_System/"))
.Select(v => v.Info.FullName)
.ShouldContain("_System/ab://10.0.0.6/1,0/_ConnectionStatus");
}
[Fact]
public async Task ReadAsync_dispatches_System_reference_to_source_not_libplctag()
{
// FakeAbCipTagFactory throws when used; if the driver materialises a libplctag handle
// for a _System/... reference, this test will trip ThrowOnRead and surface a Bad status.
var factory = new FakeAbCipTagFactory
{
Customise = p => new FakeAbCipTag(p) { ThrowOnRead = true },
};
const string host = "ab://10.0.0.5/1,0";
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(host)],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
// Seed the source so we have a known snapshot to read back.
drv.SystemTagSource.Update(host,
new SystemTagSnapshot("Running", 250.0, 5, "", 7.5));
var snaps = await drv.ReadAsync(
[$"_System/{host}/_ConnectionStatus", $"_System/{host}/_TagCount"],
CancellationToken.None);
snaps[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
snaps[0].Value.ShouldBe("Running");
snaps[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
snaps[1].Value.ShouldBe(5);
}
[Fact]
public async Task ReadAsync_unknown_System_name_returns_BadNodeIdUnknown()
{
const string host = "ab://10.0.0.5/1,0";
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(host)],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
var snaps = await drv.ReadAsync(
[$"_System/{host}/_NotARealName"], CancellationToken.None);
snaps[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
}
[Fact]
public async Task TagCount_reflects_count_excluding_System_folder()
{
var builder = new RecordingBuilder();
const string host = "ab://10.0.0.5/1,0";
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(host)],
Tags =
[
new AbCipTagDefinition("Speed", host, "Motor1.Speed", AbCipDataType.DInt),
new AbCipTagDefinition("Temp", host, "T", AbCipDataType.Real),
new AbCipTagDefinition("Pressure", host, "P", AbCipDataType.Real),
],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
// Read the synthetic _TagCount and assert it matches the three pre-declared tags.
var snaps = await drv.ReadAsync(
[$"_System/{host}/_TagCount"], CancellationToken.None);
snaps[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
snaps[0].Value.ShouldBe(3);
}
[Fact]
public async Task ResolveHost_for_System_reference_returns_embedded_device_host()
{
const string a = "ab://10.0.0.5/1,0";
const string b = "ab://10.0.0.6/1,0";
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(a), new AbCipDeviceOptions(b)],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.ResolveHost($"_System/{a}/_ConnectionStatus").ShouldBe(a);
drv.ResolveHost($"_System/{b}/_ScanRate").ShouldBe(b);
}
[Fact]
public async Task Probe_transition_updates_system_tag_snapshot()
{
var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Status = 0 } };
const string host = "ab://10.0.0.5/1,0";
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(host)],
Probe = new AbCipProbeOptions
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(100),
Timeout = TimeSpan.FromMilliseconds(50),
ProbeTagPath = "@raw_cpu_type",
},
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
// Wait until the snapshot itself flips to Running — the probe transition runs the
// snapshot refresh inline so once the snapshot reflects Running, GetHostStatuses must
// too. Polling the snapshot directly avoids a race against the post-transition
// refresh window.
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2);
object? status = null;
while (DateTime.UtcNow < deadline)
{
drv.SystemTagSource.TryRead("_ConnectionStatus", host, out status);
if (Equals(status, "Running")) break;
await Task.Delay(20);
}
status.ShouldBe("Running");
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running);
await drv.ShutdownAsync(CancellationToken.None);
}
// ---- helpers (mirror AbCipDriverDiscoveryTests) ----
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) { }
}
}
}

View File

@@ -266,7 +266,11 @@ public sealed class AbCipUdtMemberTests
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.Select(v => v.BrowseName).ShouldBe(["FlatA", "Speed", "FlatB"], ignoreOrder: true);
// PR abcip-4.3 — exclude the synthetic _System/ folder vars from the count.
builder.Variables
.Where(v => !v.Info.FullName.StartsWith("_System/"))
.Select(v => v.BrowseName)
.ShouldBe(["FlatA", "Speed", "FlatB"], ignoreOrder: true);
}
// ---- helpers ----