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 */ }
+ }
+ }
+}