fix(client): alarm filter selected ActiveState/Id as ONE QualifiedName — every event read inactive

EventFilter.AddSelectClause(type, "ActiveState/Id") does not split the string;
it selects a single QualifiedName named "ActiveState/Id", which no event carries.
AckedState/Id, ActiveState/Id and EnabledState/Id therefore always arrived null
and Client.CLI 'alarms' (and Client.UI) reported every alarm inactive and
unacknowledged while a direct Read said Active. Pre-existing since a2883b82;
server-side event delivery was correct (proven with two-element select clauses,
incl. after #629). Select them with explicit two-element browse paths.

Over-the-wire test drives OpcUaClientService.SubscribeAlarmsAsync against the
in-process server (native + scripted); ServerEventRig now also selects
ActiveState/Id, Severity and Retain.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 16:19:27 -04:00
parent cc7cd2db11
commit 49a470a080
5 changed files with 126 additions and 4 deletions
@@ -3,6 +3,7 @@ using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Client.Shared;
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
@@ -135,6 +136,70 @@ public sealed class ClientAlarmMethodTests
finally { await rig.DisposeAsync(); }
}
/// <summary>
/// The client's alarm SUBSCRIPTION (Client.CLI <c>alarms</c>) reads the condition's two-state fields off a
/// real event. Its filter used to pass <c>"ActiveState/Id"</c> to <c>EventFilter.AddSelectClause</c>, which
/// does NOT split the string: the operand's browse path was ONE QualifiedName named <c>ActiveState/Id</c>,
/// which matches nothing, so every event arrived with Active/Acked/Enabled null and the client reported
/// them all inactive and unacknowledged — while a direct Read of the same condition said Active. The
/// fake-adapter unit tests feed field values straight in and so never exercise the filter.
/// </summary>
/// <param name="native">Drive the native (raw device folder) or the scripted (equipment folder) condition.</param>
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task The_client_alarm_subscription_reports_active_acked_and_exact_severity(bool native)
{
var ct = TestContext.Current.CancellationToken;
var rig = await StartAsync($"ClientAlarmSubscribe{native}", encrypted: false, ct);
try
{
rig.Enforce(false, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
if (native) rig.MaterialiseNative(); else rig.MaterialiseScripted();
using var client = await ConnectClientAsync(rig, ct);
using var observer = await rig.ConnectAsync(anonymous: false, ct);
var source = native
? new NodeId(RawDeviceFolder, (ushort)observer.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri))
: UnsNodeId(observer, EqA);
var seen = new List<AlarmEventArgs>();
client.AlarmEvent += (_, e) => { lock (seen) seen.Add(e); };
await client.SubscribeAlarmsAsync(source, 100, ct);
AlarmEventArgs? Latest(string message)
{
lock (seen) return seen.LastOrDefault(e => e.Message == message);
}
// 800 is not one of the SDK's five EventSeverity values (900 is): the node must carry it verbatim.
rig.Project(native, new AlarmConditionSnapshot(Active: true, Acknowledged: false, Confirmed: true,
Enabled: true, AlarmShelvingKind.Unshelved, 800, "raised"));
await RecordedEvents.WaitUntilAsync(() => Latest("raised") is not null);
var raised = Latest("raised")!;
raised.ActiveState.ShouldBeTrue("the condition is active");
raised.AckedState.ShouldBeFalse();
raised.Severity.ShouldBe((ushort)800);
raised.Retain.ShouldBeTrue();
rig.Project(native, new AlarmConditionSnapshot(Active: true, Acknowledged: true, Confirmed: true,
Enabled: true, AlarmShelvingKind.Unshelved, 800, "acked"));
await RecordedEvents.WaitUntilAsync(() => Latest("acked") is not null);
Latest("acked")!.AckedState.ShouldBeTrue("the positive control for the Acked field");
Latest("acked")!.ActiveState.ShouldBeTrue();
rig.Project(native, new AlarmConditionSnapshot(Active: false, Acknowledged: true, Confirmed: true,
Enabled: true, AlarmShelvingKind.Unshelved, 800, "cleared"));
await RecordedEvents.WaitUntilAsync(() => Latest("cleared") is not null);
var cleared = Latest("cleared")!;
cleared.ActiveState.ShouldBeFalse();
cleared.Retain.ShouldBeFalse("an inactive, acknowledged condition is not retained");
await client.UnsubscribeAlarmsAsync(ct);
}
finally { await rig.DisposeAsync(); }
}
private static async Task<OpcUaClientService> ConnectClientAsync(ServerEventRig rig, CancellationToken ct)
{
var client = new OpcUaClientService();
@@ -315,6 +315,9 @@ internal sealed class RecordedEvents
public const int ChangesField = 8;
public const int SourceNodeField = 9;
public const int UnshelveTimeField = 10;
public const int ActiveField = 11;
public const int SeverityField = 12;
public const int RetainField = 13;
private readonly object _gate = new();
private readonly List<(uint Handle, VariantCollection Fields)> _events = [];
@@ -374,6 +377,10 @@ internal sealed class RecordedEvents
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.SourceNode);
filter.SelectClauses.Add(new SimpleAttributeOperand(
ObjectTypeIds.AlarmConditionType, [BrowseNames.ShelvingState, BrowseNames.UnshelveTime]));
filter.SelectClauses.Add(new SimpleAttributeOperand(
ObjectTypeIds.AlarmConditionType, [BrowseNames.ActiveState, BrowseNames.Id]));
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Severity);
filter.AddSelectClause(ObjectTypeIds.ConditionType, BrowseNames.Retain);
var item = new MonitoredItem(subscription.DefaultItem)
{