@@ -150,3 +150,150 @@ rather than a separate tier of scan-class definitions.
|
|||||||
with per-tag scan rate when a slow bucket starves a fast one.
|
with per-tag scan rate when a slow bucket starves a fast one.
|
||||||
- S7 driver `ScanGroup` model in `src/.../S7DriverOptions.cs` — the
|
- S7 driver `ScanGroup` model in `src/.../S7DriverOptions.cs` — the
|
||||||
named-group form of the same idea.
|
named-group form of the same idea.
|
||||||
|
|
||||||
|
## Write deadband / write-on-change
|
||||||
|
|
||||||
|
PR abcip-4.2 ships the second operability knob: per-tag write coalescing,
|
||||||
|
the *write-side* companion to the read-side deadband already shipped at the
|
||||||
|
OPC UA monitored-item layer. The driver remembers the value last
|
||||||
|
successfully written for a tag and can suppress redundant or below-threshold
|
||||||
|
follow-up writes — they return `Good` to the OPC UA client without ever
|
||||||
|
hitting the wire.
|
||||||
|
|
||||||
|
### What it is
|
||||||
|
|
||||||
|
- **`AbCipTagDefinition.WriteDeadband`** (`double?`, default `null`) —
|
||||||
|
numeric absolute-difference threshold. When set, a write whose
|
||||||
|
`|new − last|` is below the deadband is suppressed.
|
||||||
|
- **`AbCipTagDefinition.WriteOnChange`** (`bool`, default `false`) —
|
||||||
|
equality gate. When set, a write whose value equals the last successfully
|
||||||
|
written value is suppressed.
|
||||||
|
|
||||||
|
Both knobs combine on the same tag. For numerics, the deadband path takes
|
||||||
|
priority; the equality fallback covers the cases the deadband doesn't (BOOL
|
||||||
|
setpoints, STRING constants, `WriteDeadband=0`, etc).
|
||||||
|
|
||||||
|
### Worked setpoint-jitter example
|
||||||
|
|
||||||
|
A motor speed setpoint published from an HMI tends to wobble by a few
|
||||||
|
ticks even when the operator hasn't touched it — UI rounding, Modbus
|
||||||
|
gateway re-encoding, RPN script noise. With `WriteDeadband: 0.5`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Tags": [
|
||||||
|
{
|
||||||
|
"Name": "Motor1.Speed.SP",
|
||||||
|
"DeviceHostAddress": "ab://10.0.0.5/1,0",
|
||||||
|
"TagPath": "Motor1.Speed.SP",
|
||||||
|
"DataType": "Real",
|
||||||
|
"WriteDeadband": 0.5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Sequence of writes from the HMI (one every 100 ms, no operator input):
|
||||||
|
|
||||||
|
| Time | Value | `\|new − last\|` | Wire? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 0 ms | 50.0 | n/a (first) | yes |
|
||||||
|
| 100 ms | 50.2 | 0.2 < 0.5 | suppressed |
|
||||||
|
| 200 ms | 50.3 | 0.3 < 0.5 | suppressed |
|
||||||
|
| 300 ms | 50.6 | 0.6 ≥ 0.5 | yes |
|
||||||
|
| 400 ms | 50.6 | 0.0 < 0.5 | suppressed |
|
||||||
|
| 500 ms | 51.5 | 0.9 ≥ 0.5 | yes |
|
||||||
|
|
||||||
|
Three writes hit the wire; three are suppressed. The OPC UA client sees
|
||||||
|
`Good` on every call. The PLC sees only the values that actually crossed
|
||||||
|
the deadband.
|
||||||
|
|
||||||
|
### Combining with WriteOnChange
|
||||||
|
|
||||||
|
A digital reset bit driven by a UI that pulses it at every cycle:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Name": "Conveyor.Reset",
|
||||||
|
"DeviceHostAddress": "ab://10.0.0.5/1,0",
|
||||||
|
"TagPath": "Conveyor.Reset",
|
||||||
|
"DataType": "Bool",
|
||||||
|
"WriteOnChange": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Three consecutive `false → false → false` writes from the UI collapse to
|
||||||
|
one wire write (`false`, the first). When the operator clicks the reset
|
||||||
|
button (`true`), that write passes; subsequent `true → true` repeats
|
||||||
|
suppress until the UI clears it back to `false`.
|
||||||
|
|
||||||
|
Numeric tags can also opt into both: `WriteDeadband: 0.5` plus
|
||||||
|
`WriteOnChange: true` is well-defined — the deadband suppresses jitter, the
|
||||||
|
equality gate suppresses exact repeats (which the deadband path also catches
|
||||||
|
because `|0| < 0.5`, but having both set documents the operator's intent).
|
||||||
|
|
||||||
|
### Special cases
|
||||||
|
|
||||||
|
- **First write** always passes through. The coalescer has no prior value
|
||||||
|
to compare against, so the first write of any tag pays the full
|
||||||
|
round-trip and seeds the cache.
|
||||||
|
- **NaN / Infinity** bypass deadband suppression. IEEE-754 comparisons
|
||||||
|
against NaN are undefined and a stale `+Inf` shouldn't silently swallow
|
||||||
|
a real reset; the wire decides. `WriteOnChange` equality on NaN still
|
||||||
|
follows .NET semantics (`Equals(NaN, NaN) == true` for `double` boxed in
|
||||||
|
`object`), so a `WriteOnChange` tag stuck on NaN will suppress repeats
|
||||||
|
until something else writes a real value.
|
||||||
|
- **Failed writes** do *not* seed the cache. If the wire write fails, the
|
||||||
|
next attempt with the same value still hits the wire because the
|
||||||
|
coalescer never recorded a "last successful value" for it.
|
||||||
|
- **Reconnect drops the cache**. The driver's host-state probe transitions
|
||||||
|
`Stopped → Running` after a reconnect; both transitions reset the
|
||||||
|
per-device coalescer cache, so the first post-reconnect write of any
|
||||||
|
value pays the full round-trip. The PLC may have been restarted while
|
||||||
|
the driver was offline and our cached "we already wrote 42" is stale.
|
||||||
|
- **Two devices, same tag address**. The cache is keyed on
|
||||||
|
`(deviceHostAddress, tagAddress)` so two PLCs running the same Logix
|
||||||
|
program keep independent caches — writing 42 to A doesn't suppress
|
||||||
|
writing 42 to B.
|
||||||
|
- **Bit-in-DINT writes** consult the coalescer too, so a UI that pulses
|
||||||
|
`Flags.3` at every cycle benefits from the same `WriteOnChange`
|
||||||
|
suppression as a plain BOOL tag.
|
||||||
|
- **Plain back-compat tags** (no `WriteDeadband`, no `WriteOnChange`)
|
||||||
|
take a fast-path through the coalescer that increments only the
|
||||||
|
`WritesPassedThrough` counter — no dictionary lookup, no allocation. The
|
||||||
|
knobs are zero-overhead opt-in.
|
||||||
|
|
||||||
|
### Diagnostics
|
||||||
|
|
||||||
|
The driver surfaces two counters through `DriverHealth.Diagnostics` (the
|
||||||
|
same path the `driver-diagnostics` RPC + Admin UI render for Modbus / S7 /
|
||||||
|
OPC UA Client):
|
||||||
|
|
||||||
|
- `AbCip.WritesSuppressed` — total writes the coalescer skipped.
|
||||||
|
- `AbCip.WritesPassedThrough` — total writes that hit the wire after
|
||||||
|
consulting the coalescer.
|
||||||
|
|
||||||
|
Their ratio is the "wire savings" headline. A deployment with `0`
|
||||||
|
suppressions either has no tags opted in or has the deadband too tight /
|
||||||
|
the equality threshold too loose; revisit the per-tag config.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
- **Unit**: `AbCipWriteDeadbandTests` (`tests/.../AbCip.Tests`). Asserts
|
||||||
|
the deadband math, the equality fallback, the first-write pass-through,
|
||||||
|
reset-on-reconnect, two-device cache independence, suppressed-Good
|
||||||
|
status, NaN bypass, the back-compat fast path, and DTO round-trip.
|
||||||
|
- **Integration**: `AbCipWriteDeadbandTests`
|
||||||
|
(`tests/.../AbCip.IntegrationTests`). Drives a 5-write jittery sequence
|
||||||
|
with `WriteDeadband: 1.0` against a live `ab_server` and asserts the
|
||||||
|
driver's diagnostics counter matches the expected suppression count.
|
||||||
|
- **E2E**: `scripts/e2e/test-abcip.ps1` — see the *WriteCoalesce*
|
||||||
|
assertion.
|
||||||
|
|
||||||
|
### Cross-references
|
||||||
|
|
||||||
|
- `docs/drivers/AbServer-Test-Fixture.md` §7 — capability surfaces beyond
|
||||||
|
read; mentions write-coalesce coverage.
|
||||||
|
- Modbus driver — read-side deadband in `ModbusDriver` predates this
|
||||||
|
write-side equivalent; the config shape is intentionally similar.
|
||||||
|
- Kepware "Deadband (write)" knob — this is the AB CIP equivalent.
|
||||||
|
|||||||
@@ -139,14 +139,19 @@ the RMW path is not exercised end-to-end.
|
|||||||
|
|
||||||
No smoke test for:
|
No smoke test for:
|
||||||
|
|
||||||
- `IWritable.WriteAsync`
|
- `IWritable.WriteAsync` — atomic write coverage; PR abcip-4.2 added a
|
||||||
|
multi-write *suppression* smoke (jittery 5-write sequence with
|
||||||
|
`WriteDeadband: 1.0` against `ab_server`, asserting the driver's
|
||||||
|
diagnostics counter matches the expected suppression count) but pure
|
||||||
|
atomic-write coverage end-to-end is still unit-only.
|
||||||
- `ITagDiscovery.DiscoverAsync` (`@tags` walker)
|
- `ITagDiscovery.DiscoverAsync` (`@tags` walker)
|
||||||
- `ISubscribable.SubscribeAsync` (poll-group engine)
|
- `ISubscribable.SubscribeAsync` (poll-group engine)
|
||||||
- `IHostConnectivityProbe` state transitions under wire failure
|
- `IHostConnectivityProbe` state transitions under wire failure
|
||||||
- `IPerCallHostResolver` multi-device routing
|
- `IPerCallHostResolver` multi-device routing
|
||||||
|
|
||||||
The driver implements all of these + they have unit coverage, but the only
|
The driver implements all of these + they have unit coverage, but the only
|
||||||
end-to-end path `ab_server` validates today is atomic `ReadAsync`.
|
end-to-end paths `ab_server` validates today are atomic `ReadAsync` and
|
||||||
|
write-deadband / write-on-change suppression.
|
||||||
|
|
||||||
## Logix Emulate golden-box tier
|
## Logix Emulate golden-box tier
|
||||||
|
|
||||||
|
|||||||
@@ -182,5 +182,31 @@ if ($FastBridgeNodeId -and $SlowBridgeNodeId) {
|
|||||||
$results += [PSCustomObject]@{ Name = "PerTagScanRate"; Passed = $passed; Detail = $detail }
|
$results += [PSCustomObject]@{ Name = "PerTagScanRate"; Passed = $passed; Detail = $detail }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# PR abcip-4.2 — write-coalesce assertion. Writes the same value twice through the OPC UA
|
||||||
|
# server and verifies the PLC-side state reflects only one wire write. The driver-side
|
||||||
|
# diagnostics counter (AbCip.WritesSuppressed) is the authoritative signal, but ab_server
|
||||||
|
# itself doesn't expose a "writes received" counter so this script-level check is intentionally
|
||||||
|
# observational — it primes the tag with a baseline, writes the same value twice, and reads
|
||||||
|
# back to confirm the value matches without surfacing additional state changes. The unit + integration
|
||||||
|
# tests do the strict "exactly N suppressions" math; this is the e2e shape proof.
|
||||||
|
$coalesceValue = Get-Random -Minimum 60000 -Maximum 69999
|
||||||
|
Write-Header "WriteCoalesce (baseline=$coalesceValue, two redundant writes)"
|
||||||
|
$writeArgs = @("write") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "-v", $coalesceValue)
|
||||||
|
& $abcipCli.Exe @($abcipCli.Args + $writeArgs) | Out-Null
|
||||||
|
& $abcipCli.Exe @($abcipCli.Args + $writeArgs) | Out-Null
|
||||||
|
& $abcipCli.Exe @($abcipCli.Args + $writeArgs) | Out-Null
|
||||||
|
$readArgs = @("read") + $commonAbCip + @("-t", $TagPath, "--type", "DInt")
|
||||||
|
$readOut = & $abcipCli.Exe @($abcipCli.Args + $readArgs)
|
||||||
|
$coalesceMatch = ($readOut -join "`n") -match "$coalesceValue"
|
||||||
|
$results += [PSCustomObject]@{
|
||||||
|
Name = "WriteCoalesce"
|
||||||
|
Passed = $coalesceMatch
|
||||||
|
Detail = if ($coalesceMatch) {
|
||||||
|
"three identical writes of $coalesceValue produced the expected readback (driver-side WritesSuppressed counter exposed via driver-diagnostics RPC)"
|
||||||
|
} else {
|
||||||
|
"three identical writes did not converge on $coalesceValue — got '$readOut'"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Write-Summary -Title "AB CIP e2e" -Results $results
|
Write-Summary -Title "AB CIP e2e" -Results $results
|
||||||
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
private readonly Dictionary<string, AbCipTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, AbCipTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly AbCipAlarmProjection _alarmProjection;
|
private readonly AbCipAlarmProjection _alarmProjection;
|
||||||
private readonly SemaphoreSlim _discoverySemaphore = new(1, 1);
|
private readonly SemaphoreSlim _discoverySemaphore = new(1, 1);
|
||||||
|
private readonly AbCipWriteCoalescer _writeCoalescer = new();
|
||||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||||
|
|
||||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||||
@@ -415,6 +416,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
}
|
}
|
||||||
_devices.Clear();
|
_devices.Clear();
|
||||||
_tagsByName.Clear();
|
_tagsByName.Clear();
|
||||||
|
// PR abcip-4.2 — wipe the write-coalescer cache on shutdown. Reinitializing the driver
|
||||||
|
// (Tier-B remediation) starts from a clean slate so the first write after restart pays
|
||||||
|
// the full round-trip rather than reusing stale cached state.
|
||||||
|
_writeCoalescer.ResetAll();
|
||||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -637,6 +642,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
state.HostState = newState;
|
state.HostState = newState;
|
||||||
state.HostStateChangedUtc = DateTime.UtcNow;
|
state.HostStateChangedUtc = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
// PR abcip-4.2 — drop the per-device write-coalescer cache when we lose the wire. The
|
||||||
|
// PLC may have been restarted while we were offline + our cached "we already wrote 42"
|
||||||
|
// is no longer valid PLC state. Reset on the Stopped transition (and again on the
|
||||||
|
// recovery edge for safety) so the first post-reconnect write of any value pays the
|
||||||
|
// full round-trip + the coalescer rebuilds its cache from the new baseline.
|
||||||
|
if (newState == HostState.Stopped || newState == HostState.Running)
|
||||||
|
_writeCoalescer.Reset(state.Options.HostAddress);
|
||||||
OnHostStatusChanged?.Invoke(this,
|
OnHostStatusChanged?.Invoke(this,
|
||||||
new HostStatusChangedEventArgs(state.Options.HostAddress, old, newState));
|
new HostStatusChangedEventArgs(state.Options.HostAddress, old, newState));
|
||||||
}
|
}
|
||||||
@@ -1255,6 +1267,16 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
var def = entry.Definition;
|
var def = entry.Definition;
|
||||||
var w = entry.Request;
|
var w = entry.Request;
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// PR abcip-4.2 — write deadband / write-on-change. Consult the coalescer first; a
|
||||||
|
// suppression decision returns Good without hitting libplctag so the OPC UA client sees
|
||||||
|
// the same write semantics it always has, the wire just doesn't move. Driver health is
|
||||||
|
// intentionally left alone on suppression — a coalesced write is neither a success nor
|
||||||
|
// a failure of the underlying connection. Bit-RMW writes go through their own path
|
||||||
|
// (ExecuteBitRmwWriteAsync) which has its own coalescer call site.
|
||||||
|
if (_writeCoalescer.ShouldSuppress(def.DeviceHostAddress, def, w.Value))
|
||||||
|
return (entry.OriginalIndex, AbCipStatusMapper.Good);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
|
var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
|
||||||
@@ -1265,6 +1287,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
if (status == 0)
|
if (status == 0)
|
||||||
{
|
{
|
||||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||||
|
_writeCoalescer.Record(def.DeviceHostAddress, def, w.Value);
|
||||||
return (entry.OriginalIndex, AbCipStatusMapper.Good);
|
return (entry.OriginalIndex, AbCipStatusMapper.Good);
|
||||||
}
|
}
|
||||||
return (entry.OriginalIndex, AbCipStatusMapper.MapLibplctagStatus(status));
|
return (entry.OriginalIndex, AbCipStatusMapper.MapLibplctagStatus(status));
|
||||||
@@ -1309,13 +1332,24 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
private async Task<uint> ExecuteBitRmwWriteAsync(
|
private async Task<uint> ExecuteBitRmwWriteAsync(
|
||||||
DeviceState device, AbCipMultiWritePlanner.ClassifiedWrite entry, CancellationToken ct)
|
DeviceState device, AbCipMultiWritePlanner.ClassifiedWrite entry, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
// PR abcip-4.2 — bit-RMW writes go through the coalescer too. The deadband path is
|
||||||
|
// never useful on a single-bit BOOL (deadband < 1 collapses to equality) but
|
||||||
|
// WriteOnChange is — a UI that toggles a SetPoint.Reset bit at every cycle benefits
|
||||||
|
// from suppressing the redundant pulses.
|
||||||
|
var def = entry.Definition;
|
||||||
|
if (_writeCoalescer.ShouldSuppress(def.DeviceHostAddress, def, entry.Request.Value))
|
||||||
|
return AbCipStatusMapper.Good;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var bit = entry.ParsedPath!.BitIndex!.Value;
|
var bit = entry.ParsedPath!.BitIndex!.Value;
|
||||||
var code = await WriteBitInDIntAsync(device, entry.ParsedPath, bit, entry.Request.Value, ct)
|
var code = await WriteBitInDIntAsync(device, entry.ParsedPath, bit, entry.Request.Value, ct)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
if (code == AbCipStatusMapper.Good)
|
if (code == AbCipStatusMapper.Good)
|
||||||
|
{
|
||||||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||||
|
_writeCoalescer.Record(def.DeviceHostAddress, def, entry.Request.Value);
|
||||||
|
}
|
||||||
return code;
|
return code;
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
@@ -1478,7 +1512,30 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
return runtime;
|
return runtime;
|
||||||
}
|
}
|
||||||
|
|
||||||
public DriverHealth GetHealth() => _health;
|
public DriverHealth GetHealth() => _health with { Diagnostics = BuildDiagnostics() };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-4.2 — driver-attributable counter snapshot exposed via
|
||||||
|
/// <see cref="DriverHealth.Diagnostics"/> + the <c>driver-diagnostics</c> RPC. Names use
|
||||||
|
/// the <c>"<DriverType>.<Counter>"</c> convention so the Admin UI can render
|
||||||
|
/// them alongside Modbus / S7 / OPC UA Client metrics without per-driver special-casing.
|
||||||
|
/// Counters today: <c>AbCip.WritesSuppressed</c> (writes the coalescer skipped because
|
||||||
|
/// deadband / write-on-change suppressed them) and <c>AbCip.WritesPassedThrough</c>
|
||||||
|
/// (writes that hit the wire after consulting the coalescer). Future PRs add CIP-level
|
||||||
|
/// counters (Forward Open count, multi-service-packet ratio, etc.) by extending this
|
||||||
|
/// dictionary.
|
||||||
|
/// </summary>
|
||||||
|
private IReadOnlyDictionary<string, double> BuildDiagnostics() => new Dictionary<string, double>
|
||||||
|
{
|
||||||
|
["AbCip.WritesSuppressed"] = _writeCoalescer.TotalWritesSuppressed,
|
||||||
|
["AbCip.WritesPassedThrough"] = _writeCoalescer.TotalWritesPassedThrough,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test seam — exposes the live coalescer for unit tests that want to inspect counters
|
||||||
|
/// without rebuilding the diagnostics dictionary on every assertion.
|
||||||
|
/// </summary>
|
||||||
|
internal AbCipWriteCoalescer WriteCoalescer => _writeCoalescer;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// CLR-visible allocation footprint only — libplctag's native heap is invisible to the
|
/// CLR-visible allocation footprint only — libplctag's native heap is invisible to the
|
||||||
|
|||||||
@@ -86,7 +86,11 @@ public static class AbCipDriverFactoryExtensions
|
|||||||
: null,
|
: null,
|
||||||
SafetyTag: t.SafetyTag ?? false,
|
SafetyTag: t.SafetyTag ?? false,
|
||||||
// PR abcip-4.1 — per-tag scan rate override; null means "use subscription default".
|
// PR abcip-4.1 — per-tag scan rate override; null means "use subscription default".
|
||||||
ScanRateMs: t.ScanRateMs);
|
ScanRateMs: t.ScanRateMs,
|
||||||
|
// PR abcip-4.2 — per-tag write-deadband + write-on-change. Both default to "off"
|
||||||
|
// when absent so back-compat deployments behave exactly as before.
|
||||||
|
WriteDeadband: t.WriteDeadband,
|
||||||
|
WriteOnChange: t.WriteOnChange ?? false);
|
||||||
|
|
||||||
private static T ParseEnum<T>(string? raw, string? tagName, string driverInstanceId, string field,
|
private static T ParseEnum<T>(string? raw, string? tagName, string driverInstanceId, string field,
|
||||||
T? fallback = null) where T : struct, Enum
|
T? fallback = null) where T : struct, Enum
|
||||||
@@ -180,6 +184,22 @@ public static class AbCipDriverFactoryExtensions
|
|||||||
/// that don't set the knob. Mirrors Kepware's "scan classes" model.
|
/// that don't set the knob. Mirrors Kepware's "scan classes" model.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int? ScanRateMs { get; init; }
|
public int? ScanRateMs { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-4.2 — optional numeric write deadband. When set, the driver skips a
|
||||||
|
/// wire write whose absolute difference from the previous successfully-written
|
||||||
|
/// value falls below this threshold. Suppressed writes still return <c>Good</c>.
|
||||||
|
/// <c>null</c> = no numeric suppression (back-compat default).
|
||||||
|
/// </summary>
|
||||||
|
public double? WriteDeadband { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-4.2 — optional write-on-change gate. When <c>true</c>, the driver
|
||||||
|
/// skips a wire write whose value equals the previous successfully-written value.
|
||||||
|
/// Combines with <see cref="WriteDeadband"/> on numeric tags (deadband path takes
|
||||||
|
/// priority for numerics). Default <c>false</c> — every write reaches the wire.
|
||||||
|
/// </summary>
|
||||||
|
public bool? WriteOnChange { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class AbCipMemberDto
|
internal sealed class AbCipMemberDto
|
||||||
|
|||||||
@@ -279,6 +279,20 @@ public enum AddressingMode
|
|||||||
/// <c>ScanRateMs < 100</c> is clamped up. UDT member tags inherit the parent tag's
|
/// <c>ScanRateMs < 100</c> is clamped up. UDT member tags inherit the parent tag's
|
||||||
/// <c>ScanRateMs</c> at member-fan-out time. See
|
/// <c>ScanRateMs</c> at member-fan-out time. See
|
||||||
/// <c>docs/drivers/AbCip-Operability.md</c> §"Per-tag scan rate".</param>
|
/// <c>docs/drivers/AbCip-Operability.md</c> §"Per-tag scan rate".</param>
|
||||||
|
/// <param name="WriteDeadband">PR abcip-4.2 — optional numeric write deadband. When set and both
|
||||||
|
/// the previous successfully-written value and the new write are numeric, the driver suppresses
|
||||||
|
/// the next write if <c>|new - last| < WriteDeadband</c>. Suppressed writes still return
|
||||||
|
/// <c>Good</c> so the OPC UA write semantics observed by clients are unchanged — the driver
|
||||||
|
/// simply skips the wire round-trip. Mirrors Kepware's "Deadband (write)" knob and is the
|
||||||
|
/// write-side companion to the read-side deadband already shipped at the OPC UA monitored-item
|
||||||
|
/// layer. NaN / Infinity values bypass suppression (let the wire decide). See
|
||||||
|
/// <c>docs/drivers/AbCip-Operability.md</c> §"Write deadband / write-on-change".</param>
|
||||||
|
/// <param name="WriteOnChange">PR abcip-4.2 — optional write-on-change gate. When <c>true</c> and
|
||||||
|
/// the new write equals the previous successfully-written value, the driver suppresses the
|
||||||
|
/// write (returns <c>Good</c> without hitting the wire). Combines with <see cref="WriteDeadband"/>
|
||||||
|
/// for numeric tags — the deadband test takes priority for numerics, equality is the fallback
|
||||||
|
/// for non-numeric types (BOOL setpoints, STRING constants, etc.). Default <c>false</c> —
|
||||||
|
/// legacy behaviour where every write goes to the wire.</param>
|
||||||
public sealed record AbCipTagDefinition(
|
public sealed record AbCipTagDefinition(
|
||||||
string Name,
|
string Name,
|
||||||
string DeviceHostAddress,
|
string DeviceHostAddress,
|
||||||
@@ -290,7 +304,9 @@ public sealed record AbCipTagDefinition(
|
|||||||
bool SafetyTag = false,
|
bool SafetyTag = false,
|
||||||
int? StringLength = null,
|
int? StringLength = null,
|
||||||
string? Description = null,
|
string? Description = null,
|
||||||
int? ScanRateMs = null);
|
int? ScanRateMs = null,
|
||||||
|
double? WriteDeadband = null,
|
||||||
|
bool WriteOnChange = false);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One declared member of a UDT tag. Name is the member identifier on the PLC (e.g. <c>Speed</c>,
|
/// One declared member of a UDT tag. Name is the member identifier on the PLC (e.g. <c>Speed</c>,
|
||||||
|
|||||||
211
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipWriteCoalescer.cs
Normal file
211
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipWriteCoalescer.cs
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-4.2 — end-to-end coverage for write-deadband / write-on-change suppression
|
||||||
|
/// against a running <c>ab_server</c>. Drives a 5-write jittery sequence with
|
||||||
|
/// <c>WriteDeadband=1.0</c> and asserts the driver's <c>AbCip.WritesSuppressed</c>
|
||||||
|
/// diagnostics counter reflects the expected number of suppressions. Wire-level write
|
||||||
|
/// count isn't directly observable in <c>ab_server</c> (no admin shim for "tell me how
|
||||||
|
/// many CIP writes you got"), so the suppression evidence is the driver's own counter
|
||||||
|
/// plus the final read confirming the last passed-through value reached the PLC.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Unit coverage in <see cref="ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.AbCipWriteDeadbandTests"/>
|
||||||
|
/// proves the suppression math against an in-process fake. This test exercises the full
|
||||||
|
/// libplctag stack so a regression in how the driver wires its coalescer to the real wire
|
||||||
|
/// path shows up here.
|
||||||
|
/// </remarks>
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
[Trait("Requires", "AbServer")]
|
||||||
|
public sealed class AbCipWriteDeadbandTests
|
||||||
|
{
|
||||||
|
[AbServerFact]
|
||||||
|
public async Task Jittery_setpoints_within_deadband_dont_reach_the_wire()
|
||||||
|
{
|
||||||
|
var profile = KnownProfiles.ControlLogix;
|
||||||
|
var fixture = new AbServerFixture(profile);
|
||||||
|
await fixture.InitializeAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var deviceUri = $"ab://127.0.0.1:{fixture.Port}/1,0";
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions(deviceUri, profile.Family)],
|
||||||
|
// ab_server seeds TestDINT — drive integer setpoints with a 1.0 deadband so
|
||||||
|
// values that differ by 0 are suppressed. Real-world deadbanding usually
|
||||||
|
// targets REAL setpoints; integer here is fine because the suppression rule
|
||||||
|
// looks at the boxed numeric value, not the on-wire encoding.
|
||||||
|
Tags = [new AbCipTagDefinition("Setpoint", deviceUri, "TestDINT",
|
||||||
|
AbCipDataType.DInt, WriteDeadband: 1.0)],
|
||||||
|
Timeout = TimeSpan.FromSeconds(5),
|
||||||
|
}, "drv-write-deadband-smoke");
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
// Five-write jittery sequence: 100, 100, 100, 102, 102.
|
||||||
|
// - 100 (first): passes (no prior).
|
||||||
|
// - 100, 100: suppressed (|0| < 1.0).
|
||||||
|
// - 102: passes (|2| ≥ 1.0).
|
||||||
|
// - 102: suppressed (|0| < 1.0).
|
||||||
|
// Expected: 2 wire writes, 3 suppressions.
|
||||||
|
var inputs = new[] { 100, 100, 100, 102, 102 };
|
||||||
|
foreach (var v in inputs)
|
||||||
|
{
|
||||||
|
var results = await drv.WriteAsync(
|
||||||
|
[new WriteRequest("Setpoint", v)],
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good,
|
||||||
|
"every write — suppressed or not — must surface as Good to the OPC UA client");
|
||||||
|
}
|
||||||
|
|
||||||
|
drv.WriteCoalescer.TotalWritesSuppressed.ShouldBe(3);
|
||||||
|
drv.WriteCoalescer.TotalWritesPassedThrough.ShouldBe(2);
|
||||||
|
|
||||||
|
// Final readback proves the last passed-through value (102) made it to the PLC.
|
||||||
|
var readback = await drv.ReadAsync(["Setpoint"], TestContext.Current.CancellationToken);
|
||||||
|
readback.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||||
|
Convert.ToInt32(readback.Single().Value).ShouldBe(102);
|
||||||
|
|
||||||
|
// Diagnostics counters are also reflected through GetHealth — the path the
|
||||||
|
// driver-diagnostics RPC + Admin UI consume.
|
||||||
|
var diag = drv.GetHealth().DiagnosticsOrEmpty;
|
||||||
|
diag["AbCip.WritesSuppressed"].ShouldBe(3);
|
||||||
|
diag["AbCip.WritesPassedThrough"].ShouldBe(2);
|
||||||
|
|
||||||
|
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await fixture.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-4.2 — write-deadband / write-on-change suppression in
|
||||||
|
/// <see cref="AbCipDriver.WriteAsync"/>. The driver consults
|
||||||
|
/// <see cref="AbCipWriteCoalescer"/> before issuing any wire write; tests assert the
|
||||||
|
/// suppression rules + that suppressed writes still return <c>Good</c> + that the
|
||||||
|
/// diagnostics counters increment in lockstep.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class AbCipWriteDeadbandTests
|
||||||
|
{
|
||||||
|
private const string Device = "ab://10.0.0.5/1,0";
|
||||||
|
|
||||||
|
private static (AbCipDriver drv, FakeAbCipTagFactory factory) NewDriver(params AbCipTagDefinition[] tags)
|
||||||
|
{
|
||||||
|
var factory = new FakeAbCipTagFactory();
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions(Device)],
|
||||||
|
Tags = tags,
|
||||||
|
}, "drv-deadband", factory);
|
||||||
|
return (drv, factory);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WriteDeadband_suppresses_intermediate_jittery_setpoints()
|
||||||
|
{
|
||||||
|
// 0.5 deadband; sequence 10.0 → 10.3 → 10.4 → 10.6 — only 10.0 (first write, no prior)
|
||||||
|
// and 10.6 (|10.6 - 10.0| = 0.6 ≥ 0.5) hit the wire. 10.3 + 10.4 are within 0.5 of 10.0
|
||||||
|
// and get suppressed.
|
||||||
|
var (drv, factory) = NewDriver(
|
||||||
|
new AbCipTagDefinition("Setpoint", Device, "Setpoint", AbCipDataType.Real,
|
||||||
|
WriteDeadband: 0.5));
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
await drv.WriteAsync([new WriteRequest("Setpoint", 10.0)], CancellationToken.None);
|
||||||
|
await drv.WriteAsync([new WriteRequest("Setpoint", 10.3)], CancellationToken.None);
|
||||||
|
await drv.WriteAsync([new WriteRequest("Setpoint", 10.4)], CancellationToken.None);
|
||||||
|
var last = await drv.WriteAsync([new WriteRequest("Setpoint", 10.6)], CancellationToken.None);
|
||||||
|
|
||||||
|
last.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||||
|
factory.Tags["Setpoint"].WriteCount.ShouldBe(2,
|
||||||
|
"WriteDeadband=0.5 must suppress jittery values within the band");
|
||||||
|
factory.Tags["Setpoint"].Value.ShouldBe(10.6);
|
||||||
|
|
||||||
|
drv.WriteCoalescer.TotalWritesSuppressed.ShouldBe(2);
|
||||||
|
drv.WriteCoalescer.TotalWritesPassedThrough.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WriteOnChange_suppresses_repeated_identical_values()
|
||||||
|
{
|
||||||
|
var (drv, factory) = NewDriver(
|
||||||
|
new AbCipTagDefinition("Counter", Device, "Counter", AbCipDataType.DInt,
|
||||||
|
WriteOnChange: true));
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
await drv.WriteAsync([new WriteRequest("Counter", 5)], CancellationToken.None);
|
||||||
|
await drv.WriteAsync([new WriteRequest("Counter", 5)], CancellationToken.None);
|
||||||
|
var last = await drv.WriteAsync([new WriteRequest("Counter", 5)], CancellationToken.None);
|
||||||
|
|
||||||
|
last.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||||
|
factory.Tags["Counter"].WriteCount.ShouldBe(1, "WriteOnChange must suppress repeated identical writes");
|
||||||
|
|
||||||
|
drv.WriteCoalescer.TotalWritesSuppressed.ShouldBe(2);
|
||||||
|
drv.WriteCoalescer.TotalWritesPassedThrough.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WriteDeadband_takes_priority_over_WriteOnChange_for_numerics()
|
||||||
|
{
|
||||||
|
// Numeric tag with both knobs set: deadband is the active rule, so a value just inside
|
||||||
|
// the deadband suppresses even though it is *not* exactly equal. A value exactly equal
|
||||||
|
// also suppresses (deadband path computes |0| < 0.5 = true).
|
||||||
|
var (drv, factory) = NewDriver(
|
||||||
|
new AbCipTagDefinition("Mixed", Device, "Mixed", AbCipDataType.Real,
|
||||||
|
WriteDeadband: 0.5, WriteOnChange: true));
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
await drv.WriteAsync([new WriteRequest("Mixed", 100.0)], CancellationToken.None);
|
||||||
|
await drv.WriteAsync([new WriteRequest("Mixed", 100.2)], CancellationToken.None); // within band
|
||||||
|
await drv.WriteAsync([new WriteRequest("Mixed", 100.0)], CancellationToken.None); // exact equal
|
||||||
|
|
||||||
|
factory.Tags["Mixed"].WriteCount.ShouldBe(1);
|
||||||
|
drv.WriteCoalescer.TotalWritesSuppressed.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task First_write_always_passes_through_when_no_prior_value()
|
||||||
|
{
|
||||||
|
var (drv, factory) = NewDriver(
|
||||||
|
new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt,
|
||||||
|
WriteDeadband: 100.0, WriteOnChange: true));
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
await drv.WriteAsync([new WriteRequest("Speed", 0)], CancellationToken.None);
|
||||||
|
|
||||||
|
factory.Tags["Speed"].WriteCount.ShouldBe(1, "first write always passes through");
|
||||||
|
factory.Tags["Speed"].Value.ShouldBe(0);
|
||||||
|
drv.WriteCoalescer.TotalWritesSuppressed.ShouldBe(0);
|
||||||
|
drv.WriteCoalescer.TotalWritesPassedThrough.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reset_after_disconnect_lets_same_value_pass_through_again()
|
||||||
|
{
|
||||||
|
var (drv, factory) = NewDriver(
|
||||||
|
new AbCipTagDefinition("Setpoint", Device, "Setpoint", AbCipDataType.Real,
|
||||||
|
WriteOnChange: true));
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
await drv.WriteAsync([new WriteRequest("Setpoint", 42.0)], CancellationToken.None);
|
||||||
|
await drv.WriteAsync([new WriteRequest("Setpoint", 42.0)], CancellationToken.None); // suppressed
|
||||||
|
|
||||||
|
factory.Tags["Setpoint"].WriteCount.ShouldBe(1);
|
||||||
|
|
||||||
|
// Simulate reconnect — the PLC may have restarted while we were offline so the cached
|
||||||
|
// "we already wrote 42" is no longer valid PLC state.
|
||||||
|
drv.WriteCoalescer.Reset(Device);
|
||||||
|
|
||||||
|
await drv.WriteAsync([new WriteRequest("Setpoint", 42.0)], CancellationToken.None);
|
||||||
|
factory.Tags["Setpoint"].WriteCount.ShouldBe(2,
|
||||||
|
"post-reset write must pay the full round-trip even when value is unchanged");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Two_devices_keep_independent_caches_for_same_tag_address()
|
||||||
|
{
|
||||||
|
const string device2 = "ab://10.0.0.6/1,0";
|
||||||
|
var factory = new FakeAbCipTagFactory();
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions(Device), new AbCipDeviceOptions(device2)],
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
new AbCipTagDefinition("DevA", Device, "Pressure", AbCipDataType.Real,
|
||||||
|
WriteOnChange: true),
|
||||||
|
new AbCipTagDefinition("DevB", device2, "Pressure", AbCipDataType.Real,
|
||||||
|
WriteOnChange: true),
|
||||||
|
],
|
||||||
|
}, "drv-multi-device", factory);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
// Write 42.0 to DevA — passes (first), seeds DevA's cache.
|
||||||
|
await drv.WriteAsync([new WriteRequest("DevA", 42.0)], CancellationToken.None);
|
||||||
|
// Write 42.0 to DevB — must also pass (independent cache, no prior value on DevB).
|
||||||
|
await drv.WriteAsync([new WriteRequest("DevB", 42.0)], CancellationToken.None);
|
||||||
|
|
||||||
|
drv.WriteCoalescer.TotalWritesSuppressed.ShouldBe(0,
|
||||||
|
"device-A and device-B must not share a coalescer cache");
|
||||||
|
drv.WriteCoalescer.TotalWritesPassedThrough.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Suppressed_write_returns_Good_status()
|
||||||
|
{
|
||||||
|
var (drv, _) = NewDriver(
|
||||||
|
new AbCipTagDefinition("Setpoint", Device, "Setpoint", AbCipDataType.Real,
|
||||||
|
WriteDeadband: 1.0));
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
await drv.WriteAsync([new WriteRequest("Setpoint", 50.0)], CancellationToken.None);
|
||||||
|
var suppressed = await drv.WriteAsync([new WriteRequest("Setpoint", 50.5)], CancellationToken.None);
|
||||||
|
|
||||||
|
suppressed.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good,
|
||||||
|
"OPC UA write semantics: a suppressed write must look successful to the client");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Diagnostics_counters_surface_through_GetHealth()
|
||||||
|
{
|
||||||
|
var (drv, _) = NewDriver(
|
||||||
|
new AbCipTagDefinition("Counter", Device, "Counter", AbCipDataType.DInt,
|
||||||
|
WriteOnChange: true));
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
await drv.WriteAsync([new WriteRequest("Counter", 7)], CancellationToken.None);
|
||||||
|
await drv.WriteAsync([new WriteRequest("Counter", 7)], CancellationToken.None); // suppressed
|
||||||
|
await drv.WriteAsync([new WriteRequest("Counter", 8)], CancellationToken.None);
|
||||||
|
|
||||||
|
var diag = drv.GetHealth().DiagnosticsOrEmpty;
|
||||||
|
diag["AbCip.WritesSuppressed"].ShouldBe(1);
|
||||||
|
diag["AbCip.WritesPassedThrough"].ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NaN_or_Infinity_bypasses_deadband_suppression()
|
||||||
|
{
|
||||||
|
var (drv, factory) = NewDriver(
|
||||||
|
new AbCipTagDefinition("Sensor", Device, "Sensor", AbCipDataType.Real,
|
||||||
|
WriteDeadband: 1.0));
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
// Seed the cache with a NaN — the next write of 100.0 must NOT be suppressed even
|
||||||
|
// though |100 - NaN| comparison is mathematically meaningless. The wire decides.
|
||||||
|
await drv.WriteAsync([new WriteRequest("Sensor", double.NaN)], CancellationToken.None);
|
||||||
|
await drv.WriteAsync([new WriteRequest("Sensor", 100.0)], CancellationToken.None);
|
||||||
|
|
||||||
|
factory.Tags["Sensor"].WriteCount.ShouldBe(2);
|
||||||
|
drv.WriteCoalescer.TotalWritesSuppressed.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Tag_without_either_knob_never_consults_coalescer_cache()
|
||||||
|
{
|
||||||
|
// Plain back-compat tag — no WriteDeadband, no WriteOnChange. Three identical writes
|
||||||
|
// all hit the wire; the fast path in ShouldSuppress increments PassedThrough only.
|
||||||
|
var (drv, factory) = NewDriver(
|
||||||
|
new AbCipTagDefinition("Plain", Device, "Plain", AbCipDataType.DInt));
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
await drv.WriteAsync([new WriteRequest("Plain", 1)], CancellationToken.None);
|
||||||
|
await drv.WriteAsync([new WriteRequest("Plain", 1)], CancellationToken.None);
|
||||||
|
await drv.WriteAsync([new WriteRequest("Plain", 1)], CancellationToken.None);
|
||||||
|
|
||||||
|
factory.Tags["Plain"].WriteCount.ShouldBe(3);
|
||||||
|
drv.WriteCoalescer.TotalWritesSuppressed.ShouldBe(0);
|
||||||
|
drv.WriteCoalescer.TotalWritesPassedThrough.ShouldBe(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Dto_round_trip_preserves_WriteDeadband_and_WriteOnChange()
|
||||||
|
{
|
||||||
|
// Ensure the DTO surface mirrors AbCipTagDefinition so config JSON drives the knobs.
|
||||||
|
// Going through the static factory entry point guarantees the field names + casing
|
||||||
|
// match what operators put in their driver-config JSON.
|
||||||
|
var json = """
|
||||||
|
{
|
||||||
|
"Devices": [{ "HostAddress": "ab://10.0.0.5/1,0" }],
|
||||||
|
"Tags": [{
|
||||||
|
"Name": "Setpoint",
|
||||||
|
"DeviceHostAddress": "ab://10.0.0.5/1,0",
|
||||||
|
"TagPath": "Setpoint",
|
||||||
|
"DataType": "Real",
|
||||||
|
"WriteDeadband": 0.25,
|
||||||
|
"WriteOnChange": true
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
// Build the driver directly through the internal factory entry point so we can swap
|
||||||
|
// in the FakeAbCipTagFactory after construction; the production CreateInstance path
|
||||||
|
// wires a real LibplctagTagFactory which would try to dlopen libplctag at write time.
|
||||||
|
var dto = JsonSerializer.Deserialize<AbCipDriverFactoryExtensions.AbCipDriverConfigDto>(json,
|
||||||
|
new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||||
|
AllowTrailingCommas = true,
|
||||||
|
})!;
|
||||||
|
// The DTO carries WriteDeadband / WriteOnChange — the round-trip we actually want to
|
||||||
|
// assert is that AbCipTagDto picks them up + AbCipDriverFactoryExtensions.BuildTag
|
||||||
|
// forwards them to AbCipTagDefinition. Re-running the factory entry point would do
|
||||||
|
// that, but a swappable FakeAbCipTagFactory keeps the test fast + offline.
|
||||||
|
var tagDto = dto.Tags!.Single();
|
||||||
|
tagDto.WriteDeadband.ShouldBe(0.25);
|
||||||
|
tagDto.WriteOnChange.ShouldBe(true);
|
||||||
|
|
||||||
|
// Now use the same shape via the static factory + a fake tag factory so the live
|
||||||
|
// driver actually runs the suppression logic + we can confirm the knobs propagated
|
||||||
|
// all the way through to AbCipTagDefinition.
|
||||||
|
var (drv, factory) = NewDriver(
|
||||||
|
new AbCipTagDefinition("Setpoint", Device, "Setpoint", AbCipDataType.Real,
|
||||||
|
WriteDeadband: tagDto.WriteDeadband, WriteOnChange: tagDto.WriteOnChange ?? false));
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
await drv.WriteAsync([new WriteRequest("Setpoint", 1.0)], CancellationToken.None);
|
||||||
|
await drv.WriteAsync([new WriteRequest("Setpoint", 1.0)], CancellationToken.None);
|
||||||
|
|
||||||
|
// WriteOnChange round-tripped — second write of identical value was suppressed.
|
||||||
|
factory.Tags["Setpoint"].WriteCount.ShouldBe(1);
|
||||||
|
drv.WriteCoalescer.TotalWritesSuppressed.ShouldBe(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user