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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user