fix(modbus): deadband baseline on (value, status) so the Good recovery publishes
H2: a Bad/null publish drops the deadband baseline and any status change publishes, so a device that returns holding the identical value is no longer swallowed by |new - old| < deadband (the #527 value-only-dedup trap). Integration case drives a real drop/restore through a loopback relay. Refs #741 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -85,4 +85,47 @@ public sealed class CommsRecoveryTests(ModbusSimulatorFixture sim)
|
||||
recovered[0].Value.ShouldBe((ushort)5);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// H2: a deadbanded tag whose device returns holding the SAME value must publish the Good recovery.
|
||||
/// HR[5] is a constant 5 in the standard profile, so the recovery value is identical by construction.
|
||||
/// Positive control: the Bad publish during the outage is observed first.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Deadbanded_subscription_publishes_the_Good_recovery_with_an_identical_value()
|
||||
{
|
||||
SkipUnlessStandardLive();
|
||||
await using var relay = new TcpRelay(sim.Host, sim.Port);
|
||||
var tag = new ModbusTagDefinition("HR5", ModbusRegion.HoldingRegisters, 5, ModbusDataType.UInt16,
|
||||
Writable: false, Deadband: 10.0);
|
||||
await using var driver = new ModbusDriver(Options(relay, tag), "modbus-h2");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var published = new System.Collections.Concurrent.ConcurrentQueue<DataValueSnapshot>();
|
||||
driver.OnDataChange += (_, e) => published.Enqueue(e.Snapshot);
|
||||
var handle = await driver.SubscribeAsync(["HR5"], TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
|
||||
try
|
||||
{
|
||||
(await WaitFor(() => Task.FromResult(published.ToArray()),
|
||||
p => p.Any(s => s.StatusCode == StatusGood), TimeSpan.FromSeconds(10)))
|
||||
.ShouldContain(s => s.StatusCode == StatusGood, "initial Good publish");
|
||||
|
||||
relay.Cut();
|
||||
(await WaitFor(() => Task.FromResult(published.ToArray()),
|
||||
p => p.Any(s => s.StatusCode != StatusGood), TimeSpan.FromSeconds(20)))
|
||||
.ShouldContain(s => s.StatusCode != StatusGood, "the outage must publish Bad (positive control)");
|
||||
|
||||
var badCount = published.Count;
|
||||
relay.Restore();
|
||||
var after = await WaitFor(() => Task.FromResult(published.Skip(badCount).ToArray()),
|
||||
p => p.Any(s => s.StatusCode == StatusGood), TimeSpan.FromSeconds(30));
|
||||
var recovery = after.FirstOrDefault(s => s.StatusCode == StatusGood);
|
||||
recovery.ShouldNotBeNull("the Good recovery with the same value must not be swallowed by the deadband");
|
||||
recovery.Value.ShouldBe((ushort)5);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 H2: the deadband baseline is keyed on (value, status). A Good-after-Bad recovery carrying
|
||||
/// the IDENTICAL value must publish — the value-only dedup left the node Bad until the value drifted.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusDeadbandRecoveryTests
|
||||
{
|
||||
private const uint Good = 0u;
|
||||
private const uint Uncertain = 0x40000000u;
|
||||
private const uint BadComm = 0x80050000u;
|
||||
|
||||
private static async Task<ModbusDriver> NewDriverAsync()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("Temp", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16, Deadband: 5.0);
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = "f", RawTags = ModbusRawTags.Entries([tag]), Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
var drv = new ModbusDriver(opts, "m1", _ => new ModbusDriverTests.FakeTransport());
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
return drv;
|
||||
}
|
||||
|
||||
private static DataValueSnapshot Snap(object? v, uint status) => new(v, status, DateTime.UtcNow, DateTime.UtcNow);
|
||||
|
||||
[Fact]
|
||||
public async Task Good_after_Bad_with_the_same_value_publishes()
|
||||
{
|
||||
await using var drv = await NewDriverAsync();
|
||||
drv.ShouldPublish("Temp", Snap((short)10, Good)).ShouldBeTrue(); // baseline
|
||||
drv.ShouldPublish("Temp", Snap(null, BadComm)).ShouldBeTrue(); // comms drop
|
||||
drv.ShouldPublish("Temp", Snap((short)10, Good)).ShouldBeTrue("the recovery publish must not be swallowed");
|
||||
// Positive control: the deadband is still active after the recovery re-seeded the baseline.
|
||||
drv.ShouldPublish("Temp", Snap((short)11, Good)).ShouldBeFalse();
|
||||
drv.ShouldPublish("Temp", Snap((short)16, Good)).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_status_change_with_the_same_value_publishes()
|
||||
{
|
||||
await using var drv = await NewDriverAsync();
|
||||
drv.ShouldPublish("Temp", Snap((short)10, Good)).ShouldBeTrue();
|
||||
drv.ShouldPublish("Temp", Snap((short)10, Uncertain)).ShouldBeTrue("a quality change must publish");
|
||||
drv.ShouldPublish("Temp", Snap((short)12, Uncertain)).ShouldBeFalse("within deadband at the same quality");
|
||||
drv.ShouldPublish("Temp", Snap((short)10, Good)).ShouldBeTrue("back to Good must publish");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user