Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs
T
Joseph Doherty aa60b3e34e
v2-ci / unit-tests (push) Has been cancelled
test(soak): #799 place the soak's UNS folder after #808; Windows x64 port of the lifecycle soak harness
Acceptance runs for #799 (owner scope 2026-11-12: Windows x64 + Linux) on current master.

Fixture: since #808 an UNS folder the authority snapshot does not place is denied under enforcement (fail
closed). The canonical soak creates its own "SoakEquipment" folder but never placed it, so on master the very
first cycle's UNS monitored item came back bad (retained failed Linux launch on b61f3e05, cycle 1). The rig
gains AdditionalFolderScopes (merged into FolderScopes by Enforce) and the soak places its folder there, as a
deployed generation does - the same fixture gap #888 fixed for the SDK matrix. No server authorization code,
cap, baseline or canonical operation changed; the soak test bodies are otherwise byte-identical to b18f9723.

Windows port (test/harness only, opt-in):
- DescriptorCountsAsync gains a Windows branch: total = every handle in the process handle table, PKI = disk
  file/directory handles whose GetFinalPathNameByHandle path lies under a canonicalised PKI root; dedicated
  thread under the same 5 s bound as the macOS lsof probe.
- Descriptor controls enabled on Windows (symlink alias; timeout control via cmd.exe + ping).
- LateSlopeRun.ValidateRequest accepts Linux ARM64 or Windows x64; duration/runtime/leak tracking unchanged.
- late-slope/analyze.py: expected architecture is a module constant (Linux default Arm64 unchanged).
- windows/: prepare.py (win-x64 self-contained 10.0.14 bundle + manifest), launch.ps1 (container.sh
  equivalent: bundle re-verification, 2/2 controls first, PID/apphost/coreclr verification, external
  sampler, 7440 s outer deadline), analyze.py (Linux row/window logic, X64), README with every adaptation.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-12 12:42:16 -05:00

485 lines
23 KiB
C#

using System.Net;
using System.Net.Sockets;
using System.Reflection;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
using Shouldly;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>
/// A booted server for the Server-object event tests (Gitea #587 / #588): one native condition on a raw
/// device wired to two equipment notifiers, one scripted condition under <see cref="EqA"/>, an API-key
/// principal and anonymous, optionally an encrypted endpoint and server auditing.
/// </summary>
internal sealed class ServerEventRig : IAsyncDisposable
{
public const string Cluster = "SITE-A";
public const string RawDeviceFolder = "Plant/Modbus/dev1";
public const string RawAlarmPath = "Plant/Modbus/dev1/temp_hi";
public const string ScriptedAlarmId = "scripted-eqa-hi";
public const string EqA = "EqA";
public const string EqB = "EqB";
public const string KeyPrincipal = "event-probe";
public const string KeyId = "k-server-events";
private readonly string _pkiRoot;
private readonly List<Task> _pending = [];
private OpcUaApplicationHost _host = null!;
private OtOpcUaSdkServer _server = null!;
private int _port;
private OpcUaApplicationHostOptions _hostOptions = null!;
private ServerEventRig(string pkiRoot) => _pkiRoot = pkiRoot;
public string EndpointUrl => $"opc.tcp://127.0.0.1:{_port}/OtOpcUa";
public OtOpcUaNodeManager Nm => _server.NodeManager!;
internal OtOpcUaSdkServer Server => _server;
/// <summary>
/// The roles the <c>apikey</c> session activates with (read at each activation). Defaults to the two
/// server-wide roles; a flag-off test sets it from <c>ApiKeyInterimRoleMapper.Map</c> over its own grants, as
/// the production authenticator does.
/// </summary>
public IReadOnlyList<string> KeyRoles { get; set; } = [OpcUaDataPlaneRoles.AlarmAck, OpcUaDataPlaneRoles.WriteOperate];
public SdkAddressSpaceSink Sink { get; private set; } = null!;
public static async Task<ServerEventRig> StartAsync(string name, bool encrypted, CancellationToken ct)
{
var rig = new ServerEventRig(Path.Combine(Path.GetTempPath(), $"otopcua-{name}-{Guid.NewGuid():N}"));
rig._hostOptions = new OpcUaApplicationHostOptions
{
ApplicationName = $"OtOpcUa.{name}",
ApplicationUri = $"urn:OtOpcUa.{name}.{Guid.NewGuid():N}",
PublicHostname = "127.0.0.1",
PkiStoreRoot = rig._pkiRoot,
EnabledSecurityProfiles = encrypted
? [OpcUaSecurityProfile.None, OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt]
: [OpcUaSecurityProfile.None],
AutoAcceptUntrustedClientCertificates = true,
};
(rig._host, rig._server) = await OpcUaHostStart.StartAsync(
port =>
{
rig._port = port;
rig._hostOptions.OpcUaPort = port;
return rig.NewHost();
},
() => new OtOpcUaSdkServer(), ct);
rig.Sink = new SdkAddressSpaceSink(rig.Nm);
return rig;
}
private async Task BootAsync(CancellationToken ct)
{
_server = new OtOpcUaSdkServer();
_host = NewHost();
await _host.StartAsync(_server, ct);
Sink = new SdkAddressSpaceSink(Nm);
}
private OpcUaApplicationHost NewHost() =>
new(_hostOptions, NullLogger<OpcUaApplicationHost>.Instance, new KeyPresentingAuthenticator(() => KeyRoles));
/// <summary>
/// Stops the server and boots a fresh one on the SAME port, application URI and PKI store (a process
/// restart as a client sees it). The address space is EMPTY afterwards: the caller re-creates its nodes and
/// re-publishes values, as a redeploy would. <paramref name="whileDown"/> runs once the old server is gone
/// and before the new one binds, so a test can observe the outage.
/// </summary>
public async Task RestartAsync(CancellationToken ct, Func<Task>? whileDown = null)
{
await _host.DisposeAsync();
if (whileDown is not null) await whileDown();
await BootAsync(ct);
}
/// <summary>
/// Extra UNS folder placements <see cref="Enforce(bool, ApiKey[], IReadOnlyDictionary{AclScopeRef, IReadOnlyList{NodeAclPath}}, IReadOnlyDictionary{string, NodeAclPath}, NodeAcl[])"/>
/// adds to <see cref="FolderScopes"/>, for a test that materialises its own UNS folder. Since #808 an UNS folder
/// the generation does not place is denied under enforcement (fail closed); a deployed generation places every
/// folder it creates, so a fixture folder must be placed too (lmxopcua#799, the same fixture gap as #888).
/// </summary>
public IReadOnlyDictionary<string, UnsFolderScope>? AdditionalFolderScopes { get; set; }
/// <summary>Wires the authorizer over the given keys and grants, enforcing or not.</summary>
public DataPlaneAuthorityState Enforce(bool enabled, ApiKey[] keys, params NodeAcl[] grants) =>
Enforce(enabled, keys, ScopePaths(), grants);
/// <summary>Wires the authorizer over the given keys, scope paths and grants, enforcing or not.</summary>
public DataPlaneAuthorityState Enforce(
bool enabled, ApiKey[] keys, IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths,
params NodeAcl[] grants) =>
Enforce(enabled, keys, scopePaths, EquipmentOwnerPaths(), grants);
/// <summary>
/// Wires the authorizer over the given keys, scope paths, equipment owner paths (the scripted-alarm owner
/// lookup, Gitea #797) and grants, enforcing or not.
/// </summary>
public DataPlaneAuthorityState Enforce(
bool enabled, ApiKey[] keys, IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths,
IReadOnlyDictionary<string, NodeAclPath> equipmentOwnerPaths, params NodeAcl[] grants)
{
var state = new DataPlaneAuthorityState();
var folders = AdditionalFolderScopes is null ? FolderScopes()
: FolderScopes().Concat(AdditionalFolderScopes).ToDictionary(pair => pair.Key, pair => pair.Value);
state.Apply(
Cluster, "rev-1", keys, grants, scopePaths, equipmentOwnerPaths, ScriptedAlarmOwners(), folders,
TagReferences());
Nm.DataPlaneAuthority = state;
Nm.Authorizer = new DataPlaneAuthorizer(
state, new TriePermissionEvaluator(state.Tries), enabled, NullLogger.Instance, TimeProvider.System);
return state;
}
public static ApiKey Key() => new()
{
ApiKeyId = KeyId,
Name = KeyId,
SecretHash = "hash",
Principal = KeyPrincipal,
Enabled = true,
CreatedUtc = DateTime.UtcNow.AddDays(-1),
};
public static NodeAcl ClusterGrant(string principal, string id, NodePermissions permissions) => new()
{
NodeAclId = id,
ClusterId = Cluster,
Principal = principal,
ScopeKind = NodeAclScopeKind.Cluster,
PermissionFlags = permissions,
};
public static NodeAcl EquipmentGrant(string equipment, NodePermissions permissions) => new()
{
NodeAclId = $"acl-{equipment}",
ClusterId = Cluster,
Principal = OpcUaPrincipals.Anonymous,
ScopeKind = NodeAclScopeKind.Equipment,
ScopeId = $"eq-{equipment}",
PermissionFlags = permissions,
};
public static IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> ScopePaths() =>
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
{
[new AclScopeRef(NodeAclScopeKind.Equipment, $"eq-{EqA}")] = [new NodeAclPath([NodeAclRealms.Uns, EqA])],
[new AclScopeRef(NodeAclScopeKind.Equipment, $"eq-{EqB}")] = [new NodeAclPath([NodeAclRealms.Uns, EqB])],
};
/// <summary>
/// The rig's equipment owner paths: its equipment folders' ids ARE their EquipmentIds, sitting directly at
/// <c>[uns, EqA]</c> / <c>[uns, EqB]</c> — the same nodes <see cref="ScopePaths"/> plants the Equipment
/// grants at.
/// </summary>
public static IReadOnlyDictionary<string, NodeAclPath> EquipmentOwnerPaths() =>
new Dictionary<string, NodeAclPath>
{
[EqA] = new NodeAclPath([NodeAclRealms.Uns, EqA]),
[EqB] = new NodeAclPath([NodeAclRealms.Uns, EqB]),
};
/// <summary>
/// The rig's UNS folder placements (Gitea #808): each equipment folder hangs directly under the root, so its
/// id chain is itself and its path is <c>[uns, EqA]</c> / <c>[uns, EqB]</c> — the synthetic shape every
/// pre-#808 rig test was written against. ⚠️ Production folder ids are opaque and their paths are NAMES; the
/// #808 tests (<c>UnsFolderScopeWireTests</c>) use that shape instead, which this rig's coincidence hid.
/// </summary>
public static IReadOnlyDictionary<string, UnsFolderScope> FolderScopes() =>
new Dictionary<string, UnsFolderScope>
{
[EqA] = new(new NodeAclPath([NodeAclRealms.Uns, EqA]), [EqA]),
[EqB] = new(new NodeAclPath([NodeAclRealms.Uns, EqB]), [EqB]),
};
/// <summary>
/// The rig's deployed UNS references (Gitea #826): its native alarm's raw tag is projected into both equipment —
/// the folders <see cref="MaterialiseNative"/> wires it into, so per-folder delivery of it is current.
/// </summary>
public static IReadOnlyDictionary<string, IReadOnlySet<string>> TagReferences() =>
new Dictionary<string, IReadOnlySet<string>> { [RawAlarmPath] = new HashSet<string> { EqA, EqB } };
/// <summary>The rig's deployed scripted-alarm owners: its one scripted alarm belongs to <see cref="EqA"/>.</summary>
public static IReadOnlyDictionary<string, string> ScriptedAlarmOwners() =>
new Dictionary<string, string> { [ScriptedAlarmId] = EqA };
/// <summary>The native condition, wired as an event source of both equipment folders.</summary>
public void MaterialiseNative()
{
Sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
Sink.MaterialiseAlarmCondition(
RawAlarmPath, RawDeviceFolder, "temp_hi", "OffNormalAlarm", 700, AddressSpaceRealm.Raw, isNative: true);
EnsureEquipment();
Sink.WireAlarmNotifiers(RawAlarmPath, AddressSpaceRealm.Raw, [EqA, EqB], AddressSpaceRealm.Uns);
}
/// <summary>The scripted condition under <see cref="EqA"/>.</summary>
public void MaterialiseScripted()
{
EnsureEquipment();
Sink.MaterialiseAlarmCondition(
ScriptedAlarmId, EqA, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns, isNative: false);
}
/// <summary>
/// Adds a raw value variable under <see cref="RawDeviceFolder"/> (the folder is ensured), for tests that
/// drive a real client against plain values rather than alarm conditions. Additive to the alarm helpers.
/// </summary>
public string EnsureRawVariable(string name, string dataType, bool writable, string? historianTagname = null)
{
Sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
var id = $"{RawDeviceFolder}/{name}";
Sink.EnsureVariable(id, RawDeviceFolder, name, dataType, writable, AddressSpaceRealm.Raw, historianTagname);
return id;
}
/// <summary>The server-stable <c>nsu=</c> form of a raw-realm string NodeId.</summary>
public static string RawNsu(string id) => $"nsu={V3NodeIds.RawNamespaceUri};s={id}";
/// <summary>The API-key credential <see cref="KeyPresentingAuthenticator"/> admits (username is always <c>apikey</c>).</summary>
public static string KeyPassword => $"otua_{KeyId}_secret";
public void EnsureEquipment()
{
Sink.EnsureFolder(EqA, parentNodeId: null, displayName: "A", realm: AddressSpaceRealm.Uns);
Sink.EnsureFolder(EqB, parentNodeId: null, displayName: "B", realm: AddressSpaceRealm.Uns);
}
/// <summary>Projects <paramref name="state"/> onto a condition, as the engine or a driver would.</summary>
public void Project(bool native, AlarmConditionSnapshot state) =>
Sink.WriteAlarmCondition(
native ? RawAlarmPath : ScriptedAlarmId,
state,
DateTime.UtcNow,
native ? AddressSpaceRealm.Raw : AddressSpaceRealm.Uns);
/// <summary>
/// Re-projects <paramref name="state"/> from a pool thread, the way the engine's or the driver's answer
/// to a routed method call arrives — it contends for the node manager's lock like the real one does.
/// </summary>
public void ProjectLater(bool native, AlarmConditionSnapshot state)
{
lock (_pending) _pending.Add(Task.Run(() => Project(native, state)));
}
/// <summary>Waits for every <see cref="ProjectLater"/> re-projection to have run.</summary>
public async Task DrainProjectionsAsync()
{
Task[] pending;
lock (_pending) pending = [.. _pending];
await Task.WhenAll(pending);
}
/// <summary>The condition's NodeId in the session's namespaces.</summary>
public static NodeId ConditionNodeId(ISession session, bool native) => native
? new NodeId(RawAlarmPath, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri))
: new NodeId(ScriptedAlarmId, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri));
public static NodeId UnsNodeId(ISession session, string id)
{
var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri);
ns.ShouldBeGreaterThan((ushort)0);
return new NodeId(id, ns);
}
/// <summary>Switches the server's <c>Auditing</c> flag (no configuration seam reaches it in this repo).</summary>
public void SetAuditing(bool on)
{
var server = typeof(Opc.Ua.Server.CustomNodeManager2)
.GetProperty("Server", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)!
.GetValue(Nm)!;
server.GetType()
.GetProperty("Auditing", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)!
.GetSetMethod(nonPublic: true)!
.Invoke(server, [on]);
}
public async Task<ISession> ConnectAsync(bool anonymous, CancellationToken ct, bool encrypted = false, string[]? preferredLocales = null)
{
var pki = TestClientSecurity.AllocatePkiRoot();
var security = TestClientSecurity.Build(pki);
security.ApplicationCertificate!.SubjectName = "CN=OtOpcUa.ServerEventClient";
var appConfig = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { }))
{
ApplicationName = "OtOpcUa.ServerEventClient",
ApplicationUri = $"urn:OtOpcUa.ServerEventClient.{Guid.NewGuid():N}",
ApplicationType = ApplicationType.Client,
SecurityConfiguration = security,
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
};
await appConfig.ValidateAsync(ApplicationType.Client, ct);
// TestClientSecurity explicitly auto-accepts self-signed test peers.
if (encrypted)
{
var app = new ApplicationInstance(DefaultTelemetry.Create(_ => { })) { ApplicationName = appConfig.ApplicationName, ApplicationType = ApplicationType.Client };
app.ApplicationConfiguration = appConfig;
await app.CheckApplicationInstanceCertificatesAsync(silent: true, lifeTimeInMonths: null, ct);
}
var endpoint = await CoreClientUtils.SelectEndpointAsync(
appConfig, $"opc.tcp://127.0.0.1:{_port}/OtOpcUa", encrypted, DefaultTelemetry.Create(_ => { }), ct);
if (encrypted) endpoint!.SecurityMode.ShouldBe(MessageSecurityMode.SignAndEncrypt);
var identity = anonymous
? new UserIdentity(new AnonymousIdentityToken())
: new UserIdentity("apikey", Encoding.UTF8.GetBytes($"otua_{KeyId}_secret"));
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
appConfig,
new ConfiguredEndpoint(null, endpoint ?? throw new InvalidOperationException("Endpoint discovery returned no endpoint."), EndpointConfiguration.Create(appConfig)),
updateBeforeConnect: false,
checkDomain: false,
sessionName: "ServerEventRig",
sessionTimeout: 60_000,
identity: identity,
preferredLocales: preferredLocales is { } locales ? (ArrayOf<string>)locales : default,
ct: ct);
}
public async ValueTask DisposeAsync()
{
await _host.DisposeAsync();
if (Directory.Exists(_pkiRoot))
{
try { Directory.Delete(_pkiRoot, recursive: true); }
catch (IOException) { /* best effort */ }
}
}
/// <summary>
/// Admits <c>apikey</c> as <see cref="KeyPrincipal"/> carrying <see cref="KeyRoles"/> (used only while
/// enforcement is off); anonymous falls through to the <c>anonymous</c> principal.
/// </summary>
private sealed class KeyPresentingAuthenticator(Func<IReadOnlyList<string>> roles) : IOpcUaUserAuthenticator
{
/// <inheritdoc />
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct) =>
Task.FromResult(
string.Equals(username, "apikey", StringComparison.Ordinal)
&& password.StartsWith($"otua_{KeyId}_", StringComparison.Ordinal)
? OpcUaUserAuthResult.AllowKey(KeyPrincipal, roles(), KeyId)
: OpcUaUserAuthResult.Deny("Invalid API key."));
}
}
/// <summary>Records every event each monitored item received, in order, with the fields the tests select.</summary>
internal sealed class RecordedEvents
{
// The select clauses, in order — see AddEventItem.
public const int EventIdField = 0;
public const int MessageField = 1;
public const int EventTypeField = 2;
public const int AckedField = 3;
public const int ConfirmedField = 4;
public const int EnabledField = 5;
public const int ShelvingField = 6;
public const int CommentField = 7;
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, ArrayOf<Variant> Fields)> _events = [];
public void OnEvents(Subscription subscription, EventNotificationList notification, ArrayOf<string> stringTable)
{
lock (_gate)
{
foreach (var e in notification.Events) _events.Add((e.ClientHandle, e.EventFields));
}
}
/// <summary>Every event <paramref name="item"/> received, in arrival order.</summary>
public IReadOnlyList<ArrayOf<Variant>> Of(MonitoredItem item)
{
lock (_gate) return _events.Where(e => e.Handle == item.ClientHandle).Select(e => e.Fields).ToList();
}
/// <summary>The condition events (they carry <c>AckedState/Id</c>; audit and refresh events do not).</summary>
public IReadOnlyList<ArrayOf<Variant>> ConditionEvents(MonitoredItem item, string message) =>
Of(item).Where(f => f[AckedField].TryGetValue(out bool _) && Message(f) == message).ToList();
public bool Has(MonitoredItem item, string message) => Of(item).Any(f => Message(f) == message);
public bool HasType(MonitoredItem item, NodeId type) => Of(item).Any(f => f[EventTypeField].GetNodeId() == type);
public byte[] LatestEventId(MonitoredItem item, string message) =>
Of(item).Last(f => Message(f) == message && f[AckedField].TryGetValue(out bool _))[EventIdField].GetByteString().ToArray()!;
public static string? Message(ArrayOf<Variant> f) => f[MessageField].GetLocalizedText().Text;
public static async Task<Subscription> SubscribeAsync(ISession session, RecordedEvents events, CancellationToken ct)
{
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
subscription.FastEventCallback = events.OnEvents;
session.AddSubscription(subscription);
await subscription.CreateAsync(ct);
return subscription;
}
public static MonitoredItem AddEventItem(Subscription subscription, NodeId source)
{
var filter = new EventFilter();
filter.AddSelectClause(new NodeId(ObjectTypes.BaseEventType), QualifiedName.From(BrowseNames.EventId));
filter.AddSelectClause(new NodeId(ObjectTypes.BaseEventType), QualifiedName.From(BrowseNames.Message));
filter.AddSelectClause(new NodeId(ObjectTypes.BaseEventType), QualifiedName.From(BrowseNames.EventType));
filter.SelectClauses += new SimpleAttributeOperand(
ObjectTypeIds.AcknowledgeableConditionType, [QualifiedName.From(BrowseNames.AckedState), QualifiedName.From(BrowseNames.Id)]);
filter.SelectClauses += new SimpleAttributeOperand(
ObjectTypeIds.AcknowledgeableConditionType, [QualifiedName.From(BrowseNames.ConfirmedState), QualifiedName.From(BrowseNames.Id)]);
filter.SelectClauses += new SimpleAttributeOperand(
ObjectTypeIds.ConditionType, [QualifiedName.From(BrowseNames.EnabledState), QualifiedName.From(BrowseNames.Id)]);
filter.SelectClauses += new SimpleAttributeOperand(
ObjectTypeIds.AlarmConditionType, [QualifiedName.From(BrowseNames.ShelvingState), QualifiedName.From(BrowseNames.CurrentState), QualifiedName.From(BrowseNames.Id)]);
filter.AddSelectClause(ObjectTypeIds.ConditionType, QualifiedName.From(BrowseNames.Comment));
filter.AddSelectClause(ObjectTypeIds.GeneralModelChangeEventType, QualifiedName.From(BrowseNames.Changes));
filter.AddSelectClause(new NodeId(ObjectTypes.BaseEventType), QualifiedName.From(BrowseNames.SourceNode));
filter.SelectClauses += new SimpleAttributeOperand(
ObjectTypeIds.AlarmConditionType, [QualifiedName.From(BrowseNames.ShelvingState), QualifiedName.From(BrowseNames.UnshelveTime)]);
filter.SelectClauses += new SimpleAttributeOperand(
ObjectTypeIds.AlarmConditionType, [QualifiedName.From(BrowseNames.ActiveState), QualifiedName.From(BrowseNames.Id)]);
filter.AddSelectClause(new NodeId(ObjectTypes.BaseEventType), QualifiedName.From(BrowseNames.Severity));
filter.AddSelectClause(ObjectTypeIds.ConditionType, QualifiedName.From(BrowseNames.Retain));
var item = new MonitoredItem(subscription.DefaultItem)
{
StartNodeId = source,
AttributeId = Attributes.EventNotifier,
Filter = filter,
QueueSize = 1000,
SamplingInterval = 0,
};
subscription.AddItem(item);
return item;
}
public static async Task WaitUntilAsync(Func<bool> condition)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
while (DateTime.UtcNow < deadline)
{
if (condition()) return;
await Task.Delay(50);
}
condition().ShouldBeTrue("condition not met within timeout");
}
}