Compare commits

..

5 Commits

Author SHA1 Message Date
6863cc4652 Merge pull request 'Task #219 follow-up — close AlarmConditionState child-NodeId + Part 9 event-propagation gaps' (#198) from task-219-followup-alarm-wiring into v2 2026-04-21 00:24:41 -04:00
Joseph Doherty
8221fac8c1 Task #219 follow-up — close AlarmConditionState child-NodeId + event-propagation gaps
PR #197 surfaced two integration-level wiring gaps in DriverNodeManager's
MarkAsAlarmCondition path; this commit fixes both and upgrades the integration
test to assert them end-to-end.

Fix 1 — addressable child nodes: AlarmConditionState inherits ~50 typed children
(Severity / Message / ActiveState / AckedState / EnabledState / …). The stack
was leaving them with Foundation-namespace NodeIds (type-declaration defaults) or
shared ns=0 counter allocations, so client Read on a child returned
BadNodeIdUnknown. Pass assignNodeIds=true to alarm.Create, then walk the condition
subtree and rewrite each descendant's NodeId symbolically as
  {condition-full-ref}.{symbolic-path}
in the node manager's namespace. Stable, unique, and collision-free across
multiple alarm instances in the same driver.

Fix 2 — event propagation to Server.EventNotifier: OPC UA Part 9 event
propagation relies on the alarm condition being reachable from Objects/Server
via HasNotifier. Call CustomNodeManager2.AddRootNotifier(alarm) after registering
the condition so subscriptions placed on Server-object EventNotifier receive the
ReportEvent calls ConditionSink emits per-transition.

Test upgrades in AlarmSubscribeIntegrationTests:
  - Driver_alarm_transition_updates_server_side_AlarmConditionState_node — now
    asserts Severity == 700, Message text, and ActiveState.Id == true through
    the OPC UA client (previously scoped out as BadNodeIdUnknown).
  - New: Driver_alarm_event_flows_to_client_subscription_on_Server_EventNotifier
    subscribes an OPC UA event monitor on ObjectIds.Server, fires a driver
    transition, and waits for the AlarmConditionType event to be delivered,
    asserting Message + Severity fields. Previously scoped out as "Part 9 event
    propagation out of reach."

Regression checks: 239 server tests pass (+1 new event-subscription test),
195 Core tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 00:22:02 -04:00
bc44711dca Merge pull request 'Task #219 — Server-integration test coverage for IAlarmSource dispatch path' (#197) from task-219-alarm-history-integration into v2 2026-04-20 23:36:26 -04:00
Joseph Doherty
acf31fd943 Task #219 — Server-integration test coverage for IAlarmSource dispatch path
Adds AlarmSubscribeIntegrationTests alongside HistoryReadIntegrationTests so both
optional driver capabilities — IHistoryProvider (already covered) and IAlarmSource
(new) — have end-to-end coverage that boots the full OPC UA stack and exercises the
wiring path from driver event → GenericDriverNodeManager forwarder → DriverNodeManager
ConditionSink through a real Session.

Two tests:
  1. Driver_alarm_transition_updates_server_side_AlarmConditionState_node — a fake
     IAlarmSource declares an IsAlarm=true variable, calls MarkAsAlarmCondition in
     DiscoverAsync, and fires OnAlarmEvent for that source. Verifies the
     client can browse the alarm condition node at FullReference + ".Condition"
     and reads the DisplayName back through Session.Read.
  2. Each_IsAlarm_variable_registers_its_own_condition_node_in_the_driver_namespace —
     two IsAlarm variables each produce their own addressable AlarmConditionState,
     proving the CapturingHandle per-variable registration works.

Scoped-out (documented in the class docstring): the stack exposes AlarmConditionState's
inherited children (Severity / Message / ActiveState / …) with Foundation-namespace
NodeIds that DriverNodeManager does not add to its predefined-node index, so reading
those child attributes through a client returns BadNodeIdUnknown. OPC UA Part 9 event
propagation (subscribe-on-Server + ConditionRefresh) is likewise out of reach until
the node manager wires HasNotifier + child-node registration. The existing Core-level
GenericDriverNodeManagerTests cover the in-memory alarm-sink fan-out semantics.

Full Server.Tests suite: 238 passed, 0 failed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 23:33:45 -04:00
7e143e293b Merge pull request 'Driver-instance bootstrap pipeline (#248) — DriverInstance rows materialise as live IDriver instances' (#196) from phase-7-fu-248-driver-bootstrap into v2 2026-04-20 22:52:12 -04:00
2 changed files with 366 additions and 1 deletions

View File

@@ -371,7 +371,20 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
BrowseName = new QualifiedName(_variable.BrowseName.Name + "_Condition", _owner.NamespaceIndex),
DisplayName = new LocalizedText(info.SourceName),
};
alarm.Create(_owner.SystemContext, alarm.NodeId, alarm.BrowseName, alarm.DisplayName, false);
// assignNodeIds=true makes the stack allocate NodeIds for every inherited
// AlarmConditionState child (Severity / Message / ActiveState / AckedState /
// EnabledState / …). Without this the children keep Foundation (ns=0) type-
// declaration NodeIds that aren't in the node manager's predefined-node index.
// The newly-allocated NodeIds default to ns=0 via the shared identifier
// counter — we remap them to the node manager's namespace below so client
// Read/Browse on children resolves against the predefined-node dictionary.
alarm.Create(_owner.SystemContext, alarm.NodeId, alarm.BrowseName, alarm.DisplayName, true);
// Assign every descendant a stable, collision-free NodeId in the node manager's
// namespace keyed on the condition path. The stack's default assignNodeIds path
// allocates from a shared ns=0 counter and does not update parent→child
// references when we remap, so we do the rename up front, symbolically:
// {condition-full-ref}/{symbolic-path-under-condition}
AssignSymbolicDescendantIds(alarm, alarm.NodeId, _owner.NamespaceIndex);
alarm.SourceName.Value = info.SourceName;
alarm.Severity.Value = (ushort)MapSeverity(info.InitialSeverity);
alarm.Message.Value = new LocalizedText(info.InitialDescription ?? info.SourceName);
@@ -382,10 +395,20 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
alarm.AckedState.Id.Value = true;
alarm.ActiveState.Value = new LocalizedText("Inactive");
alarm.ActiveState.Id.Value = false;
// Enable ConditionRefresh support so clients that connect *after* a transition
// can pull the current retained-condition snapshot.
alarm.ClientUserId.Value = string.Empty;
alarm.BranchId.Value = NodeId.Null;
_variable.AddChild(alarm);
_owner.AddPredefinedNode(_owner.SystemContext, alarm);
// Part 9 event propagation: AddRootNotifier registers the alarm as an event
// source reachable from Objects/Server so subscriptions placed on Server-object
// EventNotifier receive the ReportEvent calls ConditionSink.OnTransition emits.
// Without this the Report fires but has no subscribers to deliver to.
_owner.AddRootNotifier(alarm);
return new ConditionSink(_owner, alarm);
}
}
@@ -398,6 +421,26 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
AlarmSeverity.Critical => 900,
_ => 500,
};
// After alarm.Create(assignNodeIds=true), every descendant has *some* NodeId but
// they default to ns=0 via the shared identifier counter — allocations from two
// different alarms collide when we move them into the driver's namespace. Rewriting
// symbolically based on the condition path gives each descendant a unique, stable
// NodeId in the node manager's namespace. Browse + Read resolve against the current
// NodeId because the stack's CustomNodeManager2.Browse traverses NodeState.Children
// (NodeState references) and uses each child's current .NodeId in the response.
private static void AssignSymbolicDescendantIds(
NodeState parent, NodeId parentNodeId, ushort namespaceIndex)
{
var children = new List<BaseInstanceState>();
parent.GetChildren(null!, children);
foreach (var child in children)
{
child.NodeId = new NodeId(
$"{parentNodeId.Identifier}.{child.SymbolicName}", namespaceIndex);
AssignSymbolicDescendantIds(child, child.NodeId, namespaceIndex);
}
}
}
private sealed class ConditionSink(DriverNodeManager owner, AlarmConditionState alarm)

View File

@@ -0,0 +1,322 @@
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
using ZB.MOM.WW.OtOpcUa.Server.Security;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
/// <summary>
/// Task #219 — end-to-end server integration coverage for the <see cref="IAlarmSource"/>
/// dispatch path. Boots the full OPC UA stack + a fake <see cref="IAlarmSource"/> driver,
/// opens a client session, raises a driver-side transition, and asserts it propagates
/// through <c>GenericDriverNodeManager</c>'s alarm forwarder into
/// <c>DriverNodeManager.ConditionSink</c>, updates the server-side
/// <c>AlarmConditionState</c> child attributes (Severity / Message / ActiveState), and
/// flows out to an OPC UA subscription on the Server object's EventNotifier.
///
/// Companion to <see cref="HistoryReadIntegrationTests"/> which covers the
/// <see cref="IHistoryProvider"/> dispatch path; together they close the server-side
/// integration gap for optional driver capabilities (plan decision #62).
/// </summary>
[Trait("Category", "Integration")]
public sealed class AlarmSubscribeIntegrationTests : IAsyncLifetime
{
private static readonly int Port = 48700 + Random.Shared.Next(0, 99);
private readonly string _endpoint = $"opc.tcp://localhost:{Port}/OtOpcUaAlarmTest";
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-alarm-test-{Guid.NewGuid():N}");
private DriverHost _driverHost = null!;
private OpcUaApplicationHost _server = null!;
private AlarmDriver _driver = null!;
public async ValueTask InitializeAsync()
{
_driverHost = new DriverHost();
_driver = new AlarmDriver();
await _driverHost.RegisterAsync(_driver, "{}", CancellationToken.None);
var options = new OpcUaServerOptions
{
EndpointUrl = _endpoint,
ApplicationName = "OtOpcUaAlarmTest",
ApplicationUri = "urn:OtOpcUa:Server:AlarmTest",
PkiStoreRoot = _pkiRoot,
AutoAcceptUntrustedClientCertificates = true,
HealthEndpointsEnabled = false,
};
_server = new OpcUaApplicationHost(options, _driverHost, new DenyAllUserAuthenticator(),
NullLoggerFactory.Instance, NullLogger<OpcUaApplicationHost>.Instance);
await _server.StartAsync(CancellationToken.None);
}
public async ValueTask DisposeAsync()
{
await _server.DisposeAsync();
await _driverHost.DisposeAsync();
try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort */ }
}
[Fact]
public async Task Driver_alarm_transition_updates_server_side_AlarmConditionState_node()
{
using var session = await OpenSessionAsync();
var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:alarm-driver");
_driver.RaiseAlarm(new AlarmEventArgs(
SubscriptionHandle: new FakeHandle("sub"),
SourceNodeId: "Tank.HiHi",
ConditionId: "cond-1",
AlarmType: "Active",
Message: "Level exceeded upper-upper",
Severity: AlarmSeverity.High,
SourceTimestampUtc: DateTime.UtcNow));
// The alarm-condition node's identifier is the driver full-reference + ".Condition"
// (DriverNodeManager.VariableHandle.MarkAsAlarmCondition). Server-side state changes
// are applied synchronously under DriverNodeManager.Lock inside ConditionSink.OnTransition,
// so by the time RaiseAlarm returns the node state has been flushed.
var conditionNodeId = new NodeId("Tank.HiHi.Condition", nsIndex);
// Browse the condition node for the well-known Part-9 child variables. The stack
// materializes Severity / Message / ActiveState / AckedState as children below the
// AlarmConditionState; their NodeIds are allocated by the stack so we discover them
// by BrowseName rather than guessing.
var browseDescriptions = new BrowseDescriptionCollection
{
new()
{
NodeId = conditionNodeId,
BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
IncludeSubtypes = true,
NodeClassMask = 0,
ResultMask = (uint)BrowseResultMask.All,
},
};
session.Browse(null, null, 0, browseDescriptions, out var browseResults, out _);
var children = browseResults[0].References
.ToDictionary(r => r.BrowseName.Name,
r => ExpandedNodeId.ToNodeId(r.NodeId, session.NamespaceUris),
StringComparer.Ordinal);
children.ShouldContainKey("Severity");
children.ShouldContainKey("Message");
children.ShouldContainKey("ActiveState");
// Severity / Message / ActiveState.Id reflect the driver-fired transition — verifies
// the forwarder → ConditionSink.OnTransition → alarm.ClearChangeMasks pipeline
// landed the new values in addressable child nodes. DriverNodeManager's
// AssignSymbolicDescendantIds keeps each child reachable under the node manager's
// namespace so Read resolves against the predefined-node dictionary.
var severity = session.ReadValue(children["Severity"]);
var message = session.ReadValue(children["Message"]);
severity.Value.ShouldBe((ushort)700); // AlarmSeverity.High → 700 (MapSeverity)
((LocalizedText)message.Value).Text.ShouldBe("Level exceeded upper-upper");
// ActiveState exposes its boolean Id as a HasProperty child.
var activeBrowse = new BrowseDescriptionCollection
{
new()
{
NodeId = children["ActiveState"],
BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.HasProperty,
IncludeSubtypes = true,
ResultMask = (uint)BrowseResultMask.All,
},
};
session.Browse(null, null, 0, activeBrowse, out var activeChildren, out _);
var idRef = activeChildren[0].References.Single(r => r.BrowseName.Name == "Id");
var activeId = session.ReadValue(ExpandedNodeId.ToNodeId(idRef.NodeId, session.NamespaceUris));
activeId.Value.ShouldBe(true);
}
[Fact]
public async Task Driver_alarm_event_flows_to_client_subscription_on_Server_EventNotifier()
{
// AddRootNotifier registers the AlarmConditionState as a Server-object notifier
// source, so a subscription with an EventFilter on Server receives the
// ReportEvent calls ConditionSink emits per-transition.
using var session = await OpenSessionAsync();
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
session.AddSubscription(subscription);
await subscription.CreateAsync();
var received = new List<EventFieldList>();
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var filter = new EventFilter();
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.EventId);
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.SourceName);
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Message);
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Severity);
filter.WhereClause = new ContentFilter();
filter.WhereClause.Push(FilterOperator.OfType,
new LiteralOperand { Value = new Variant(ObjectTypeIds.AlarmConditionType) });
var item = new MonitoredItem(subscription.DefaultItem)
{
StartNodeId = ObjectIds.Server,
AttributeId = Attributes.EventNotifier,
NodeClass = NodeClass.Object,
SamplingInterval = 0,
QueueSize = 100,
Filter = filter,
};
item.Notification += (_, e) =>
{
if (e.NotificationValue is EventFieldList fields)
{
lock (received) { received.Add(fields); gate.TrySetResult(); }
}
};
subscription.AddItem(item);
await subscription.ApplyChangesAsync();
// Give the publish loop a tick to establish before firing.
await Task.Delay(200);
_driver.RaiseAlarm(new AlarmEventArgs(
new FakeHandle("sub"), "Tank.HiHi", "cond-x", "Active",
"High-high tripped", AlarmSeverity.Critical, DateTime.UtcNow));
var delivered = await Task.WhenAny(gate.Task, Task.Delay(TimeSpan.FromSeconds(10)));
delivered.ShouldBe(gate.Task, "alarm event must arrive at the client within 10s");
EventFieldList first;
lock (received) first = received[0];
// Filter field order: 0=EventId, 1=SourceName, 2=Message, 3=Severity.
((LocalizedText)first.EventFields[2].Value).Text.ShouldBe("High-high tripped");
first.EventFields[3].Value.ShouldBe((ushort)900); // Critical → 900
}
[Fact]
public async Task Each_IsAlarm_variable_registers_its_own_condition_node_in_the_driver_namespace()
{
// Tag-scoped alarm wiring: DiscoverAsync declares two IsAlarm variables and calls
// MarkAsAlarmCondition on each. The server-side DriverNodeManager wraps each call in
// a CapturingHandle that creates a sibling AlarmConditionState + registers a sink
// under the driver full-reference. Browse should show both condition nodes with
// distinct NodeIds using the FullReference + ".Condition" convention.
using var session = await OpenSessionAsync();
var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:alarm-driver");
_driver.RaiseAlarm(new AlarmEventArgs(
new FakeHandle("sub"), "Tank.HiHi", "c", "Active", "first", AlarmSeverity.High,
DateTime.UtcNow));
var attrs = new ReadValueIdCollection
{
new() { NodeId = new NodeId("Tank.HiHi.Condition", nsIndex), AttributeId = Attributes.DisplayName },
new() { NodeId = new NodeId("Heater.OverTemp.Condition", nsIndex), AttributeId = Attributes.DisplayName },
};
session.Read(null, 0, TimestampsToReturn.Neither, attrs, out var results, out _);
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
results[1].StatusCode.Code.ShouldBe(StatusCodes.Good);
((LocalizedText)results[0].Value).Text.ShouldBe("Tank.HiHi");
((LocalizedText)results[1].Value).Text.ShouldBe("Heater.OverTemp");
}
private async Task<ISession> OpenSessionAsync()
{
var cfg = new ApplicationConfiguration
{
ApplicationName = "OtOpcUaAlarmTestClient",
ApplicationUri = "urn:OtOpcUa:AlarmTestClient",
ApplicationType = ApplicationType.Client,
SecurityConfiguration = new SecurityConfiguration
{
ApplicationCertificate = new CertificateIdentifier
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(_pkiRoot, "client-own"),
SubjectName = "CN=OtOpcUaAlarmTestClient",
},
TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-issuers") },
TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-trusted") },
RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-rejected") },
AutoAcceptUntrustedCertificates = true,
AddAppCertToTrustedStore = true,
},
TransportConfigurations = new TransportConfigurationCollection(),
TransportQuotas = new TransportQuotas { OperationTimeout = 15000 },
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 },
};
await cfg.Validate(ApplicationType.Client);
cfg.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
var instance = new ApplicationInstance { ApplicationConfiguration = cfg, ApplicationType = ApplicationType.Client };
await instance.CheckApplicationInstanceCertificate(true, CertificateFactory.DefaultKeySize);
var selected = CoreClientUtils.SelectEndpoint(cfg, _endpoint, useSecurity: false);
var endpointConfig = EndpointConfiguration.Create(cfg);
var configuredEndpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
return await Session.Create(cfg, configuredEndpoint, false, "OtOpcUaAlarmTestClientSession", 60000,
new UserIdentity(new AnonymousIdentityToken()), null);
}
/// <summary>
/// Stub <see cref="IAlarmSource"/> driver. <see cref="DiscoverAsync"/> emits two alarm-
/// bearing variables (so tag-scoped fan-out can be asserted); <see cref="RaiseAlarm"/>
/// fires <see cref="OnAlarmEvent"/> exactly like a real driver would.
/// </summary>
private sealed class AlarmDriver : IDriver, ITagDiscovery, IAlarmSource
{
public string DriverInstanceId => "alarm-driver";
public string DriverType => "AlarmStub";
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
public long GetMemoryFootprint() => 0;
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct)
{
var tank = builder.Folder("Tank", "Tank");
var hiHi = tank.Variable("HiHi", "HiHi", new DriverAttributeInfo(
"Tank.HiHi", DriverDataType.Boolean, false, null,
SecurityClassification.FreeAccess, false, IsAlarm: true));
hiHi.MarkAsAlarmCondition(new AlarmConditionInfo(
"Tank.HiHi", AlarmSeverity.High, "High-high alarm"));
var heater = builder.Folder("Heater", "Heater");
var ot = heater.Variable("OverTemp", "OverTemp", new DriverAttributeInfo(
"Heater.OverTemp", DriverDataType.Boolean, false, null,
SecurityClassification.FreeAccess, false, IsAlarm: true));
ot.MarkAsAlarmCondition(new AlarmConditionInfo(
"Heater.OverTemp", AlarmSeverity.Critical, "Over-temperature"));
return Task.CompletedTask;
}
public void RaiseAlarm(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args);
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
IReadOnlyList<string> _, CancellationToken __)
=> Task.FromResult<IAlarmSubscriptionHandle>(new FakeHandle("sub"));
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle _, CancellationToken __)
=> Task.CompletedTask;
public Task AcknowledgeAsync(
IReadOnlyList<AlarmAcknowledgeRequest> _, CancellationToken __)
=> Task.CompletedTask;
}
private sealed class FakeHandle(string diagnosticId) : IAlarmSubscriptionHandle
{
public string DiagnosticId { get; } = diagnosticId;
}
}