@@ -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/<host>/<name></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/<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"/>.
|
||||
/// 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/<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);
|
||||
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,
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user