using System.Collections.Concurrent; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; /// /// PR abcip-4.2 — per-tag last-successfully-written-value cache supporting /// + /// suppression in . Keys are /// (deviceHostAddress, tagAddress): the same Logix tag served from two devices /// keeps independent caches because the underlying PLC state is independent. Counters /// (, ) feed /// AbCip.WritesSuppressed / AbCip.WritesPassedThrough in the driver /// diagnostics surface. /// /// /// The coalescer is consulted *before* the wire write; only successful writes call /// 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). /// 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. /// /// Suppression rules: /// /// No prior recorded value → not suppressed (first write always passes through). /// + both values numeric → /// |new - last| < deadband suppresses. NaN / Infinity in either side bypass /// suppression; the wire decides. /// set → /// 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). /// Neither knob set → never suppress (back-compat default). /// /// internal sealed class AbCipWriteCoalescer { private readonly ConcurrentDictionary<(string Device, string Tag), object?> _lastValues = new(LastKeyComparer.Instance); private long _totalWritesSuppressed; private long _totalWritesPassedThrough; /// Diagnostics counter — number of writes the coalescer told the driver to skip. public long TotalWritesSuppressed => Interlocked.Read(ref _totalWritesSuppressed); /// Diagnostics counter — number of writes that hit the wire after consulting the coalescer. public long TotalWritesPassedThrough => Interlocked.Read(ref _totalWritesPassedThrough); /// /// Decide whether should suppress the wire write for /// on . Increments the /// internal / /// counter as a side effect so callers don't have to maintain a parallel tally. /// /// /// true when the write can be skipped (last value recorded + suppression rule /// fired). false when the write must hit the wire (no prior value, no rule /// active, or values differ enough). /// 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; } /// /// Record the value just successfully written so the next call to /// can compare against it. Called only from the /// success branch — failed writes do not seed the cache. /// 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; } /// /// 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. /// 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 _); } } /// Drop every cached last-value across all devices — invoked on full driver shutdown. 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)); } }