Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/NativeAlarmEventIdentityFieldDeliveryTests.cs
T
Joseph Doherty e08b6b0e69 fix(alarms): populate ConditionClassId/ConditionClassName on conditions (#475)
MaterialiseAlarmCondition never assigned the mandatory Part 9 ConditionType
classification fields, so every condition event — native and scripted — shipped
ConditionClassId = NodeId.Null (i=0) and ConditionClassName = empty text. Same
mechanism as #473: Create() builds the mandatory children from the type's
embedded definition but leaves them unset, and nothing downstream synthesises
them (ReportEvent / InstanceStateSnapshot copy children verbatim). An HMI
bucketing alarms by condition class dropped every OtOpcUa alarm as unclassified.

Report BaseConditionClassType — Part 9's "no condition class modelled" value.
This is the honest report: we hold no classification at the materialise seam.
Deliberately NOT ProcessConditionClassType (the SDK sample's pick), which would
assert a classification we cannot back and would be actively wrong for a Galaxy
alarm whose upstream category is Safety/Diagnostics — trading a detectable null
for an undetectable lie. Real per-alarm classification needs the driver's
AlarmCategory carried to this deploy-time seam (it lives only on the runtime
AlarmEventArgs transition today) and is a separate feature.

Guards, both observed RED against the pre-fix server:
- NativeAlarmEventIdentityFieldDeliveryTests: wire-level, its own select clause
  (the #473 test's clause mirrors ScadaBridge's exactly and its indices are
  load-bearing, so it is left untouched). The class fields are declared on
  ConditionType, not BaseEventType, so they are selected against that type.
- NodeManagerAlarmSourceFieldsTests: node-level, native (Raw) + scripted (Uns).

Stacked on #473 (PR #474) — merge after it.
2026-07-17 00:49:43 -04:00

331 lines
16 KiB
C#

using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
using Shouldly;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>
/// Issue #473 — the over-the-wire proof that a live condition event carries the mandatory
/// <c>BaseEventType</c> identity fields <c>EventType</c> / <c>SourceNode</c> / <c>SourceName</c>. All three
/// previously arrived <b>null</b> on every condition event: <c>MaterialiseAlarmCondition</c> never assigned
/// them, and nothing downstream synthesises them (<c>ReportEvent</c> / <c>InstanceStateSnapshot</c> copy the
/// condition's children verbatim), so a conforming client could not attribute an alarm to its source.
///
/// <para>This is the WIRE-level guard the node-level
/// <c>NodeManagerAlarmSourceFieldsTests</c> cannot give: it proves the values survive the snapshot +
/// serialization all the way into a real client's <c>EventFieldList</c>. The select clause is deliberately
/// the exact one a real consumer uses — ScadaBridge's Data Connection Layer selects
/// <c>[EventType, SourceNode, SourceName, Time, Message, Severity]</c> — so this test fails the way that
/// client failed.</para>
///
/// <para>Boots the real <see cref="OtOpcUaSdkServer"/> and drives the production
/// <see cref="SdkAddressSpaceSink"/>, mirroring <c>NativeAlarmMultiNotifierEventDeliveryTests</c>. Heavy
/// in-process server+client integration — runs in the serial integration pass.</para>
/// </summary>
public sealed class NativeAlarmEventIdentityFieldDeliveryTests
{
private const string ServerUri = "urn:OtOpcUa.AlarmEventIdentityFields";
private const string ConditionClassServerUri = "urn:OtOpcUa.AlarmEventConditionClassFields";
private const string RawDeviceFolder = "pymodbus/plc";
private const string RawAlarmPath = "pymodbus/plc/HR200";
private const string LeafName = "HR200";
private const string Equip1 = "EQ-filling-line1-station1";
private const string AlarmMessage = "HR200 over limit (identity-field test)";
// Field indices in the select clause built by BuildEventFilter — ScadaBridge's exact clause.
private const int EventTypeIndex = 0;
private const int SourceNodeIndex = 1;
private const int SourceNameIndex = 2;
private const int TimeIndex = 3;
private const int MessageIndex = 4;
private const int SeverityIndex = 5;
// Field indices in BuildConditionClassEventFilter's clause (#475). A SEPARATE clause on purpose: the
// ScadaBridge one above is mirrored field-for-field from the real consumer and its indices are load-bearing,
// so appending to it would silently shift them. Message is re-selected here only as the collector's filter key.
private const int ConditionClassIdIndex = 0;
private const int ConditionClassNameIndex = 1;
private const int ConditionClassMessageIndex = 2;
/// <summary>A live native condition event delivers a populated EventType / SourceNode / SourceName to a
/// Server-object subscriber using the standard BaseEventType select clause.</summary>
[Fact]
public async Task Condition_event_carries_populated_EventType_SourceNode_and_SourceName_on_the_wire()
{
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-alarm-identity-{Guid.NewGuid():N}");
var port = AllocateFreePort();
var ct = TestContext.Current.CancellationToken;
try
{
var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri, ct);
await using var _ = host;
var sink = new SdkAddressSpaceSink(server.NodeManager!);
WireCondition(sink);
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
// Resolve the RAW namespace index CLIENT-side (from the server's advertised NamespaceArray) so the
// expected SourceNode is built exactly as a real client would resolve it.
var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
rawNs.ShouldBeGreaterThan((ushort)0);
var collector = new EventCollector(MessageIndex);
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
subscription.FastEventCallback = collector.OnEvents;
session.AddSubscription(subscription);
await subscription.CreateAsync(ct);
// The collector filters by our unique Message, so the item's ClientHandle is not needed here
// (unlike the sibling multi-notifier test, which tallies delivery per monitored item).
AddEventItem(subscription, ObjectIds.Server, BuildEventFilter());
await subscription.ApplyChangesAsync(ct);
sink.WriteAlarmCondition(RawAlarmPath, ActiveSnapshot(), DateTime.UtcNow, AddressSpaceRealm.Raw);
await WaitUntilAsync(() => collector.Fields.Count >= 1, TimeSpan.FromSeconds(5));
var fields = collector.Fields.ShouldHaveSingleItem();
// --- the three fields this issue is about ---
// EventType: the concrete condition type, readable as a FIELD (not just via an OfType where-clause).
fields[EventTypeIndex].Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType,
"EventType must carry the concrete condition type, not null");
// SourceNode: the condition's own NodeId in the RAW namespace == ConditionId.
fields[SourceNodeIndex].Value.ShouldBe(new NodeId(RawAlarmPath, rawNs),
"SourceNode must identify the source condition node, not null");
// SourceName: the full RawPath — unique across devices, matching SourceNode/ConditionId. The leaf
// name (HR200) is deliberately NOT used here: it collides across PLCs and is carried by ConditionName.
fields[SourceNameIndex].Value.ShouldBe(RawAlarmPath,
"SourceName must carry the unique RawPath, not null");
fields[SourceNameIndex].Value.ShouldNotBe(LeafName,
"SourceName is deliberately the unique RawPath, not the ambiguous leaf name");
// The rest of the standard envelope still arrives intact (guards against a regression that
// populated identity at the cost of the existing fields).
fields[TimeIndex].Value.ShouldBeOfType<DateTime>();
((LocalizedText)fields[MessageIndex].Value).Text.ShouldBe(AlarmMessage);
fields[SeverityIndex].Value.ShouldBe((ushort)700);
await subscription.DeleteAsync(true, ct);
}
finally
{
SafeDelete(pkiRoot + "-srv");
}
}
/// <summary>Issue #475 — a live condition event delivers a populated ConditionClassId / ConditionClassName.
/// Both previously arrived unset (NodeId.Null / empty text) via the same mechanism as the #473 fields, so an
/// HMI bucketing alarms by condition class dropped every OtOpcUa alarm into an unclassified bin.</summary>
[Fact]
public async Task Condition_event_carries_populated_ConditionClass_fields_on_the_wire()
{
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-alarm-condclass-{Guid.NewGuid():N}");
var port = AllocateFreePort();
var ct = TestContext.Current.CancellationToken;
try
{
var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ConditionClassServerUri, ct);
await using var _ = host;
var sink = new SdkAddressSpaceSink(server.NodeManager!);
WireCondition(sink);
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
var collector = new EventCollector(ConditionClassMessageIndex);
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
subscription.FastEventCallback = collector.OnEvents;
session.AddSubscription(subscription);
await subscription.CreateAsync(ct);
AddEventItem(subscription, ObjectIds.Server, BuildConditionClassEventFilter());
await subscription.ApplyChangesAsync(ct);
sink.WriteAlarmCondition(RawAlarmPath, ActiveSnapshot(), DateTime.UtcNow, AddressSpaceRealm.Raw);
await WaitUntilAsync(() => collector.Fields.Count >= 1, TimeSpan.FromSeconds(5));
var fields = collector.Fields.ShouldHaveSingleItem();
// ConditionClassId — a resolvable class NodeId, NOT NodeId.Null. We model no condition classes, so
// Part 9's "no class modelled" value (BaseConditionClassType) is the conformant report.
fields[ConditionClassIdIndex].Value.ShouldBe(ObjectTypeIds.BaseConditionClassType,
"ConditionClassId must carry a resolvable condition class, not null");
// ConditionClassName — the matching human-readable name, NOT empty text.
var className = fields[ConditionClassNameIndex].Value.ShouldBeOfType<LocalizedText>();
className.Text.ShouldBe("BaseConditionClass",
"ConditionClassName must name the reported condition class, not be empty");
await subscription.DeleteAsync(true, ct);
}
finally
{
SafeDelete(pkiRoot + "-srv");
}
}
/// <summary>Materialise the raw device folder + the native condition at its RawPath, plus one referencing
/// equipment folder wired as a notifier (the production topology).</summary>
private static void WireCondition(SdkAddressSpaceSink sink)
{
sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "plc", realm: AddressSpaceRealm.Raw);
sink.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, LeafName, "OffNormalAlarm", 700, AddressSpaceRealm.Raw, isNative: true);
sink.EnsureFolder(Equip1, parentNodeId: null, displayName: "station1", realm: AddressSpaceRealm.Uns);
sink.WireAlarmNotifiers(RawAlarmPath, AddressSpaceRealm.Raw, new[] { Equip1 }, AddressSpaceRealm.Uns);
}
private static AlarmConditionSnapshot ActiveSnapshot() =>
new(Active: true, Acknowledged: false, Confirmed: true, Enabled: true,
Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: AlarmMessage);
private static MonitoredItem AddEventItem(Subscription subscription, NodeId source, EventFilter filter)
{
var item = new MonitoredItem(subscription.DefaultItem)
{
StartNodeId = source,
AttributeId = Attributes.EventNotifier,
Filter = filter,
QueueSize = 100,
SamplingInterval = 0,
};
subscription.AddItem(item);
return item;
}
/// <summary>ScadaBridge's exact select clause: [EventType, SourceNode, SourceName, Time, Message, Severity].</summary>
private static EventFilter BuildEventFilter()
{
var filter = new EventFilter();
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.EventType);
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.SourceNode);
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.SourceName);
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Time);
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message);
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Severity);
return filter;
}
/// <summary>#475's clause: [ConditionClassId, ConditionClassName, Message]. The two class fields are declared on
/// <c>ConditionType</c>, not BaseEventType, so they must be selected against that type or the server returns no
/// value for them regardless of the fix. Message rides along solely as the collector's filter key.</summary>
private static EventFilter BuildConditionClassEventFilter()
{
var filter = new EventFilter();
filter.AddSelectClause(ObjectTypes.ConditionType, BrowseNames.ConditionClassId);
filter.AddSelectClause(ObjectTypes.ConditionType, BrowseNames.ConditionClassName);
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message);
return filter;
}
/// <summary>Captures the delivered event field lists, filtered to our unique alarm Message so unrelated
/// server events / refresh brackets never count.</summary>
/// <param name="messageIndex">Position of the Message field in the select clause this collector is paired
/// with — the two clauses here place it differently, so it cannot be a shared constant.</param>
private sealed class EventCollector(int messageIndex)
{
private readonly object _gate = new();
private readonly List<VariantCollection> _fields = new();
public IReadOnlyList<VariantCollection> Fields
{
get { lock (_gate) return _fields.ToList(); }
}
public void OnEvents(Subscription subscription, EventNotificationList notification, IList<string> stringTable)
{
lock (_gate)
{
foreach (var e in notification.Events)
{
if (e.EventFields.Count > messageIndex &&
e.EventFields[messageIndex].Value is LocalizedText lt &&
lt.Text == AlarmMessage)
{
_fields.Add(e.EventFields);
}
}
}
}
}
private static async Task<(OtOpcUaSdkServer Server, OpcUaApplicationHost Host)> BootServerAsync(
int port, string pkiRoot, string appUri, CancellationToken ct)
{
var options = new OpcUaApplicationHostOptions
{
ApplicationName = appUri,
ApplicationUri = appUri,
OpcUaPort = port,
PublicHostname = "127.0.0.1",
PkiStoreRoot = pkiRoot,
EnabledSecurityProfiles = new List<OpcUaSecurityProfile> { OpcUaSecurityProfile.None },
AutoAcceptUntrustedClientCertificates = true,
};
var server = new OtOpcUaSdkServer();
var host = new OpcUaApplicationHost(options, NullLogger<OpcUaApplicationHost>.Instance);
await host.StartAsync(server, ct);
return (server, host);
}
private static async Task<ISession> OpenSessionAsync(string endpointUrl, CancellationToken ct)
{
var appConfig = new ApplicationConfiguration
{
ApplicationName = "OtOpcUa.AlarmEventIdentityFieldsClient",
ApplicationUri = $"urn:OtOpcUa.AlarmEventIdentityFieldsClient.{Guid.NewGuid():N}",
ApplicationType = ApplicationType.Client,
SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()),
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
};
await appConfig.ValidateAsync(ApplicationType.Client, ct);
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
var endpoint = await CoreClientUtils.SelectEndpointAsync(
appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), ct);
var endpointConfiguration = EndpointConfiguration.Create(appConfig);
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration);
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
appConfig, configuredEndpoint, updateBeforeConnect: false, checkDomain: false,
sessionName: "AlarmEventIdentityFieldDeliveryTests", sessionTimeout: 60_000,
identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: null, ct: ct);
}
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (condition()) return;
await Task.Delay(50);
}
condition().ShouldBeTrue("condition not met within timeout");
}
private static int AllocateFreePort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
private static void SafeDelete(string dir)
{
if (Directory.Exists(dir))
{
try { Directory.Delete(dir, recursive: true); }
catch { /* best-effort */ }
}
}
}