fix(opcuaclient): derive AlarmTransitionKind from upstream ActiveState/AckedState (#619 review)

NativeAlarmProjector derives Active/Acked ONLY from AlarmEventArgs.Kind, and this driver
never set it - every routed event projected Unspecified, leaving the condition inactive+acked
(only Message/Severity moved). The live-gate expectation 'toggling Active as opc-plc cycles'
could not have passed. Select ActiveState/Id + AckedState/Id (two-element browse paths) and
emit the Kind sequence that moves the projector from the state last told to the host to the
event's (Raise+Acknowledge when a notification carries both).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 17:04:48 -04:00
parent 3de2cc3ba8
commit 454775c67c
3 changed files with 135 additions and 5 deletions
@@ -196,6 +196,42 @@ public sealed class OpcUaClientAlarmTests
target.Value.EventId.ShouldBe(new byte[] { 1 });
}
// ---- #619 review: the host's NativeAlarmProjector derives Active/Acked ONLY from AlarmEventArgs.Kind; an
// Unspecified kind keeps the prior state (inactive+acked when fresh), so without a Kind no upstream alarm
// could ever show active. ----
/// <summary>ActiveState/Id and AckedState/Id are read from the two trailing select clauses.</summary>
[Fact]
public void ProjectEvent_reads_the_condition_state()
{
var index = new Dictionary<string, string> { ["ns=3;s=Alarms.Trip1"] = "Plant/opc/plc/Trip1" };
var fields = EventFields("ns=3;s=Alarms.Trip1", "ns=3;s=Alarms.Trip1.Cond");
fields.Add(new Variant(true)); // ActiveState/Id
fields.Add(new Variant(false)); // AckedState/Id
OpcUaClientDriver.ProjectEvent(fields, index, new FakeAlarmHandle(), n => n.ToString(), (_, _) => { },
out var state);
state.ShouldBe((true, false));
}
/// <summary>The transition sequence drives a projector from the driver's last-told state to the event's.</summary>
[Theory]
[InlineData(false, true, true, false, new[] { AlarmTransitionKind.Raise })]
[InlineData(false, true, true, true, new[] { AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge })]
[InlineData(true, false, true, true, new[] { AlarmTransitionKind.Acknowledge })]
[InlineData(true, false, false, false, new[] { AlarmTransitionKind.Clear })]
[InlineData(true, true, false, true, new[] { AlarmTransitionKind.Clear })]
[InlineData(false, false, false, true, new[] { AlarmTransitionKind.Acknowledge })]
[InlineData(true, false, false, true, new[] { AlarmTransitionKind.Clear, AlarmTransitionKind.Acknowledge })]
[InlineData(true, true, true, false, new[] { AlarmTransitionKind.Retrigger })]
[InlineData(true, false, true, false, new[] { AlarmTransitionKind.Unspecified })]
public void AlarmTransitions_move_prior_to_target(
bool priorActive, bool priorAcked, bool active, bool acked, AlarmTransitionKind[] expected)
{
OpcUaClientDriver.AlarmTransitions((priorActive, priorAcked), (active, acked)).ShouldBe(expected);
}
private sealed class FakeAlarmHandle : IAlarmSubscriptionHandle
{
/// <summary>Gets the diagnostic identifier for this alarm handle.</summary>