From eb5286148e1c2cfe6b527ee118676d67068a4cc6 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 25 Apr 2026 23:50:07 -0400 Subject: [PATCH] =?UTF-8?q?Auto:=20ablegacy-8=20=E2=80=94=20per-tag=20dead?= =?UTF-8?q?band=20/=20change=20filter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #251 --- docs/Driver.AbLegacy.Cli.md | 25 ++ docs/drivers/AbLegacy-Test-Fixture.md | 6 + scripts/e2e/test-ablegacy.ps1 | 38 +++ scripts/smoke/seed-ablegacy-smoke.sql | 3 +- .../Commands/SubscribeCommand.cs | 14 +- .../AbLegacyDriver.cs | 111 ++++++++- .../AbLegacyDriverFactoryExtensions.cs | 18 +- .../AbLegacyDriverOptions.cs | 20 +- .../AbLegacyDeadbandTests.cs | 216 ++++++++++++++++++ 9 files changed, 443 insertions(+), 8 deletions(-) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeadbandTests.cs diff --git a/docs/Driver.AbLegacy.Cli.md b/docs/Driver.AbLegacy.Cli.md index b7c3bbf..0fc9d8b 100644 --- a/docs/Driver.AbLegacy.Cli.md +++ b/docs/Driver.AbLegacy.Cli.md @@ -95,6 +95,31 @@ PLC-managed — use with caution. otopcua-ablegacy-cli subscribe -g ab://192.168.1.20/1,0 -a N7:10 -t Int -i 500 ``` +#### Deadband + +PR 8 — per-tag absolute / percent change filter on top of the polled subscription. The driver +caches the last *published* value per tag and suppresses `OnDataChange` notifications until the +new sample crosses the configured threshold. + +| Flag | Effect | +|---|---| +| `--deadband-absolute ` | Suppress until `|new - prev| >= value`. | +| `--deadband-percent ` | Suppress until `|new - prev| >= |prev * value / 100|`. `prev == 0` always publishes (avoids div-by-zero). | + +Booleans bypass the filter entirely (every transition publishes); strings + status changes +always publish; first-seen always publishes; both flags set → either passing triggers a +publish (Kepware-style logical OR). + +```powershell +# Float — drop sub-0.5 jitter from the noisy load-cell address. +otopcua-ablegacy-cli subscribe -g ab://192.168.1.20/1,0 -a F8:0 -t Float -i 500 ` + --deadband-absolute 0.5 + +# Integer — only fire on >= 5% deviation from the last reported value. +otopcua-ablegacy-cli subscribe -g ab://192.168.1.20/1,0 -a N7:10 -t Int -i 500 ` + --deadband-percent 5 +``` + ## Array reads PR 7 — one PCCC frame can carry up to ~120 words. Address an array tag with either the diff --git a/docs/drivers/AbLegacy-Test-Fixture.md b/docs/drivers/AbLegacy-Test-Fixture.md index 1040730..8bfc17e 100644 --- a/docs/drivers/AbLegacy-Test-Fixture.md +++ b/docs/drivers/AbLegacy-Test-Fixture.md @@ -49,6 +49,12 @@ supplies a `FakeAbLegacyTag`. - `AbLegacyHostAndStatusTests` — probe + host-status transitions driven by fake-returned statuses - `AbLegacyDriverTests` — `IDriver` lifecycle +- `AbLegacyDeadbandTests` — PR 8 per-tag deadband / change filter: + absolute-only suppression sequence `[10.0, 10.5, 11.5, 11.6] -> [10.0, 11.5]`, + percent-only suppression with a zero-prev short-circuit, both-set logical-OR + semantics (Kepware), Boolean edge-only publish, string change-only publish, + status-change always-publish, first-seen always-publish, ReinitializeAsync + cache wipe, JSON DTO round-trip. Capability surfaces whose contract is verified: `IDriver`, `IReadable`, `IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`, diff --git a/scripts/e2e/test-ablegacy.ps1 b/scripts/e2e/test-ablegacy.ps1 index 705e1ab..10a43f8 100644 --- a/scripts/e2e/test-ablegacy.ps1 +++ b/scripts/e2e/test-ablegacy.ps1 @@ -112,5 +112,43 @@ if ($arrayResult.ExitCode -eq 0) { $results += @{ Passed = $false; Reason = "array read exit $($arrayResult.ExitCode)" } } +# PR 8 — deadband subscribe assertion. Subscribe with --deadband-absolute 5, +# write three small deltas (each within the 5-unit deadband), assert exactly +# one notification fires (the first-seen sample). The fourth write breaks +# above the threshold and the subscription should fire again. +Write-Header "Deadband subscribe (--deadband-absolute 5)" +$baseValue = Get-Random -Minimum 100 -Maximum 200 +& $abLegacyCli.File @($abLegacyCli.PrefixArgs) ` + @("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", $baseValue) | Out-Null +$subscribeProc = Start-Process -FilePath $abLegacyCli.File ` + -ArgumentList ($abLegacyCli.PrefixArgs + @("subscribe") + $commonAbLegacy ` + + @("-a", $Address, "-t", "Int", "-i", "200", "--deadband-absolute", "5")) ` + -PassThru -RedirectStandardOutput "$env:TEMP/ablegacy-deadband.out" ` + -RedirectStandardError "$env:TEMP/ablegacy-deadband.err" +Start-Sleep -Seconds 2 +# Three small deltas within deadband. +& $abLegacyCli.File @($abLegacyCli.PrefixArgs) ` + @("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", ($baseValue + 1)) | Out-Null +Start-Sleep -Milliseconds 500 +& $abLegacyCli.File @($abLegacyCli.PrefixArgs) ` + @("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", ($baseValue + 2)) | Out-Null +Start-Sleep -Milliseconds 500 +& $abLegacyCli.File @($abLegacyCli.PrefixArgs) ` + @("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", ($baseValue + 3)) | Out-Null +Start-Sleep -Milliseconds 500 +Stop-Process -Id $subscribeProc.Id -Force -ErrorAction SilentlyContinue +$subscribeOutput = Get-Content "$env:TEMP/ablegacy-deadband.out" -ErrorAction SilentlyContinue +# Count `=` lines (the SubscribeCommand format prints one per OnDataChange). Expect exactly 1 +# (the first-seen sample at $baseValue) — none of the +1/+2/+3 deltas crosses the 5 absolute. +$notifyLines = @($subscribeOutput | Where-Object { $_ -match " = " }) +if ($notifyLines.Count -eq 1) { + Write-Pass "deadband subscribe emitted 1 notification (initial only); 3 sub-threshold writes suppressed" + $results += @{ Passed = $true } +} else { + Write-Fail "deadband subscribe expected 1 notification; got $($notifyLines.Count)" + Write-Host ($subscribeOutput -join "`n") + $results += @{ Passed = $false; Reason = "deadband notify count $($notifyLines.Count)" } +} + Write-Summary -Title "AB Legacy e2e" -Results $results if ($results | Where-Object { -not $_.Passed }) { exit 1 } diff --git a/scripts/smoke/seed-ablegacy-smoke.sql b/scripts/smoke/seed-ablegacy-smoke.sql index cf326e1..d7daed8 100644 --- a/scripts/smoke/seed-ablegacy-smoke.sql +++ b/scripts/smoke/seed-ablegacy-smoke.sql @@ -99,7 +99,8 @@ VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'ablegacy-smoke', 'AbLegacy', N'{ "Address": "N7:5", "DataType": "Int", "Writable": true, - "WriteIdempotent": true + "WriteIdempotent": true, + "AbsoluteDeadband": 5 }, { "Name": "N7_Block", diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/SubscribeCommand.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/SubscribeCommand.cs index 439890b..f536d1c 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/SubscribeCommand.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/SubscribeCommand.cs @@ -24,6 +24,16 @@ public sealed class SubscribeCommand : AbLegacyCommandBase "Publishing interval in milliseconds (default 1000).")] public int IntervalMs { get; init; } = 1000; + [CommandOption("deadband-absolute", Description = + "PR 8 — absolute change filter. Suppress notifications until |new - prev| >= this value. " + + "Booleans bypass; strings + status changes always publish.")] + public double? DeadbandAbsolute { get; init; } + + [CommandOption("deadband-percent", Description = + "PR 8 — percent-of-previous change filter. Suppress notifications until " + + "|new - prev| >= |prev * pct / 100|. prev=0 always publishes.")] + public double? DeadbandPercent { get; init; } + public override async ValueTask ExecuteAsync(IConsole console) { ConfigureLogging(); @@ -35,7 +45,9 @@ public sealed class SubscribeCommand : AbLegacyCommandBase DeviceHostAddress: Gateway, Address: Address, DataType: DataType, - Writable: false); + Writable: false, + AbsoluteDeadband: DeadbandAbsolute, + PercentDeadband: DeadbandPercent); var options = BuildOptions([tag]); await using var driver = new AbLegacyDriver(options, DriverInstanceId); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs index 8ebfde0..3f68be6 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -17,6 +17,18 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover private readonly PollGroupEngine _poll; private readonly Dictionary _devices = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _tagsByName = new(StringComparer.OrdinalIgnoreCase); + + /// + /// PR 8 — per-tag last published (value, status) cache for the deadband filter. + /// Layered on top of because the engine's change-detection + /// is binary (publish on any value/status diff). Cleared on + /// so a reconnect doesn't suppress legitimate post-reconnect updates against stale state. + /// Keyed by full reference (== tag name) — matches the engine's own LastValues key + /// space. + /// + private readonly Dictionary _lastPublished = + new(StringComparer.OrdinalIgnoreCase); + private readonly object _lastPublishedLock = new(); private DriverHealth _health = new(DriverState.Unknown, null, null); public event EventHandler? 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); + } + + /// + /// PR 8 — wraps the change callback with a per-tag + /// deadband filter. Booleans bypass (publish on every edge); strings + status changes + /// always publish; numerics pass only when |new - prev| meets the configured + /// absolute and / or percent deadband. First-seen always publishes. + /// + private void DispatchPollChange(ISubscriptionHandle handle, string tagRef, DataValueSnapshot snapshot) + { + if (!ShouldPublish(tagRef, snapshot)) return; + OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)); + } + + /// + /// 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). + /// + 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); } diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs index abe2123..ce69dab 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs @@ -52,7 +52,9 @@ public static class AbLegacyDriverFactoryExtensions tagName: t.Name), Writable: t.Writable ?? true, WriteIdempotent: t.WriteIdempotent ?? false, - ArrayLength: t.ArrayLength))] + ArrayLength: t.ArrayLength, + AbsoluteDeadband: t.AbsoluteDeadband, + PercentDeadband: t.PercentDeadband))] : [], Probe = new AbLegacyProbeOptions { @@ -118,6 +120,20 @@ public static class AbLegacyDriverFactoryExtensions /// driver issues a single contiguous PCCC block read for N elements. /// public int? ArrayLength { get; init; } + + /// + /// PR 8 — optional absolute change filter for numeric tags. OnDataChange is + /// suppressed unless |new - prev| >= AbsoluteDeadband. Booleans bypass; + /// strings + status changes always publish. + /// + public double? AbsoluteDeadband { get; init; } + + /// + /// PR 8 — optional percent-of-previous change filter for numeric tags. + /// OnDataChange is suppressed unless |new - prev| >= |prev * Percent / 100|. + /// prev == 0 always publishes (avoids division-by-zero). + /// + public double? PercentDeadband { get; init; } } internal sealed class AbLegacyProbeDto diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverOptions.cs index f9b9c05..2c14e04 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverOptions.cs @@ -22,9 +22,21 @@ public sealed record AbLegacyDeviceOptions( string? DeviceName = null); /// -/// One PCCC-backed OPC UA variable. is the canonical PCCC -/// file-address string that parses via . +/// One PCCC-backed OPC UA variable. Address is the canonical PCCC file-address +/// string that parses via . /// +/// +/// PR 8 deadband fields: +/// +/// AbsoluteDeadband — when set, suppresses OnDataChange for numeric +/// tags unless |new - prev| >= AbsoluteDeadband. +/// PercentDeadband — when set, suppresses unless +/// |new - prev| >= |prev * Percent / 100|; prev == 0 always publishes. +/// +/// Booleans bypass deadband entirely (every transition publishes); strings + status +/// changes always publish; first-seen always publishes; both set → logical-OR (Kepware +/// semantics). +/// public sealed record AbLegacyTagDefinition( string Name, string DeviceHostAddress, @@ -32,7 +44,9 @@ public sealed record AbLegacyTagDefinition( AbLegacyDataType DataType, bool Writable = true, bool WriteIdempotent = false, - int? ArrayLength = null); + int? ArrayLength = null, + double? AbsoluteDeadband = null, + double? PercentDeadband = null); public sealed class AbLegacyProbeOptions { diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeadbandTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeadbandTests.cs new file mode 100644 index 0000000..a55bcbc --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeadbandTests.cs @@ -0,0 +1,216 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; + +/// +/// PR 8 — per-tag deadband / change filter tests. The driver layers the deadband on top of +/// 's built-in change-detection: the engine fires its callback +/// whenever value or status differs from its own last snapshot; the driver then applies the +/// per-tag absolute / percent / type-aware filter before raising OnDataChange. +/// +[Trait("Category", "Unit")] +public sealed class AbLegacyDeadbandTests +{ + private const uint Good = 0x00000000u; + private const uint BadCommunication = 0x80050000u; + + private static async Task BuildDriverAsync(params AbLegacyTagDefinition[] tags) + { + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [], // ShouldPublish only inspects the tag-by-name map; no device is needed. + Tags = tags, + Probe = new AbLegacyProbeOptions { Enabled = false, ProbeAddress = null }, + }, "drv-deadband", new FakeAbLegacyTagFactory()); + await drv.InitializeAsync("{}", CancellationToken.None); + return drv; + } + + private static DataValueSnapshot Snap(object? v, uint status = Good) => + new(v, status, DateTime.UtcNow, DateTime.UtcNow); + + [Fact] + public async Task Absolute_deadband_suppresses_changes_below_threshold() + { + var tag = new AbLegacyTagDefinition("t", "ab://h/", "F8:0", AbLegacyDataType.Float, + AbsoluteDeadband: 1.0); + await using var drv = await BuildDriverAsync(tag); + + // Walk the published flag for each sample — the asserted sequence is [10.0, 11.5]. + drv.ShouldPublish("t", Snap(10.0)).ShouldBeTrue(); // first-seen + drv.ShouldPublish("t", Snap(10.5)).ShouldBeFalse(); // 0.5 < 1.0 + drv.ShouldPublish("t", Snap(11.5)).ShouldBeTrue(); // 1.5 from last published 10.0 + drv.ShouldPublish("t", Snap(11.6)).ShouldBeFalse(); // 0.1 from last published 11.5 + } + + [Fact] + public async Task Percent_deadband_suppresses_changes_below_threshold() + { + var tag = new AbLegacyTagDefinition("t", "ab://h/", "F8:0", AbLegacyDataType.Float, + PercentDeadband: 10.0); + await using var drv = await BuildDriverAsync(tag); + + drv.ShouldPublish("t", Snap(100.0)).ShouldBeTrue(); // first-seen + drv.ShouldPublish("t", Snap(105.0)).ShouldBeFalse(); // 5% < 10% + drv.ShouldPublish("t", Snap(115.0)).ShouldBeTrue(); // |115-100|=15 >= 10% of 100 + } + + [Fact] + public async Task Percent_deadband_with_zero_prev_always_publishes_on_change() + { + var tag = new AbLegacyTagDefinition("t", "ab://h/", "F8:0", AbLegacyDataType.Float, + PercentDeadband: 50.0); + await using var drv = await BuildDriverAsync(tag); + + drv.ShouldPublish("t", Snap(0.0)).ShouldBeTrue(); // first-seen + drv.ShouldPublish("t", Snap(0.001)).ShouldBeTrue(); // prev=0 short-circuit + } + + [Fact] + public async Task Boolean_publishes_every_transition_and_suppresses_no_op() + { + var tag = new AbLegacyTagDefinition("t", "ab://h/", "B3:0/0", AbLegacyDataType.Bit, + AbsoluteDeadband: 1.0); + await using var drv = await BuildDriverAsync(tag); + + // Edge sequence false -> true -> false -> true => 4 publishes. + drv.ShouldPublish("t", Snap(false)).ShouldBeTrue(); + drv.ShouldPublish("t", Snap(true)).ShouldBeTrue(); + drv.ShouldPublish("t", Snap(false)).ShouldBeTrue(); + drv.ShouldPublish("t", Snap(true)).ShouldBeTrue(); + + // No-op (true -> true) suppressed. + drv.ShouldPublish("t", Snap(true)).ShouldBeFalse(); + } + + [Fact] + public async Task Status_change_always_publishes_even_within_deadband() + { + var tag = new AbLegacyTagDefinition("t", "ab://h/", "F8:0", AbLegacyDataType.Float, + AbsoluteDeadband: 100.0); // ridiculously large — would normally suppress everything + await using var drv = await BuildDriverAsync(tag); + + drv.ShouldPublish("t", Snap(10.0, Good)).ShouldBeTrue(); // first-seen + drv.ShouldPublish("t", Snap(10.5, Good)).ShouldBeFalse(); // within deadband + drv.ShouldPublish("t", Snap(10.5, BadCommunication)).ShouldBeTrue(); // status flip + drv.ShouldPublish("t", Snap(10.5, Good)).ShouldBeTrue(); // status flip back + } + + [Fact] + public async Task String_publishes_on_value_change_and_suppresses_no_op() + { + var tag = new AbLegacyTagDefinition("t", "ab://h/", "ST20:0", AbLegacyDataType.String, + AbsoluteDeadband: 1.0); // deadband meaningless on strings — should be ignored + await using var drv = await BuildDriverAsync(tag); + + drv.ShouldPublish("t", Snap("alpha")).ShouldBeTrue(); // first-seen + drv.ShouldPublish("t", Snap("alpha")).ShouldBeFalse(); // identical + drv.ShouldPublish("t", Snap("beta")).ShouldBeTrue(); // change + } + + [Fact] + public async Task Both_deadbands_set_either_triggers_publish() + { + // 1.0 absolute OR 50% percent — picking deltas where one passes, the other does not. + var tag = new AbLegacyTagDefinition("t", "ab://h/", "F8:0", AbLegacyDataType.Float, + AbsoluteDeadband: 1.0, PercentDeadband: 50.0); + await using var drv = await BuildDriverAsync(tag); + + drv.ShouldPublish("t", Snap(2.0)).ShouldBeTrue(); // first-seen + // |3.0 - 2.0| = 1.0 -> absolute passes (>= 1.0); percent test 50% of 2.0 = 1.0 -> 1.0 >= 1.0 also passes. + drv.ShouldPublish("t", Snap(3.0)).ShouldBeTrue(); + // After last-publish=3.0: small delta of 0.6, absolute fails (0.6 < 1.0), percent fails (0.6 < 1.5). + drv.ShouldPublish("t", Snap(3.6)).ShouldBeFalse(); + // |5.0 - 3.0| = 2.0; absolute passes (>= 1.0). + drv.ShouldPublish("t", Snap(5.0)).ShouldBeTrue(); + } + + [Fact] + public async Task First_seen_always_publishes_even_with_huge_deadband() + { + var tag = new AbLegacyTagDefinition("t", "ab://h/", "F8:0", AbLegacyDataType.Float, + AbsoluteDeadband: 1e9); + await using var drv = await BuildDriverAsync(tag); + + drv.ShouldPublish("t", Snap(1.0)).ShouldBeTrue(); + } + + [Fact] + public async Task ReinitializeAsync_clears_last_published_cache() + { + var tag = new AbLegacyTagDefinition("t", "ab://h/", "F8:0", AbLegacyDataType.Float, + AbsoluteDeadband: 1.0); + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [], + Tags = [tag], + Probe = new AbLegacyProbeOptions { Enabled = false, ProbeAddress = null }, + }, "drv-reinit", new FakeAbLegacyTagFactory()); + + await drv.InitializeAsync("{}", CancellationToken.None); + drv.ShouldPublish("t", Snap(10.0)).ShouldBeTrue(); + drv.ShouldPublish("t", Snap(10.2)).ShouldBeFalse(); + + await drv.ReinitializeAsync("{}", CancellationToken.None); + + // After reinit the cache is empty — the very next sample is treated as first-seen and + // publishes regardless of how close it is to the pre-reinit last-published value. + drv.ShouldPublish("t", Snap(10.2)).ShouldBeTrue(); + + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task Tag_without_deadband_publishes_every_sample_reaching_dispatcher() + { + // No deadband fields set — the driver passes through whatever the engine gives it. + // (PollGroupEngine itself filters by value-equality, so any sample reaching ShouldPublish + // is already a real change.) + var tag = new AbLegacyTagDefinition("t", "ab://h/", "F8:0", AbLegacyDataType.Float); + await using var drv = await BuildDriverAsync(tag); + + drv.ShouldPublish("t", Snap(1.0)).ShouldBeTrue(); + drv.ShouldPublish("t", Snap(1.001)).ShouldBeTrue(); + drv.ShouldPublish("t", Snap(1.002)).ShouldBeTrue(); + } + + [Fact] + public async Task Dto_round_trip_preserves_deadband_fields() + { + const string json = """ + { + "Devices": [ + { "HostAddress": "ab://10.0.0.5/1,0", "PlcFamily": "Slc500" } + ], + "Tags": [ + { + "Name": "T1", + "DeviceHostAddress": "ab://10.0.0.5/1,0", + "Address": "F8:0", + "DataType": "Float", + "AbsoluteDeadband": 0.5, + "PercentDeadband": 5.0 + } + ] + } + """; + + var drv = AbLegacyDriverFactoryExtensions.CreateInstance("drv-json", json); + try + { + await drv.InitializeAsync(json, CancellationToken.None); + // ShouldPublish exercises the deadband — confirms the DTO values landed on the + // AbLegacyTagDefinition. + drv.ShouldPublish("T1", Snap(100.0)).ShouldBeTrue(); // first-seen + drv.ShouldPublish("T1", Snap(100.4)).ShouldBeFalse(); // 0.4 < 0.5 absolute + drv.ShouldPublish("T1", Snap(101.0)).ShouldBeTrue(); // 1.0 >= 0.5 absolute + } + finally + { + await drv.DisposeAsync(); + } + } +} -- 2.49.1