@@ -17,6 +17,18 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
private readonly PollGroupEngine _poll;
|
||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, AbLegacyTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// PR 8 — per-tag last published <c>(value, status)</c> cache for the deadband filter.
|
||||
/// Layered on top of <see cref="PollGroupEngine"/> because the engine's change-detection
|
||||
/// is binary (publish on any value/status diff). Cleared on <see cref="ShutdownAsync"/>
|
||||
/// so a reconnect doesn't suppress legitimate post-reconnect updates against stale state.
|
||||
/// Keyed by full reference (== tag name) — matches the engine's own <c>LastValues</c> key
|
||||
/// space.
|
||||
/// </summary>
|
||||
private readonly Dictionary<string, (object? Value, uint StatusCode)> _lastPublished =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly object _lastPublishedLock = new();
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
@@ -31,8 +43,99 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
_tagFactory = tagFactory ?? new LibplctagLegacyTagFactory();
|
||||
_poll = new PollGroupEngine(
|
||||
reader: ReadAsync,
|
||||
onChange: (handle, tagRef, snapshot) =>
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
|
||||
onChange: DispatchPollChange);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR 8 — wraps the <see cref="PollGroupEngine"/> change callback with a per-tag
|
||||
/// deadband filter. Booleans bypass (publish on every edge); strings + status changes
|
||||
/// always publish; numerics pass only when <c>|new - prev|</c> meets the configured
|
||||
/// absolute and / or percent deadband. First-seen always publishes.
|
||||
/// </summary>
|
||||
private void DispatchPollChange(ISubscriptionHandle handle, string tagRef, DataValueSnapshot snapshot)
|
||||
{
|
||||
if (!ShouldPublish(tagRef, snapshot)) return;
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR 8 — deadband decision for one new sample. Updates the per-tag last-published
|
||||
/// cache when the publish goes through so the next sample compares against the actual
|
||||
/// emitted value (not every polled value).
|
||||
/// </summary>
|
||||
internal bool ShouldPublish(string tagRef, DataValueSnapshot snapshot)
|
||||
{
|
||||
// Tags absent from config (impossible via the engine path, defensive against callers
|
||||
// that exercise the dispatch logic in isolation) bypass the filter.
|
||||
var hasTag = _tagsByName.TryGetValue(tagRef, out var def);
|
||||
|
||||
lock (_lastPublishedLock)
|
||||
{
|
||||
var firstSeen = !_lastPublished.TryGetValue(tagRef, out var prev);
|
||||
|
||||
// First-seen, status change, or no tag config: always publish.
|
||||
if (firstSeen || prev.StatusCode != snapshot.StatusCode || !hasTag)
|
||||
{
|
||||
_lastPublished[tagRef] = (snapshot.Value, snapshot.StatusCode);
|
||||
return true;
|
||||
}
|
||||
|
||||
// No deadband configured -> defer to PollGroupEngine's value-equality decision
|
||||
// (the engine already filtered to "different from last engine snapshot" before we
|
||||
// got here, so any sample reaching this point is a legitimate change).
|
||||
if (def!.AbsoluteDeadband is null && def.PercentDeadband is null)
|
||||
{
|
||||
_lastPublished[tagRef] = (snapshot.Value, snapshot.StatusCode);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Booleans + strings + non-numerics: deadband is meaningless; publish whenever the
|
||||
// value differs from the last published one.
|
||||
if (!TryAsDouble(snapshot.Value, out var newD) || !TryAsDouble(prev.Value, out var prevD))
|
||||
{
|
||||
if (Equals(prev.Value, snapshot.Value)) return false;
|
||||
_lastPublished[tagRef] = (snapshot.Value, snapshot.StatusCode);
|
||||
return true;
|
||||
}
|
||||
|
||||
var delta = Math.Abs(newD - prevD);
|
||||
var absPass = def.AbsoluteDeadband is double abs && delta >= abs;
|
||||
|
||||
// Percent: |prev| == 0 short-circuits to "always publish on any change" — avoids
|
||||
// div-by-zero and matches Kepware's documented behaviour.
|
||||
bool percentPass;
|
||||
if (def.PercentDeadband is double pct)
|
||||
{
|
||||
if (prevD == 0) percentPass = delta > 0;
|
||||
else percentPass = delta >= Math.Abs(prevD * pct / 100.0);
|
||||
}
|
||||
else percentPass = false;
|
||||
|
||||
// Logical OR — either filter triggering is enough. Matches the spec note in the
|
||||
// PR plan ("Both deadbands set -> either triggers, Kepware semantics").
|
||||
var pass = (def.AbsoluteDeadband is not null && absPass)
|
||||
|| (def.PercentDeadband is not null && percentPass);
|
||||
|
||||
if (!pass) return false;
|
||||
|
||||
_lastPublished[tagRef] = (snapshot.Value, snapshot.StatusCode);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryAsDouble(object? value, out double result)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case null: result = 0; return false;
|
||||
case bool: result = 0; return false; // booleans use the equality fast path
|
||||
case string: result = 0; return false;
|
||||
case Array: result = 0; return false;
|
||||
case IConvertible conv:
|
||||
try { result = conv.ToDouble(System.Globalization.CultureInfo.InvariantCulture); return true; }
|
||||
catch { result = 0; return false; }
|
||||
default: result = 0; return false;
|
||||
}
|
||||
}
|
||||
|
||||
public string DriverInstanceId => _driverInstanceId;
|
||||
@@ -91,6 +194,10 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
}
|
||||
_devices.Clear();
|
||||
_tagsByName.Clear();
|
||||
// PR 8 — clear the deadband last-published cache so a ReinitializeAsync (or a
|
||||
// reconnect-driven shutdown) doesn't suppress the very first post-reconnect sample
|
||||
// by comparing it against pre-disconnect state.
|
||||
lock (_lastPublishedLock) { _lastPublished.Clear(); }
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user