Auto: abcip-4.2 — write deadband / write-on-change

Closes #239
This commit is contained in:
Joseph Doherty
2026-04-26 02:31:50 -04:00
parent 9202ebe5ef
commit da9936f7f0
9 changed files with 855 additions and 5 deletions

View File

@@ -35,6 +35,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private readonly Dictionary<string, AbCipTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
private readonly AbCipAlarmProjection _alarmProjection;
private readonly SemaphoreSlim _discoverySemaphore = new(1, 1);
private readonly AbCipWriteCoalescer _writeCoalescer = new();
private DriverHealth _health = new(DriverState.Unknown, null, null);
public event EventHandler<DataChangeEventArgs>? OnDataChange;
@@ -415,6 +416,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
_devices.Clear();
_tagsByName.Clear();
// PR abcip-4.2 — wipe the write-coalescer cache on shutdown. Reinitializing the driver
// (Tier-B remediation) starts from a clean slate so the first write after restart pays
// the full round-trip rather than reusing stale cached state.
_writeCoalescer.ResetAll();
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
}
@@ -637,6 +642,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
state.HostState = newState;
state.HostStateChangedUtc = DateTime.UtcNow;
}
// PR abcip-4.2 — drop the per-device write-coalescer cache when we lose the wire. The
// PLC may have been restarted while we were offline + our cached "we already wrote 42"
// is no longer valid PLC state. Reset on the Stopped transition (and again on the
// recovery edge for safety) so the first post-reconnect write of any value pays the
// full round-trip + the coalescer rebuilds its cache from the new baseline.
if (newState == HostState.Stopped || newState == HostState.Running)
_writeCoalescer.Reset(state.Options.HostAddress);
OnHostStatusChanged?.Invoke(this,
new HostStatusChangedEventArgs(state.Options.HostAddress, old, newState));
}
@@ -1255,6 +1267,16 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var def = entry.Definition;
var w = entry.Request;
var now = DateTime.UtcNow;
// PR abcip-4.2 — write deadband / write-on-change. Consult the coalescer first; a
// suppression decision returns Good without hitting libplctag so the OPC UA client sees
// the same write semantics it always has, the wire just doesn't move. Driver health is
// intentionally left alone on suppression — a coalesced write is neither a success nor
// a failure of the underlying connection. Bit-RMW writes go through their own path
// (ExecuteBitRmwWriteAsync) which has its own coalescer call site.
if (_writeCoalescer.ShouldSuppress(def.DeviceHostAddress, def, w.Value))
return (entry.OriginalIndex, AbCipStatusMapper.Good);
try
{
var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
@@ -1265,6 +1287,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
if (status == 0)
{
_health = new DriverHealth(DriverState.Healthy, now, null);
_writeCoalescer.Record(def.DeviceHostAddress, def, w.Value);
return (entry.OriginalIndex, AbCipStatusMapper.Good);
}
return (entry.OriginalIndex, AbCipStatusMapper.MapLibplctagStatus(status));
@@ -1309,13 +1332,24 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private async Task<uint> ExecuteBitRmwWriteAsync(
DeviceState device, AbCipMultiWritePlanner.ClassifiedWrite entry, CancellationToken ct)
{
// PR abcip-4.2 — bit-RMW writes go through the coalescer too. The deadband path is
// never useful on a single-bit BOOL (deadband < 1 collapses to equality) but
// WriteOnChange is — a UI that toggles a SetPoint.Reset bit at every cycle benefits
// from suppressing the redundant pulses.
var def = entry.Definition;
if (_writeCoalescer.ShouldSuppress(def.DeviceHostAddress, def, entry.Request.Value))
return AbCipStatusMapper.Good;
try
{
var bit = entry.ParsedPath!.BitIndex!.Value;
var code = await WriteBitInDIntAsync(device, entry.ParsedPath, bit, entry.Request.Value, ct)
.ConfigureAwait(false);
if (code == AbCipStatusMapper.Good)
{
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
_writeCoalescer.Record(def.DeviceHostAddress, def, entry.Request.Value);
}
return code;
}
catch (OperationCanceledException)
@@ -1478,7 +1512,30 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
return runtime;
}
public DriverHealth GetHealth() => _health;
public DriverHealth GetHealth() => _health with { Diagnostics = BuildDiagnostics() };
/// <summary>
/// PR abcip-4.2 — driver-attributable counter snapshot exposed via
/// <see cref="DriverHealth.Diagnostics"/> + the <c>driver-diagnostics</c> RPC. Names use
/// the <c>"&lt;DriverType&gt;.&lt;Counter&gt;"</c> convention so the Admin UI can render
/// them alongside Modbus / S7 / OPC UA Client metrics without per-driver special-casing.
/// Counters today: <c>AbCip.WritesSuppressed</c> (writes the coalescer skipped because
/// deadband / write-on-change suppressed them) and <c>AbCip.WritesPassedThrough</c>
/// (writes that hit the wire after consulting the coalescer). Future PRs add CIP-level
/// counters (Forward Open count, multi-service-packet ratio, etc.) by extending this
/// dictionary.
/// </summary>
private IReadOnlyDictionary<string, double> BuildDiagnostics() => new Dictionary<string, double>
{
["AbCip.WritesSuppressed"] = _writeCoalescer.TotalWritesSuppressed,
["AbCip.WritesPassedThrough"] = _writeCoalescer.TotalWritesPassedThrough,
};
/// <summary>
/// Test seam — exposes the live coalescer for unit tests that want to inspect counters
/// without rebuilding the diagnostics dictionary on every assertion.
/// </summary>
internal AbCipWriteCoalescer WriteCoalescer => _writeCoalescer;
/// <summary>
/// CLR-visible allocation footprint only — libplctag's native heap is invisible to the