da9936f7f0
Closes #239
212 lines
9.7 KiB
C#
212 lines
9.7 KiB
C#
using System.Collections.Concurrent;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
|
|
|
/// <summary>
|
|
/// PR abcip-4.2 — per-tag last-successfully-written-value cache supporting
|
|
/// <see cref="AbCipTagDefinition.WriteDeadband"/> + <see cref="AbCipTagDefinition.WriteOnChange"/>
|
|
/// suppression in <see cref="AbCipDriver.WriteAsync"/>. Keys are
|
|
/// <c>(deviceHostAddress, tagAddress)</c>: the same Logix tag served from two devices
|
|
/// keeps independent caches because the underlying PLC state is independent. Counters
|
|
/// (<see cref="TotalWritesSuppressed"/>, <see cref="TotalWritesPassedThrough"/>) feed
|
|
/// <c>AbCip.WritesSuppressed</c> / <c>AbCip.WritesPassedThrough</c> in the driver
|
|
/// diagnostics surface.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>The coalescer is consulted *before* the wire write; only successful writes call
|
|
/// <see cref="Record"/> so a failed write does not poison the cache (next attempt with the
|
|
/// same value still hits the wire because no last-value was ever recorded for it).
|
|
/// <see cref="Reset"/> wipes the per-device entries on reconnect / shutdown — the PLC may
|
|
/// have been restarted and our cached "we already wrote 42" is no longer valid PLC state.</para>
|
|
///
|
|
/// <para>Suppression rules:</para>
|
|
/// <list type="bullet">
|
|
/// <item>No prior recorded value → not suppressed (first write always passes through).</item>
|
|
/// <item><see cref="AbCipTagDefinition.WriteDeadband"/> + both values numeric →
|
|
/// <c>|new - last| < deadband</c> suppresses. NaN / Infinity in either side bypass
|
|
/// suppression; the wire decides.</item>
|
|
/// <item><see cref="AbCipTagDefinition.WriteOnChange"/> set →
|
|
/// <see cref="object.Equals(object?, object?)"/> equality suppresses. For numeric tags
|
|
/// with a deadband configured, this still applies as the equality fallback when the
|
|
/// deadband path doesn't trigger (e.g. exact equality with a 0 deadband).</item>
|
|
/// <item>Neither knob set → never suppress (back-compat default).</item>
|
|
/// </list>
|
|
/// </remarks>
|
|
internal sealed class AbCipWriteCoalescer
|
|
{
|
|
private readonly ConcurrentDictionary<(string Device, string Tag), object?> _lastValues =
|
|
new(LastKeyComparer.Instance);
|
|
|
|
private long _totalWritesSuppressed;
|
|
private long _totalWritesPassedThrough;
|
|
|
|
/// <summary>Diagnostics counter — number of writes the coalescer told the driver to skip.</summary>
|
|
public long TotalWritesSuppressed => Interlocked.Read(ref _totalWritesSuppressed);
|
|
|
|
/// <summary>Diagnostics counter — number of writes that hit the wire after consulting the coalescer.</summary>
|
|
public long TotalWritesPassedThrough => Interlocked.Read(ref _totalWritesPassedThrough);
|
|
|
|
/// <summary>
|
|
/// Decide whether <paramref name="newValue"/> should suppress the wire write for
|
|
/// <paramref name="tag"/> on <paramref name="deviceHostAddress"/>. Increments the
|
|
/// internal <see cref="TotalWritesSuppressed"/> / <see cref="TotalWritesPassedThrough"/>
|
|
/// counter as a side effect so callers don't have to maintain a parallel tally.
|
|
/// </summary>
|
|
/// <returns>
|
|
/// <c>true</c> when the write can be skipped (last value recorded + suppression rule
|
|
/// fired). <c>false</c> when the write must hit the wire (no prior value, no rule
|
|
/// active, or values differ enough).
|
|
/// </returns>
|
|
public bool ShouldSuppress(string deviceHostAddress, AbCipTagDefinition tag, object? newValue)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(deviceHostAddress);
|
|
ArgumentNullException.ThrowIfNull(tag);
|
|
|
|
// Fast path — neither knob active. Skip the dictionary lookup entirely; this is the
|
|
// overwhelming common case in deployments that don't opt in.
|
|
if (!tag.WriteOnChange && !tag.WriteDeadband.HasValue)
|
|
{
|
|
Interlocked.Increment(ref _totalWritesPassedThrough);
|
|
return false;
|
|
}
|
|
|
|
var key = (deviceHostAddress, tag.TagPath);
|
|
if (!_lastValues.TryGetValue(key, out var lastValue))
|
|
{
|
|
// No prior recorded write — first write must always pass through so the PLC sees a
|
|
// baseline. The Record call after a successful write seeds the cache from this point.
|
|
Interlocked.Increment(ref _totalWritesPassedThrough);
|
|
return false;
|
|
}
|
|
|
|
if (TrySuppress(tag, lastValue, newValue))
|
|
{
|
|
Interlocked.Increment(ref _totalWritesSuppressed);
|
|
return true;
|
|
}
|
|
|
|
Interlocked.Increment(ref _totalWritesPassedThrough);
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Record the value just successfully written so the next call to
|
|
/// <see cref="ShouldSuppress"/> can compare against it. Called only from the
|
|
/// <see cref="AbCipDriver"/> success branch — failed writes do not seed the cache.
|
|
/// </summary>
|
|
public void Record(string deviceHostAddress, AbCipTagDefinition tag, object? writtenValue)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(deviceHostAddress);
|
|
ArgumentNullException.ThrowIfNull(tag);
|
|
|
|
// Only care about tags that opted in to either knob — pure-passthrough tags don't need
|
|
// a cache entry at all and the dictionary stays small for the common case.
|
|
if (!tag.WriteOnChange && !tag.WriteDeadband.HasValue) return;
|
|
|
|
_lastValues[(deviceHostAddress, tag.TagPath)] = writtenValue;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Drop every cached last-value for one device. Called on reconnect or driver shutdown
|
|
/// so the next write after a wire-state change pays the full round-trip — the PLC may
|
|
/// have been restarted and our cached "we already wrote 42" is stale.
|
|
/// </summary>
|
|
public void Reset(string deviceHostAddress)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(deviceHostAddress);
|
|
|
|
// ConcurrentDictionary doesn't have a "remove where" overload, so iterate keys + remove.
|
|
// Suppression races are tolerated — losing one suppression decision after a reconnect
|
|
// costs at most one extra wire write, never correctness.
|
|
foreach (var key in _lastValues.Keys)
|
|
{
|
|
if (string.Equals(key.Device, deviceHostAddress, StringComparison.OrdinalIgnoreCase))
|
|
_lastValues.TryRemove(key, out _);
|
|
}
|
|
}
|
|
|
|
/// <summary>Drop every cached last-value across all devices — invoked on full driver shutdown.</summary>
|
|
public void ResetAll() => _lastValues.Clear();
|
|
|
|
private static bool TrySuppress(AbCipTagDefinition tag, object? lastValue, object? newValue)
|
|
{
|
|
// Numeric deadband — only fires when both sides convert cleanly to double. NaN / Infinity
|
|
// bypass: the wire decides because IEEE-754 comparisons against NaN are undefined and
|
|
// we don't want a stale +Inf in the cache to silently swallow a real reset.
|
|
if (tag.WriteDeadband.HasValue
|
|
&& TryToDouble(lastValue, out var lastNum)
|
|
&& TryToDouble(newValue, out var newNum))
|
|
{
|
|
if (double.IsNaN(lastNum) || double.IsNaN(newNum)
|
|
|| double.IsInfinity(lastNum) || double.IsInfinity(newNum))
|
|
{
|
|
// Fall through to the WriteOnChange equality check below — NaN / Infinity skip
|
|
// the deadband path but a legacy WriteOnChange tag should still benefit from
|
|
// exact-equality suppression on the same packet.
|
|
}
|
|
else if (Math.Abs(newNum - lastNum) < tag.WriteDeadband.Value)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// WriteOnChange — equality fallback. Always evaluated when the flag is set so a
|
|
// non-numeric tag (BOOL, STRING) still benefits even when WriteDeadband is set on the
|
|
// same tag (the deadband path simply doesn't apply to it).
|
|
if (tag.WriteOnChange && Equals(lastValue, newValue))
|
|
return true;
|
|
|
|
return false;
|
|
}
|
|
|
|
private static bool TryToDouble(object? value, out double result)
|
|
{
|
|
// IConvertible covers every Logix atomic type the AB CIP driver decodes (sbyte, short,
|
|
// int, long + their unsigned siblings + float / double). DateTime and string are
|
|
// excluded — neither has a meaningful "deadband" interpretation.
|
|
switch (value)
|
|
{
|
|
case null:
|
|
result = 0;
|
|
return false;
|
|
case bool:
|
|
result = 0;
|
|
return false;
|
|
case string:
|
|
result = 0;
|
|
return false;
|
|
case DateTime:
|
|
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;
|
|
}
|
|
}
|
|
|
|
private sealed class LastKeyComparer : IEqualityComparer<(string Device, string Tag)>
|
|
{
|
|
public static readonly LastKeyComparer Instance = new();
|
|
|
|
public bool Equals((string Device, string Tag) x, (string Device, string Tag) y) =>
|
|
string.Equals(x.Device, y.Device, StringComparison.OrdinalIgnoreCase)
|
|
&& string.Equals(x.Tag, y.Tag, StringComparison.Ordinal);
|
|
|
|
public int GetHashCode((string Device, string Tag) obj) =>
|
|
HashCode.Combine(
|
|
StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Device),
|
|
StringComparer.Ordinal.GetHashCode(obj.Tag));
|
|
}
|
|
}
|