From 33b0e639a5c00768a31ec56f45817119682b6748 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 26 Jun 2026 06:55:52 -0400 Subject: [PATCH] feat(otopcua): GeneralModelChangeEvent(NodeAdded) for runtime node adds --- .../OtOpcUaNodeManager.cs | 87 +++++++++++++ .../NodeManagerModelChangeOnAddTests.cs | 121 ++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerModelChangeOnAddTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 24f845c1..985a6aab 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -1567,6 +1567,93 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } } + /// + /// Emit a Part 3 GeneralModelChangeEvent (verb NodeAdded) announcing that one or more + /// nodes were added UNDER at runtime — so already-connected, + /// model-aware OPC UA clients re-browse the affected node and discover the new children. This is the + /// runtime-add counterpart of the shape-changed reporter (): + /// when a driver discovers FixedTree nodes AFTER the server is up and they are materialised into the + /// served Equipment address space (Tasks 4/5), an attribute notification alone is invisible to a + /// subscribed client — only a model-change event tells it the address space grew. + /// + /// Built AND reported under Lock (like ) and wrapped in + /// try/catch so it is tolerant when eventing is disabled / there are no monitored items / the + /// server is shutting down — the same swallow-and-log tolerance as the write-revert path + /// (). The nodes have already been materialised, so a surprise from + /// the event path MUST NOT propagate out of this announcement. + /// + /// + /// The folder-scoped node id of the parent under which nodes were added. + public void RaiseNodesAddedModelChange(string affectedNodeId) + { + ArgumentException.ThrowIfNullOrEmpty(affectedNodeId); + lock (Lock) + { + try + { + Server.ReportEvent(SystemContext, BuildNodesAddedModelChange(affectedNodeId)); + } + catch (Exception ex) + { + // Model-change reporting disabled / no monitored items / server shutting down ⇒ ReportEvent may + // no-op or throw; either way the node add already stands. Log to the SDK trace, don't rethrow. +#pragma warning disable CS0618 // Utils.LogError is [Obsolete] in favour of an ITelemetryContext this manager doesn't carry. + Utils.LogError(ex, "OtOpcUaNodeManager: failed to report GeneralModelChangeEvent(NodeAdded) for {0}", affectedNodeId); +#pragma warning restore CS0618 + } + } + } + + /// Build (but do not report) the Part 3 GeneralModelChangeEvent announcing that nodes were + /// added under . MIRRORS exactly — + /// the only differences are Verb = NodeAdded (vs DataTypeChanged) and Affected = the + /// passed parent node id (vs the variable's own NodeId). AffectedType carries the affected node's + /// TypeDefinition resolved from the live node maps (same semantics the shape-changed builder gets from the + /// variable), defaulting to when the id is not (yet) materialised. internal + /// (not private) so a node-manager test can assert the populated Changes structure at the nearest + /// deterministic seam (the end-to-end Server.ReportEvent dispatch would need a subscribed event + /// monitored-item to observe). + /// The folder-scoped node id of the parent under which nodes were added. + /// A populated, unreported . + internal GeneralModelChangeEventState BuildNodesAddedModelChange(string affectedNodeId) + { + var affected = new NodeId(affectedNodeId, NamespaceIndex); + var e = new GeneralModelChangeEventState(null); + e.Initialize( + SystemContext, + source: null, + severity: EventSeverity.Medium, + message: new LocalizedText($"Nodes added under {affected}")); + // Part 3 §8.7.4: a GeneralModelChangeEvent is emitted by the Server object — set SourceNode/SourceName + // to Server explicitly (we report with source:null since this manager has no Server NodeState handle), + // so conformant clients that filter events by SourceNode still match this one. + e.SetChildValue(SystemContext, BrowseNames.SourceNode, ObjectIds.Server, false); + e.SetChildValue(SystemContext, BrowseNames.SourceName, "Server", false); + var change = new ModelChangeStructureDataType + { + Affected = affected, + // The affected node is the parent the children were added under; carry its TypeDefinition (a Folder + // for an equipment parent) just as the shape-changed builder carries the variable's. Null when the + // id is unknown — a valid Part 3 "type not applicable", and clients re-browse Affected regardless. + AffectedType = ResolveAffectedTypeDefinition(affectedNodeId), + Verb = (byte)ModelChangeStructureVerbMask.NodeAdded, + }; + // SetChildValue lazily creates + sets the Changes property (same pattern the audit-event builder + // relies on for its child PropertyStates). + e.SetChildValue(SystemContext, BrowseNames.Changes, new[] { change }, false); + return e; + } + + /// Resolve the TypeDefinition of a materialised node id from the live folder/variable maps for a + /// model-change event's AffectedType; when the id is not registered. + /// The folder-scoped node id whose TypeDefinition is wanted. + private NodeId ResolveAffectedTypeDefinition(string nodeId) + { + if (_folders.TryGetValue(nodeId, out var folder)) return folder.TypeDefinitionId; + if (_variables.TryGetValue(nodeId, out var variable)) return variable.TypeDefinitionId; + return NodeId.Null; + } + /// Map a Tag.DataType string ("Boolean", "Int32", "Float", "Double", "String", /// "DateTime") to the OPC UA built-in NodeId. Unknown names fall back to BaseDataType /// (matches CreateVariable's default for lazy-created nodes). diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerModelChangeOnAddTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerModelChangeOnAddTests.cs new file mode 100644 index 00000000..1801794b --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerModelChangeOnAddTests.cs @@ -0,0 +1,121 @@ +using Opc.Ua; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// +/// FixedTree injection — the BEHAVIOURAL half of the runtime node-add model-change announcement. When a +/// driver discovers FixedTree nodes AFTER the server is up and they are materialised into the served +/// Equipment address space, already-connected OPC UA clients won't see them unless the server emits a +/// Part 3 GeneralModelChangeEvent (verb NodeAdded) under the affected parent so subscribed +/// clients refresh their browse. is that seam +/// (Tasks 4/5 call it after materialising discovered nodes); this test asserts: +/// +/// the built event announces the affected parent with verb NodeAdded (the runtime-add +/// counterpart of 's DataTypeChanged case); +/// raising it is tolerant — callable before AND after nodes exist, and never throws even when the +/// event path is disabled / has no monitored items. +/// +/// +/// Coverage boundary (deliberate, mirrors ): the +/// model-change event is asserted via its builder +/// () in isolation, not its end-to-end +/// Server.ReportEvent dispatch — observing that would require a subscribed event monitored-item. +/// The single in-lock report call-site is covered by inspection (it mirrors the shape-changed reporter). +/// +/// +public sealed class NodeManagerModelChangeOnAddTests : IDisposable +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private readonly string _pkiRoot = Path.Combine( + Path.GetTempPath(), + $"otopcua-modelchange-add-{Guid.NewGuid():N}"); + + /// The built model-change event announces the affected parent with verb NodeAdded and the parent's + /// TypeDefinition as AffectedType — what model-aware clients consume to re-browse the new children. + [Trait("Category", "Unit")] + [Fact] + public async Task Built_nodes_added_event_announces_the_affected_parent_with_NodeAdded_verb() + { + var (host, server) = await BootAsync(); + var nm = server.NodeManager!; + + nm.EnsureFolder("eq-7", parentNodeId: null, displayName: "Equipment 7"); + nm.EnsureVariable("eq-7/speed", parentFolderNodeId: "eq-7", displayName: "Speed", dataType: "Float", writable: false); + var parent = nm.TryGetFolder("eq-7")!; + + var e = nm.BuildNodesAddedModelChange("eq-7"); + + e.ShouldNotBeNull(); + e.Changes.ShouldNotBeNull(); + var changes = e.Changes.Value; + changes.Length.ShouldBe(1); + changes[0].Affected.ShouldBe(parent.NodeId); + changes[0].AffectedType.ShouldBe(ObjectTypeIds.FolderType); + changes[0].Verb.ShouldBe((byte)ModelChangeStructureVerbMask.NodeAdded); + + await host.DisposeAsync(); + } + + /// Raising the announcement is tolerant: callable before any nodes exist (unknown affected id ⇒ + /// AffectedType defaults to null, still a valid Part 3 change) AND after they are materialised, and never + /// throws even when the event path reaches no monitored items (same tolerance as the write-revert path). + [Trait("Category", "Unit")] + [Fact] + public async Task Raising_nodes_added_is_tolerant_before_and_after_nodes_exist() + { + var (host, server) = await BootAsync(); + var nm = server.NodeManager!; + + // Before any nodes exist under the parent — must not throw. + Should.NotThrow(() => nm.RaiseNodesAddedModelChange("eq-9")); + + nm.EnsureFolder("eq-9", parentNodeId: null, displayName: "Equipment 9"); + nm.EnsureVariable("eq-9/temp", parentFolderNodeId: "eq-9", displayName: "Temp", dataType: "Float", writable: false); + + // After the nodes are materialised — still must not throw. + Should.NotThrow(() => nm.RaiseNodesAddedModelChange("eq-9")); + + await host.DisposeAsync(); + } + + private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() + { + var host = new OpcUaApplicationHost( + new OpcUaApplicationHostOptions + { + ApplicationName = "OtOpcUa.ModelChangeOnAddTest", + ApplicationUri = $"urn:OtOpcUa.ModelChangeOnAddTest:{Guid.NewGuid():N}", + OpcUaPort = AllocateFreePort(), + PublicHostname = "localhost", + PkiStoreRoot = _pkiRoot, + }, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + + var server = new OtOpcUaSdkServer(); + await host.StartAsync(server, Ct); + return (host, server); + } + + private static int AllocateFreePort() + { + using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); + listener.Start(); + var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + /// Cleans up the PKI root directory. + public void Dispose() + { + if (Directory.Exists(_pkiRoot)) + { + try { Directory.Delete(_pkiRoot, recursive: true); } + catch { /* best-effort cleanup */ } + } + } +}