Auto: abcip-4.3 — diagnostic / system tags as browseable variables
Closes #240
This commit is contained in:
@@ -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/<host>/<name></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/<host>/<name></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/<host>/<name></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);
|
||||
|
||||
|
||||
166
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipSystemTagSource.cs
Normal file
166
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipSystemTagSource.cs
Normal 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/<name></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/<name></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);
|
||||
Reference in New Issue
Block a user