Auto: abcip-4.3 — diagnostic / system tags as browseable variables

Closes #240
This commit is contained in:
Joseph Doherty
2026-04-26 02:55:56 -04:00
parent 9c108cd00a
commit 901a5b9b21
10 changed files with 915 additions and 10 deletions

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);