Auto: abcip-4.4 — _RefreshTagDb writeable system tag

Closes #241
This commit is contained in:
Joseph Doherty
2026-04-26 03:16:28 -04:00
parent e46e4de31f
commit e0e5e04e48
8 changed files with 877 additions and 45 deletions

View File

@@ -37,6 +37,11 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private readonly SemaphoreSlim _discoverySemaphore = new(1, 1);
private readonly AbCipWriteCoalescer _writeCoalescer = new();
private readonly AbCipSystemTagSource _systemTagSource = new();
// PR abcip-4.4 — cached builder reference so a _RefreshTagDb write can dispatch to
// RebrowseAsync without an out-of-band call back into Core. Set by every successful
// DiscoverAsync / RebrowseAsync run; null before first discovery (a refresh write that
// arrives before the address space exists is a no-op + reports Good).
private IAddressSpaceBuilder? _cachedBuilder;
private DriverHealth _health = new(DriverState.Unknown, null, null);
public event EventHandler<DataChangeEventArgs>? OnDataChange;
@@ -1330,22 +1335,60 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
ArgumentNullException.ThrowIfNull(writes);
var results = new WriteResult[writes.Count];
// PR abcip-4.4 — intercept _System/<host>/_RefreshTagDb writes BEFORE the
// multi-write planner runs. These are driver-local control writes: they never
// hit libplctag, they're not coalesced, and they don't ride the per-device
// bulkhead because the dispatch is in-memory (RebrowseAsync's discovery semaphore
// already serialises concurrent refreshes). Truthy writes invoke RebrowseAsync;
// falsy / unparseable writes report Good as a no-op so a UI that toggles the
// trigger off after firing it doesn't see a phantom error.
//
// Filter the request list to a non-system slice for the planner so genuine tag
// writes still flow through the multi-write packing path unchanged.
var nonSystemIndices = new List<int>(writes.Count);
for (var i = 0; i < writes.Count; i++)
{
var w = writes[i];
if (AbCipSystemTagSource.IsSystemReference(w.FullReference))
{
results[i] = await HandleSystemWriteAsync(w, cancellationToken).ConfigureAwait(false);
}
else
{
nonSystemIndices.Add(i);
}
}
if (nonSystemIndices.Count == 0) return results;
// Slice the input down to the genuine-tag writes; the planner reports preflight
// failures back through the original-index closure so we have to remap.
var nonSystemWrites = new WriteRequest[nonSystemIndices.Count];
for (var i = 0; i < nonSystemIndices.Count; i++)
nonSystemWrites[i] = writes[nonSystemIndices[i]];
var plans = AbCipMultiWritePlanner.Build(
writes, _tagsByName, _devices,
reportPreflight: (idx, code) => results[idx] = new WriteResult(code));
nonSystemWrites, _tagsByName, _devices,
reportPreflight: (slicedIdx, code) =>
results[nonSystemIndices[slicedIdx]] = new WriteResult(code));
// PR abcip-4.4 — the planner's OriginalIndex addresses the sliced input list, so
// every write back into the caller-visible results array translates through
// nonSystemIndices to land at the right slot.
int Remap(int slicedIdx) => nonSystemIndices[slicedIdx];
foreach (var plan in plans)
{
if (!_devices.TryGetValue(plan.DeviceHostAddress, out var device))
{
foreach (var e in plan.Packable) results[e.OriginalIndex] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
foreach (var e in plan.BitRmw) results[e.OriginalIndex] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
foreach (var e in plan.Packable) results[Remap(e.OriginalIndex)] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
foreach (var e in plan.BitRmw) results[Remap(e.OriginalIndex)] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
continue;
}
// Bit-RMW writes always serialise per-parent — never packed.
foreach (var entry in plan.BitRmw)
results[entry.OriginalIndex] = new WriteResult(
results[Remap(entry.OriginalIndex)] = new WriteResult(
await ExecuteBitRmwWriteAsync(device, entry, cancellationToken).ConfigureAwait(false));
if (plan.Packable.Count == 0) continue;
@@ -1362,7 +1405,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
var outcomes = await Task.WhenAll(tasks).ConfigureAwait(false);
foreach (var (idx, code) in outcomes)
results[idx] = new WriteResult(code);
results[Remap(idx)] = new WriteResult(code);
}
else
{
@@ -1371,7 +1414,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
{
var code = await ExecutePackableWriteAsync(device, entry, cancellationToken)
.ConfigureAwait(false);
results[entry.OriginalIndex] = new WriteResult(code.code);
results[Remap(entry.OriginalIndex)] = new WriteResult(code.code);
}
}
}
@@ -1379,6 +1422,66 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
return results;
}
/// <summary>
/// PR abcip-4.4 — handle a write against a <c>_System/&lt;host&gt;/&lt;name&gt;</c>
/// reference. Today only <c>_RefreshTagDb</c> is writeable; everything else under
/// <c>_System/</c> is <see cref="SecurityClassification.ViewOnly"/> + the OPC UA
/// server layer rejects the write before it reaches the driver. The driver still
/// defends in depth here: an unrecognised system-tag write reports
/// <see cref="AbCipStatusMapper.BadNotWritable"/> rather than silently succeeding.
/// </summary>
private async Task<WriteResult> HandleSystemWriteAsync(WriteRequest write, CancellationToken cancellationToken)
{
var deviceHost = ExtractSystemDeviceHost(write.FullReference);
var nameUnderSystem = ExtractSystemTagName(write.FullReference);
if (deviceHost is null || nameUnderSystem is null)
return new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
if (!AbCipSystemTagSource.IsRefreshTagDb(nameUnderSystem))
{
// Read-only system variable — the server-layer ACL should already have
// rejected this, but defend in depth so a misconfigured client gets a
// recognisable error instead of "Good but nothing happened".
return new WriteResult(AbCipStatusMapper.BadNotWritable);
}
// Falsy / unparseable writes are a no-op so a UI that resets the trigger flag
// back to false (after firing it) doesn't see a phantom error. Good is the same
// shape Kepware's driver returns for an inert trigger write.
if (!AbCipSystemTagSource.IsTruthyRefresh(write.Value))
return new WriteResult(AbCipStatusMapper.Good);
if (!_devices.ContainsKey(deviceHost))
return new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
var builder = _cachedBuilder;
if (builder is null)
{
// Refresh fired before discovery had a chance to cache the builder — bump the
// counter (so operators can correlate the click with the lack of effect) but
// skip the dispatch since RebrowseAsync needs a builder to stream nodes into.
_systemTagSource.RecordRefreshTrigger(deviceHost);
return new WriteResult(AbCipStatusMapper.Good);
}
try
{
await RebrowseAsync(builder, cancellationToken).ConfigureAwait(false);
_systemTagSource.RecordRefreshTrigger(deviceHost);
return new WriteResult(AbCipStatusMapper.Good);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"_RefreshTagDb dispatch failed: {ex.Message}");
return new WriteResult(AbCipStatusMapper.BadCommunicationError);
}
}
/// <summary>
/// Execute one packable write — encode the value into the per-tag runtime, flush, and
/// map the resulting libplctag status. Exception-to-StatusCode mapping mirrors the
@@ -1652,6 +1755,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
{
["AbCip.WritesSuppressed"] = _writeCoalescer.TotalWritesSuppressed,
["AbCip.WritesPassedThrough"] = _writeCoalescer.TotalWritesPassedThrough,
// PR abcip-4.4 — total _RefreshTagDb truthy writes that dispatched to RebrowseAsync.
["AbCip.RefreshTriggers"] = _systemTagSource.TotalRefreshTriggers,
};
/// <summary>
@@ -1722,6 +1827,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private async Task DiscoverCoreAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
// PR abcip-4.4 — remember the most-recent builder so a subsequent _RefreshTagDb
// write can hand it back to RebrowseAsync without a callback through Core. The
// IAddressSpaceBuilder contract documents that builders are reusable for the
// lifetime of the address space + the host owns the lifecycle, so caching the
// reference here is safe.
_cachedBuilder = builder;
var root = builder.Folder("AbCip", "AbCip");
foreach (var device in _options.Devices)
@@ -1841,25 +1952,29 @@ 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"/>.
/// PR abcip-4.3 — emit the per-device <c>_System</c> folder + its diagnostic
/// variables. PR abcip-4.4 added <c>_RefreshTagDb</c> as the sixth writeable entry.
/// 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);
EmitSystemVariable(systemFolder, deviceHostAddress, "_ConnectionStatus", DriverDataType.String, writeable: false);
EmitSystemVariable(systemFolder, deviceHostAddress, "_ScanRate", DriverDataType.Float64, writeable: false);
EmitSystemVariable(systemFolder, deviceHostAddress, "_TagCount", DriverDataType.Int32, writeable: false);
EmitSystemVariable(systemFolder, deviceHostAddress, "_DeviceError", DriverDataType.String, writeable: false);
EmitSystemVariable(systemFolder, deviceHostAddress, "_LastScanTimeMs", DriverDataType.Float64, writeable: false);
// PR abcip-4.4 — Kepware-style writeable refresh trigger. Reads return false; a
// truthy write dispatches to RebrowseAsync via the cached IAddressSpaceBuilder.
EmitSystemVariable(systemFolder, deviceHostAddress, AbCipSystemTagSource.RefreshTagDbName,
DriverDataType.Boolean, writeable: true);
}
private static void EmitSystemVariable(
IAddressSpaceBuilder systemFolder, string deviceHostAddress, string name, DriverDataType type)
IAddressSpaceBuilder systemFolder, string deviceHostAddress, string name, DriverDataType type, bool writeable)
{
var fullName = $"{AbCipSystemTagSource.SystemFolderPrefix}{deviceHostAddress}/{name}";
systemFolder.Variable(name, name, new DriverAttributeInfo(
@@ -1867,11 +1982,15 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
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,
// PR abcip-4.4 _RefreshTagDb is the only writeable entry; everything else
// remains ViewOnly so subscribed clients can't accidentally write the
// diagnostic surface from a misbehaving SCADA template.
SecurityClass: writeable ? SecurityClassification.Operate : SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
// _RefreshTagDb is idempotent in spirit (writing true twice is the same as
// once — both fire one rebrowse) but Kepware-style triggers don't deduplicate
// because operators expect each click to issue a fresh refresh.
WriteIdempotent: false,
Description: name switch
{
@@ -1880,6 +1999,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
"_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.",
AbCipSystemTagSource.RefreshTagDbName =>
"Writeable Kepware-style refresh trigger. Reads always return false. Writing a truthy value (true / non-zero / \"true\" / \"1\") forces a controller-side @tags re-walk via RebrowseAsync.",
_ => null,
}));
}

View File

@@ -10,11 +10,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <see cref="IHostConnectivityProbe"/> + <see cref="DriverHealth"/> surfaces.
/// </summary>
/// <remarks>
/// <para>Design parity with Modbus' <c>ModbusSystemTags</c> — the same five canonical
/// <para>Design parity with Modbus' <c>ModbusSystemTags</c> — the same six 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>
/// without leaving the OPC UA address space. PR 4.4 turns <c>_RefreshTagDb</c> into a
/// writeable Kepware-style trigger — reads always return <c>false</c>, writes of any
/// truthy value dispatch to <see cref="AbCipDriver.RebrowseAsync"/>.</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
@@ -24,10 +25,21 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <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>
/// <item><c>_RefreshTagDb</c> — boolean, writeable Kepware-style trigger. Reads
/// always return <c>false</c>; writing any truthy value (true / non-zero / "true"
/// / "1") forces a controller-side re-walk via
/// <see cref="AbCipDriver.RebrowseAsync"/>.</item>
/// </list>
/// </remarks>
public sealed class AbCipSystemTagSource
{
/// <summary>
/// PR abcip-4.4 — the writeable Kepware-style refresh-trigger system tag. Reads
/// always return <c>false</c>; writes of any truthy value cause the driver to
/// re-run discovery against the live controller symbol table.
/// </summary>
public const string RefreshTagDbName = "_RefreshTagDb";
/// <summary>Canonical names the system folder exposes — keep in lockstep with discovery.</summary>
public static readonly IReadOnlyList<string> SystemTagNames =
[
@@ -36,6 +48,7 @@ public sealed class AbCipSystemTagSource
"_TagCount",
"_DeviceError",
"_LastScanTimeMs",
RefreshTagDbName,
];
/// <summary>
@@ -48,8 +61,49 @@ public sealed class AbCipSystemTagSource
private readonly Dictionary<string, SystemTagSnapshot> _snapshots =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, long> _refreshTriggers =
new(StringComparer.OrdinalIgnoreCase);
private long _totalRefreshTriggers;
private readonly object _lock = new();
/// <summary>
/// PR abcip-4.4 — total <c>_RefreshTagDb</c> writes across every device managed by
/// the driver. Surfaced through <see cref="AbCipDriver.GetHealth"/> as the
/// <c>AbCip.RefreshTriggers</c> diagnostic counter.
/// </summary>
public long TotalRefreshTriggers => Interlocked.Read(ref _totalRefreshTriggers);
/// <summary>
/// PR abcip-4.4 — number of times <c>_RefreshTagDb</c> has been written for this
/// specific device. Returns <c>0</c> when the device has never seen a refresh
/// write (or isn't known to the source).
/// </summary>
public long GetRefreshTriggerCount(string deviceHostAddress)
{
ArgumentNullException.ThrowIfNull(deviceHostAddress);
lock (_lock)
{
return _refreshTriggers.TryGetValue(deviceHostAddress, out var n) ? n : 0;
}
}
/// <summary>
/// PR abcip-4.4 — bump the per-device + global refresh counters for a successful
/// <c>_RefreshTagDb</c> write. Called from <see cref="AbCipDriver.WriteAsync"/>
/// after the rebrowse dispatch lands so a failed dispatch doesn't pollute the
/// counter.
/// </summary>
public void RecordRefreshTrigger(string deviceHostAddress)
{
ArgumentNullException.ThrowIfNull(deviceHostAddress);
Interlocked.Increment(ref _totalRefreshTriggers);
lock (_lock)
{
_refreshTriggers[deviceHostAddress] =
(_refreshTriggers.TryGetValue(deviceHostAddress, out var n) ? n : 0) + 1;
}
}
/// <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
@@ -112,6 +166,15 @@ public sealed class AbCipSystemTagSource
return false;
}
// PR abcip-4.4 — _RefreshTagDb is a Kepware-style writeable trigger: reads always
// return false (the trigger latches back to "idle" the moment the dispatch returns)
// so subscribed clients see a stable shape regardless of how many refreshes fired.
if (string.Equals(name, RefreshTagDbName, StringComparison.Ordinal))
{
value = false;
return true;
}
var snapshot = TryGet(deviceHostAddress);
if (snapshot is null)
{
@@ -139,6 +202,51 @@ public sealed class AbCipSystemTagSource
return true;
}
/// <summary>
/// PR abcip-4.4 — recognise <c>_RefreshTagDb</c> writes. Accepts both bare
/// (<c>_RefreshTagDb</c>) + prefixed (<c>_System/_RefreshTagDb</c>) shapes so
/// callers can pass whichever form the address came in as.
/// </summary>
public static bool IsRefreshTagDb(string addressUnderSystem)
{
if (string.IsNullOrEmpty(addressUnderSystem)) return false;
var name = addressUnderSystem.StartsWith(SystemFolderPrefix, StringComparison.Ordinal)
? addressUnderSystem[SystemFolderPrefix.Length..]
: addressUnderSystem;
return string.Equals(name, RefreshTagDbName, StringComparison.Ordinal);
}
/// <summary>
/// PR abcip-4.4 — Kepware-style truthy coercion for the <c>_RefreshTagDb</c> trigger.
/// Mirrors the same wire-format the OPC UA stack delivers: booleans pass through;
/// integers / doubles trigger when non-zero; strings parse as <c>"true"</c> /
/// <c>"1"</c> (case-insensitive). Anything else (null, empty, an unparseable string)
/// is treated as <c>false</c> + the write becomes a no-op.
/// </summary>
public static bool IsTruthyRefresh(object? value)
{
if (value is null) return false;
return value switch
{
bool b => b,
sbyte s => s != 0,
byte b => b != 0,
short s => s != 0,
ushort u => u != 0,
int i => i != 0,
uint u => u != 0,
long l => l != 0,
ulong u => u != 0,
float f => f != 0f && !float.IsNaN(f),
double d => d != 0.0 && !double.IsNaN(d),
decimal m => m != 0m,
string s => bool.TryParse(s, out var parsed)
? parsed
: (int.TryParse(s, out var n) && n != 0),
_ => false,
};
}
/// <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