fix(alarms): native conditions support Acknowledge/AddComment only; report only real method changes (#587 review)
Review of 167a48bf (FIX-FIRST): making native Confirmed/Shelving method-owned produced over-the-wire
regressions — a native TimedShelve never expired, a native OneShotShelve was silently undone by
SetActiveState, an OPC UA ack left a native alarm Unconfirmed forever (ScadaBridge maps ConfirmedState),
and any local state was per-node, never reaching the redundant peer.
- Reverted: the driver projection owns native Confirmed/Shelving again.
- Native Confirm / OneShotShelve / TimedShelve / Unshelve return BadNotSupported with no event, as native
Enable/Disable already did. Scripted alarms keep every method.
- A native Acknowledge re-asserts ConfirmedState=true and the projection's Retain rule (Active || !Acked)
before its single report. The native condition starts Confirmed.
- The method path reports only when the state actually moved (before/after). A re-ack is silent and
restores the Confirmed/Retain that the SDK flips, so a scripted node never drifts from its engine.
AddComment always reports.
- Docs: native alarms support Acknowledge/AddComment only; the Secondary-node native-ack flap is documented.
Tests: refused native methods (5), native ack stays Confirmed through echo/clear/reactivate, scripted
TimedShelve expires with exactly one unshelve event, re-ack silent (native + scripted). Each fix falsified.
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+206
-26
@@ -50,16 +50,13 @@ public sealed class ConditionMethodEventTests
|
||||
[InlineData("Unshelve", false, true)]
|
||||
[InlineData("Disable", false, true)]
|
||||
[InlineData("Enable", false, true)]
|
||||
// Native conditions support Acknowledge and AddComment only (the rest: A_refused_native_method_…).
|
||||
[InlineData("Acknowledge", true, true)]
|
||||
[InlineData("Confirm", true, true)]
|
||||
[InlineData("AddComment", true, true)]
|
||||
[InlineData("OneShotShelve", true, true)]
|
||||
[InlineData("TimedShelve", true, true)]
|
||||
[InlineData("Unshelve", true, true)]
|
||||
// Flag off: the server-wide AlarmAck role admits the call; delivery is ungated.
|
||||
[InlineData("Acknowledge", false, false)]
|
||||
[InlineData("Acknowledge", true, false)]
|
||||
[InlineData("OneShotShelve", true, false)]
|
||||
[InlineData("OneShotShelve", false, false)]
|
||||
public async Task An_accepted_method_call_reports_exactly_one_condition_event(string method, bool native, bool aclOn)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
@@ -97,15 +94,6 @@ public sealed class ConditionMethodEventTests
|
||||
rig.Project(native, current);
|
||||
}
|
||||
|
||||
// A native condition's Confirmed and Shelving are reached only through the methods.
|
||||
ProjectAs("setup");
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(folderItem, "setup"));
|
||||
if (native && method == "Confirm")
|
||||
await CallAsync(session, condition, "Acknowledge", events.LatestEventId(folderItem, "setup"), ct);
|
||||
if (native && method == "Unshelve")
|
||||
await CallAsync(session, condition, "OneShotShelve", events.LatestEventId(folderItem, "setup"), ct);
|
||||
await rig.DrainProjectionsAsync();
|
||||
|
||||
ProjectAs("base");
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(folderItem, "base") && events.Has(serverItem, "base"));
|
||||
|
||||
@@ -121,7 +109,7 @@ public sealed class ConditionMethodEventTests
|
||||
{
|
||||
var baseEvents = events.ConditionEvents(item, "base");
|
||||
baseEvents.Count.ShouldBe(2, $"the base transition plus exactly one {method} event ({item.StartNodeId})");
|
||||
AssertNewState(method, baseEvents[1]);
|
||||
AssertNewState(method, native, baseEvents[1]);
|
||||
}
|
||||
|
||||
if (aclOn)
|
||||
@@ -141,19 +129,29 @@ public sealed class ConditionMethodEventTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A refused call reports nothing: Disable on a native condition is <c>BadNotSupported</c>, so no
|
||||
/// transition happened and no condition event may claim one.
|
||||
/// A native condition supports Acknowledge and AddComment only: Confirm, the shelve methods and
|
||||
/// Disable are refused, report nothing, and leave the condition Confirmed and Unshelved.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_refused_method_call_reports_no_condition_event()
|
||||
/// <remarks>
|
||||
/// Native Confirm/Shelve used to be accepted and applied locally — per node, never upstream, never to the
|
||||
/// redundant peer, and undone by the driver projection (a one-shot shelve silently, a timed one never
|
||||
/// expiring). Unshelve on an unshelved condition is refused by the SDK itself before our veto runs.
|
||||
/// </remarks>
|
||||
[Theory]
|
||||
[InlineData("Confirm")]
|
||||
[InlineData("OneShotShelve")]
|
||||
[InlineData("TimedShelve")]
|
||||
[InlineData("Unshelve")]
|
||||
[InlineData("Disable")]
|
||||
public async Task A_refused_native_method_reports_no_condition_event(string method)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await StartAsync("CondMethodRefused", encrypted: false, ct);
|
||||
var rig = await StartAsync($"CondMethodRefused{method}", encrypted: false, ct);
|
||||
try
|
||||
{
|
||||
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
|
||||
rig.MaterialiseNative();
|
||||
var current = PreState("Disable", native: true);
|
||||
var current = PreState(method, native: true);
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var events = new RecordedEvents();
|
||||
@@ -166,13 +164,16 @@ public sealed class ConditionMethodEventTests
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(serverItem, "base"));
|
||||
|
||||
var refused = await Should.ThrowAsync<ServiceResultException>(
|
||||
CallAsync(session, ConditionNodeId(session, native: true), "Disable", null, ct));
|
||||
refused.StatusCode.ShouldBe(StatusCodes.BadNotSupported);
|
||||
CallAsync(session, ConditionNodeId(session, native: true), method, events.LatestEventId(serverItem, "base"), ct));
|
||||
if (method != "Unshelve") refused.StatusCode.ShouldBe(StatusCodes.BadNotSupported);
|
||||
|
||||
rig.Project(native: true, current with { Message = "sentinel" });
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(folderItem, "sentinel") && events.Has(serverItem, "sentinel"));
|
||||
events.ConditionEvents(folderItem, "base").Count.ShouldBe(1);
|
||||
events.ConditionEvents(serverItem, "base").Count.ShouldBe(1);
|
||||
var sentinel = events.ConditionEvents(serverItem, "sentinel")[0];
|
||||
sentinel[RecordedEvents.ConfirmedField].Value.ShouldBe(true);
|
||||
sentinel[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved);
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
@@ -214,8 +215,184 @@ public sealed class ConditionMethodEventTests
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An OPC UA Acknowledge on a native condition reports exactly one event with <c>ConfirmedState</c> still
|
||||
/// true, and the condition stays Confirmed through the driver's echo, a clear and a re-activation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The SDK's Acknowledge sets Confirmed false on any condition that supports Confirm, and nothing resets
|
||||
/// it for a native alarm — ScadaBridge maps <c>ConfirmedState</c> into its alarm state, so every native
|
||||
/// alarm would read Unconfirmed after its first HMI ack (review of <c>167a48bf</c>).
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task A_native_acknowledge_keeps_the_condition_confirmed_through_clear_and_reactivation()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await StartAsync("CondNativeAckConfirmed", encrypted: false, ct);
|
||||
try
|
||||
{
|
||||
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
|
||||
rig.MaterialiseNative();
|
||||
rig.Nm.NativeAlarmAckRouter = _ => { };
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var events = new RecordedEvents();
|
||||
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
||||
var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
|
||||
rig.Project(true, NativeState(active: true, acked: false, "base"));
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base"));
|
||||
|
||||
await CallAsync(session, ConditionNodeId(session, true), "Acknowledge", events.LatestEventId(item, "base"), ct);
|
||||
rig.Project(true, NativeState(active: true, acked: true, "base")); // the driver's echo of the ack
|
||||
rig.Project(true, NativeState(active: false, acked: true, "cleared"));
|
||||
rig.Project(true, NativeState(active: true, acked: false, "reactivated"));
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "reactivated"));
|
||||
|
||||
var baseEvents = events.ConditionEvents(item, "base");
|
||||
baseEvents.Count.ShouldBe(2, "the base transition plus the one ack event; the echo is no delta");
|
||||
baseEvents[1][RecordedEvents.AckedField].Value.ShouldBe(true);
|
||||
foreach (var e in baseEvents.Concat(events.ConditionEvents(item, "cleared")).Concat(events.ConditionEvents(item, "reactivated")))
|
||||
e[RecordedEvents.ConfirmedField].Value.ShouldBe(true);
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A scripted TimedShelve expires: the engine's expiry comes back as an Unshelved re-projection, which
|
||||
/// reports exactly one unshelve event after the one shelve event.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The method path applies the shelve with <c>shelvingTime 0</c>, as the projection does, so the expiry is
|
||||
/// the engine's (computed from the command's <c>UnshelveAtUtc</c>), never the SDK timer's — a node timer
|
||||
/// would only route an Unshelve back to the engine anyway. The stand-in engine here schedules its expiry
|
||||
/// from the real <c>UnshelveAtUtc</c> the method handler computed.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task A_scripted_timed_shelve_expires_with_exactly_one_unshelve_event()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await StartAsync("CondTimedExpiry", encrypted: false, ct);
|
||||
try
|
||||
{
|
||||
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
|
||||
rig.MaterialiseScripted();
|
||||
var current = PreState("TimedShelve", native: false) with { Message = "base" };
|
||||
Task? expiry = null;
|
||||
rig.Nm.AlarmCommandRouter = cmd =>
|
||||
{
|
||||
if (cmd.Operation != "TimedShelve") return;
|
||||
current = current with { Shelving = AlarmShelvingKind.Timed };
|
||||
rig.ProjectLater(native: false, current);
|
||||
var unshelveAt = cmd.UnshelveAtUtc!.Value;
|
||||
expiry = Task.Run(async () =>
|
||||
{
|
||||
var wait = unshelveAt - DateTime.UtcNow;
|
||||
if (wait > TimeSpan.Zero) await Task.Delay(wait);
|
||||
current = current with { Shelving = AlarmShelvingKind.Unshelved };
|
||||
rig.Project(native: false, current);
|
||||
});
|
||||
};
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var events = new RecordedEvents();
|
||||
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
||||
var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
|
||||
rig.Project(false, current);
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base"));
|
||||
|
||||
await CallAsync(session, ConditionNodeId(session, false), "TimedShelve", null, ct, shelvingTimeMs: 1000);
|
||||
await rig.DrainProjectionsAsync();
|
||||
(expiry is not null).ShouldBeTrue("the TimedShelve reached the engine");
|
||||
await expiry!;
|
||||
|
||||
rig.Project(false, current = current with { Message = "sentinel" });
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel"));
|
||||
|
||||
var shelving = events.ConditionEvents(item, "base").Select(e => e[RecordedEvents.ShelvingField].Value).ToList();
|
||||
shelving.ShouldBe(
|
||||
[ObjectIds.ShelvedStateMachineType_Unshelved, ObjectIds.ShelvedStateMachineType_TimedShelved,
|
||||
ObjectIds.ShelvedStateMachineType_Unshelved],
|
||||
"base, then exactly one shelve event, then exactly one expiry event");
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledging an acknowledged condition reports nothing, and does not flip a scripted condition's
|
||||
/// Confirmed out of step with its engine.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The SDK accepts a re-ack and sets Confirmed false again; the engine ignores it. The scripted leg
|
||||
/// acknowledges and confirms first, so the flip would be visible on the sentinel.
|
||||
/// </remarks>
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task A_re_acknowledge_reports_nothing_and_changes_nothing(bool native)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await StartAsync($"CondReAck{native}", encrypted: false, ct);
|
||||
try
|
||||
{
|
||||
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
|
||||
rig.MaterialiseNative();
|
||||
rig.MaterialiseScripted();
|
||||
var current = PreState("Acknowledge", native) with { Message = "base" };
|
||||
WireSimulatedOwners(rig, native, () => current, s => current = s);
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var events = new RecordedEvents();
|
||||
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
||||
var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
|
||||
var condition = ConditionNodeId(session, native);
|
||||
rig.Project(native, current);
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base"));
|
||||
|
||||
await CallAsync(session, condition, "Acknowledge", events.LatestEventId(item, "base"), ct);
|
||||
await rig.DrainProjectionsAsync();
|
||||
var expected = 2;
|
||||
if (!native)
|
||||
{
|
||||
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == 2);
|
||||
await CallAsync(session, condition, "Confirm", events.LatestEventId(item, "base"), ct);
|
||||
await rig.DrainProjectionsAsync();
|
||||
expected = 3;
|
||||
}
|
||||
|
||||
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == expected);
|
||||
await CallAsync(session, condition, "Acknowledge", events.LatestEventId(item, "base"), ct);
|
||||
await rig.DrainProjectionsAsync();
|
||||
|
||||
rig.Project(native, current = current with { Message = "sentinel" });
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel"));
|
||||
|
||||
events.ConditionEvents(item, "base").Count.ShouldBe(expected, "the re-ack must report nothing");
|
||||
var sentinel = events.ConditionEvents(item, "sentinel");
|
||||
sentinel.Count.ShouldBe(1);
|
||||
sentinel[0][RecordedEvents.AckedField].Value.ShouldBe(true);
|
||||
sentinel[0][RecordedEvents.ConfirmedField].Value.ShouldBe(true, "the re-ack must not unconfirm the node");
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
// ───────────────────────────── helpers ─────────────────────────────
|
||||
|
||||
private static AlarmConditionSnapshot NativeState(bool active, bool acked, string message) => new(
|
||||
Active: active, Acknowledged: acked, Confirmed: true, Enabled: true,
|
||||
Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: message);
|
||||
|
||||
/// <summary>The condition state a case starts from — one in which its method is accepted.</summary>
|
||||
private static AlarmConditionSnapshot PreState(string method, bool native)
|
||||
{
|
||||
@@ -267,8 +444,10 @@ public sealed class ConditionMethodEventTests
|
||||
};
|
||||
}
|
||||
|
||||
private static void AssertNewState(string method, VariantCollection e)
|
||||
private static void AssertNewState(string method, bool native, VariantCollection e)
|
||||
{
|
||||
// A native condition is never Unconfirmed, whatever the method (the SDK's ack would make it so).
|
||||
if (native) e[RecordedEvents.ConfirmedField].Value.ShouldBe(true);
|
||||
switch (method)
|
||||
{
|
||||
case "Acknowledge": e[RecordedEvents.AckedField].Value.ShouldBe(true); break;
|
||||
@@ -287,7 +466,8 @@ public sealed class ConditionMethodEventTests
|
||||
/// Calls one Part 9 method on the condition by its type's method id (the SDK resolves it against the
|
||||
/// condition's method declarations — the shelve methods included); throws on a bad result.
|
||||
/// </summary>
|
||||
private static async Task CallAsync(ISession session, NodeId condition, string method, byte[]? eventId, CancellationToken ct)
|
||||
private static async Task CallAsync(
|
||||
ISession session, NodeId condition, string method, byte[]? eventId, CancellationToken ct, double shelvingTimeMs = 60_000)
|
||||
{
|
||||
var (methodId, args) = method switch
|
||||
{
|
||||
@@ -297,7 +477,7 @@ public sealed class ConditionMethodEventTests
|
||||
"Enable" => (MethodIds.ConditionType_Enable, []),
|
||||
"Disable" => (MethodIds.ConditionType_Disable, []),
|
||||
"OneShotShelve" => (MethodIds.AlarmConditionType_ShelvingState_OneShotShelve, []),
|
||||
"TimedShelve" => (MethodIds.AlarmConditionType_ShelvingState_TimedShelve, [60_000.0]),
|
||||
"TimedShelve" => (MethodIds.AlarmConditionType_ShelvingState_TimedShelve, [shelvingTimeMs]),
|
||||
"Unshelve" => (MethodIds.AlarmConditionType_ShelvingState_Unshelve, []),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(method), method, null),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user