Task #141 — Modbus subscribe-side knobs (deadband + write-on-change)
Two driver-side filters that ≥5 of 6 surveyed vendors expose: 1. Per-tag Deadband (double?, on ModbusTagDefinition) — when set, the PollGroupEngine onChange callback suppresses publishes whose distance from the last-published value is below the threshold. Reduces wire traffic to OPC UA clients on noisy analog signals (flow meters, temperatures). Numeric scalar types only — Bool / BitInRegister / String / array tags publish unconditionally. 2. WriteOnChangeOnly (bool, on ModbusDriverOptions) — when true, the driver short-circuits writes whose value matches the most recent successful write to that tag. Saves PLC bandwidth on clients that re-publish the same setpoint every scan. Cache invalidates on any read that returns a different value, so HMI-side changes don't get masked. Both default off so existing deployments see no behaviour change. Implementation: - ShouldPublish guard wraps the existing OnDataChange invocation. First sample always passes through (no baseline); subsequent samples compare via Convert.ToDouble for the cross-numeric-type math. - IsRedundantWrite check at the top of WriteAsync; on success the cache is populated. Object.Equals handles boxed-numeric equality; arrays are excluded (reference-equality would never match anyway). - ReadAsync invalidates the WriteOnChangeOnly cache when the new value differs from the cached last-written value. Tests (5 new ModbusSubscribeOptionsTests): - Deadband suppresses sub-threshold changes (100 → 102 → 106 → 107 with deadband=5 publishes 100 and 106 only). - Deadband=null still publishes every change. - WriteOnChangeOnly suppresses 3 identical 42 writes (only first hits wire). - WriteOnChangeOnly default false hits the wire every time. - Read-divergence cache invalidation: external panel write to 99, our client's re-write of 42 must NOT be suppressed. 220/220 unit tests green; existing ProtocolOptions tests hardened against probe-loop noise by disabling the probe in their fixtures.
This commit is contained in:
@@ -55,7 +55,47 @@ public sealed class ModbusDriver
|
||||
_poll = new PollGroupEngine(
|
||||
reader: ReadAsync,
|
||||
onChange: (handle, tagRef, snapshot) =>
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
|
||||
{
|
||||
// #141 deadband filter: when configured on a tag, suppress publishes whose
|
||||
// numeric distance from the last-published value is below the threshold.
|
||||
if (!ShouldPublish(tagRef, snapshot)) return;
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot));
|
||||
});
|
||||
}
|
||||
|
||||
// Last-published value per tag, keyed by FullReference. Used by ShouldPublish to apply
|
||||
// the deadband filter. Stored as object so all numeric types share one map; the comparison
|
||||
// does a typed cast inside.
|
||||
private readonly Dictionary<string, object> _lastPublishedByRef = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Last-written value per tag for the WriteOnChangeOnly suppression. Invalidated by reads
|
||||
// that return a different value (so an HMI-side change doesn't get masked).
|
||||
private readonly Dictionary<string, object?> _lastWrittenByRef = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly object _lastWrittenLock = new();
|
||||
|
||||
private bool ShouldPublish(string tagRef, DataValueSnapshot snapshot)
|
||||
{
|
||||
if (!_tagsByName.TryGetValue(tagRef, out var tag) || tag.Deadband is null) return true;
|
||||
if (snapshot.Value is null) return true;
|
||||
// Deadband only applies to numeric scalar types — array / Bool / String publishes
|
||||
// unconditionally. Easier to special-case skip than to enumerate the supported types.
|
||||
if (tag.ArrayCount.HasValue || tag.DataType is ModbusDataType.Bool or ModbusDataType.BitInRegister or ModbusDataType.String)
|
||||
return true;
|
||||
|
||||
if (!_lastPublishedByRef.TryGetValue(tagRef, out var prev))
|
||||
{
|
||||
// First sample passes through unconditionally — the threshold can't be evaluated
|
||||
// without a baseline. The publish lands and seeds the comparison.
|
||||
_lastPublishedByRef[tagRef] = snapshot.Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
var newD = Convert.ToDouble(snapshot.Value);
|
||||
var oldD = Convert.ToDouble(prev);
|
||||
if (Math.Abs(newD - oldD) < tag.Deadband.Value) return false;
|
||||
|
||||
_lastPublishedByRef[tagRef] = snapshot.Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
public string DriverInstanceId => _driverInstanceId;
|
||||
@@ -151,6 +191,19 @@ public sealed class ModbusDriver
|
||||
var value = await ReadOneAsync(transport, tag, cancellationToken).ConfigureAwait(false);
|
||||
results[i] = new DataValueSnapshot(value, 0u, now, now);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
|
||||
// Invalidate the WriteOnChangeOnly cache when the read returns a different value
|
||||
// — typically an HMI-side or PLC-internal change. Without this, a setpoint
|
||||
// tweaked at the panel could be silently re-suppressed when our client tried
|
||||
// to restore it.
|
||||
if (_options.WriteOnChangeOnly)
|
||||
{
|
||||
lock (_lastWrittenLock)
|
||||
{
|
||||
if (_lastWrittenByRef.TryGetValue(fullReferences[i], out var prev) && !Equals(prev, value))
|
||||
_lastWrittenByRef.Remove(fullReferences[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (ModbusException mex)
|
||||
{
|
||||
@@ -387,10 +440,20 @@ public sealed class ModbusDriver
|
||||
results[i] = new WriteResult(StatusBadNotWritable);
|
||||
continue;
|
||||
}
|
||||
// #141 WriteOnChangeOnly suppression: skip the wire round-trip when the same value
|
||||
// was already successfully written and no read since has invalidated the cache.
|
||||
if (_options.WriteOnChangeOnly && IsRedundantWrite(w.FullReference, w.Value))
|
||||
{
|
||||
results[i] = new WriteResult(0u);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await WriteOneAsync(transport, tag, w.Value, cancellationToken).ConfigureAwait(false);
|
||||
results[i] = new WriteResult(0u);
|
||||
if (_options.WriteOnChangeOnly)
|
||||
lock (_lastWrittenLock) _lastWrittenByRef[w.FullReference] = w.Value;
|
||||
}
|
||||
catch (ModbusException mex)
|
||||
{
|
||||
@@ -404,6 +467,20 @@ public sealed class ModbusDriver
|
||||
return results;
|
||||
}
|
||||
|
||||
private bool IsRedundantWrite(string tagRef, object? value)
|
||||
{
|
||||
lock (_lastWrittenLock)
|
||||
{
|
||||
if (!_lastWrittenByRef.TryGetValue(tagRef, out var prev)) return false;
|
||||
// Object.Equals handles boxed-numeric equality (5 == 5 even if one was short and
|
||||
// one int through boxing). For arrays we deliberately don't suppress — equality
|
||||
// semantics on arrays are reference-only so the cache miss is the safer answer.
|
||||
if (prev is null || value is null) return Equals(prev, value);
|
||||
if (prev is Array || value is Array) return false;
|
||||
return prev.Equals(value);
|
||||
}
|
||||
}
|
||||
|
||||
// BitInRegister writes need a read-modify-write against the full holding register. A
|
||||
// per-register lock keeps concurrent bit-write callers from stomping on each other —
|
||||
// Write bit 0 and Write bit 5 targeting the same register can arrive on separate
|
||||
|
||||
Reference in New Issue
Block a user