282 lines
13 KiB
C#
282 lines
13 KiB
C#
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);
|
|
}
|
|
}
|