7339a4af07
MaterialiseAlarmCondition never assigned the three mandatory BaseEventType
identity fields, so all three arrived null on every condition event — native
and scripted. The SDK does not synthesise them on this path: Create() builds
the children from the type definition but leaves them unset, the auto-filling
BaseEventState.Initialize overload is only used for transient events, and
ReportEvent / InstanceStateSnapshot copy children verbatim. A conforming
client could not attribute an alarm to its source.
EventType = the concrete materialised type (TypeDefinitionId)
SourceNode = the condition's own NodeId (== ConditionId) — the condition IS
the source; an alarm-bearing raw tag materialises only the
condition, with no sibling value variable
SourceName = the same identifying id string: RawPath (native) /
ScriptedAlarmId (scripted)
SourceName carries the unique id rather than the leaf name: the leaf collides
across devices (HR200 on two PLCs) and is already carried by ConditionName, so
nothing is lost. Documented in docs/AlarmTracking.md, including that clients
must key on ConditionId and must not compose SourceName with ConditionName,
and that SourceName is NOT a live<->history join key (the alarm-history writer
stamps it with the EquipmentPath — a pre-existing divergence, now called out).
Tests: NativeAlarmEventIdentityFieldDeliveryTests is the wire-level guard —
a real client subscription using the standard [EventType, SourceNode,
SourceName, Time, Message, Severity] select clause, verified to fail against
the pre-fix server. NodeManagerAlarmSourceFieldsTests guards the node across
both realms, the base-type fallback, and the kind-swap re-materialise.
The HistoryRead events projection is a separate path (it projects historian
rows, not node fields) and is unaffected — its EventType => Variant.Null
assertions still hold.
258 lines
12 KiB
C#
258 lines
12 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 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;
|
|
|
|
/// <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();
|
|
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);
|
|
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>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)
|
|
{
|
|
var item = new MonitoredItem(subscription.DefaultItem)
|
|
{
|
|
StartNodeId = source,
|
|
AttributeId = Attributes.EventNotifier,
|
|
Filter = BuildEventFilter(),
|
|
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>Captures the delivered event field lists, filtered to our unique alarm Message so unrelated
|
|
/// server events / refresh brackets never count.</summary>
|
|
private sealed class EventCollector
|
|
{
|
|
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 */ }
|
|
}
|
|
}
|
|
}
|