Merge pull request '[ablegacy] AbLegacy — Per-tag deadband / change filter' (#371) from auto/ablegacy/8 into auto/driver-gaps

This commit was merged in pull request #371.
This commit is contained in:
2026-04-25 23:52:42 -04:00
9 changed files with 443 additions and 8 deletions

View File

@@ -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 <value>` | Suppress until `|new - prev| >= value`. |
| `--deadband-percent <value>` | 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

View File

@@ -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`,

View File

@@ -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 }

View File

@@ -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",

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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.
/// </summary>
public int? ArrayLength { get; init; }
/// <summary>
/// PR 8 — optional absolute change filter for numeric tags. <c>OnDataChange</c> is
/// suppressed unless <c>|new - prev| &gt;= AbsoluteDeadband</c>. Booleans bypass;
/// strings + status changes always publish.
/// </summary>
public double? AbsoluteDeadband { get; init; }
/// <summary>
/// PR 8 — optional percent-of-previous change filter for numeric tags.
/// <c>OnDataChange</c> is suppressed unless <c>|new - prev| &gt;= |prev * Percent / 100|</c>.
/// <c>prev == 0</c> always publishes (avoids division-by-zero).
/// </summary>
public double? PercentDeadband { get; init; }
}
internal sealed class AbLegacyProbeDto

View File

@@ -22,9 +22,21 @@ public sealed record AbLegacyDeviceOptions(
string? DeviceName = null);
/// <summary>
/// One PCCC-backed OPC UA variable. <paramref name="Address"/> is the canonical PCCC
/// file-address string that parses via <see cref="AbLegacyAddress.TryParse(string?)"/>.
/// One PCCC-backed OPC UA variable. <c>Address</c> is the canonical PCCC file-address
/// string that parses via <see cref="AbLegacyAddress.TryParse(string?)"/>.
/// </summary>
/// <remarks>
/// PR 8 deadband fields:
/// <list type="bullet">
/// <item><c>AbsoluteDeadband</c> — when set, suppresses <c>OnDataChange</c> for numeric
/// tags unless <c>|new - prev| &gt;= AbsoluteDeadband</c>.</item>
/// <item><c>PercentDeadband</c> — when set, suppresses unless
/// <c>|new - prev| &gt;= |prev * Percent / 100|</c>; <c>prev == 0</c> always publishes.</item>
/// </list>
/// Booleans bypass deadband entirely (every transition publishes); strings + status
/// changes always publish; first-seen always publishes; both set → logical-OR (Kepware
/// semantics).
/// </remarks>
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
{

View File

@@ -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;
/// <summary>
/// PR 8 — per-tag deadband / change filter tests. The driver layers the deadband on top of
/// <see cref="PollGroupEngine"/>'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 <c>OnDataChange</c>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbLegacyDeadbandTests
{
private const uint Good = 0x00000000u;
private const uint BadCommunication = 0x80050000u;
private static async Task<AbLegacyDriver> 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();
}
}
}