diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs
index f79258f..5cd8384 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs
@@ -27,5 +27,8 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
=> _inner.WriteAlarmState(alarmNodeId, active, acknowledged, sourceTimestampUtc);
+ public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
+ => _inner.EnsureFolder(folderNodeId, parentNodeId, displayName);
+
public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
}
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs
index 76ac52f..7082ddf 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs
@@ -14,6 +14,14 @@ public interface IOpcUaAddressSpaceSink
/// Write an alarm-condition Variable's active/acknowledged state.
void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc);
+ ///
+ /// Ensure a folder node exists under the given parent. Used by Phase7Applier to
+ /// materialise the UNS Area/Line/Equipment hierarchy in the address space. When
+ /// is null the folder is parented under the namespace
+ /// root. Idempotent: calling twice with the same id is safe.
+ ///
+ void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName);
+
///
/// Tear down + repopulate the address space. Called by OpcUaPublishActor after a
/// successful deployment apply so the node manager reflects the new config. Idempotent.
@@ -33,5 +41,6 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
private NullOpcUaAddressSpaceSink() { }
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) { }
+ public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
public void RebuildAddressSpace() { }
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
index b5ec9ea..b71af57 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
@@ -27,6 +27,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
public const string DefaultNamespaceUri = "https://zb.com/otopcua/ns";
private readonly ConcurrentDictionary _variables = new(StringComparer.Ordinal);
+ private readonly ConcurrentDictionary _folders = new(StringComparer.Ordinal);
private FolderState? _root;
public OtOpcUaNodeManager(IServerInternal server, ApplicationConfiguration configuration)
@@ -36,6 +37,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
public int VariableCount => _variables.Count;
+ public int FolderCount => _folders.Count;
///
/// Apply a value write from . Creates the
@@ -73,9 +75,43 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
}
- /// Clear every registered variable from the address space. Phase7Applier calls this
- /// when Equipment/Alarm topology changes; the populator then re-adds via WriteValue on the
- /// next pass.
+ ///
+ /// Ensure a folder node exists at with the given display
+ /// name, parented under (or the namespace root when null).
+ /// #85 — used by to materialise the UNS Area/Line/Equipment
+ /// folder hierarchy. Idempotent: the second call with the same id returns the cached
+ /// folder so adding child variables under it still works.
+ ///
+ public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(folderNodeId);
+ ArgumentException.ThrowIfNullOrEmpty(displayName);
+
+ if (_folders.ContainsKey(folderNodeId)) return;
+
+ lock (Lock)
+ {
+ if (_folders.ContainsKey(folderNodeId)) return;
+
+ var parent = ResolveParentFolder(parentNodeId);
+ var folder = new FolderState(parent)
+ {
+ NodeId = new NodeId(folderNodeId, NamespaceIndex),
+ BrowseName = new QualifiedName(folderNodeId, NamespaceIndex),
+ DisplayName = displayName,
+ EventNotifier = EventNotifiers.None,
+ TypeDefinitionId = ObjectTypeIds.FolderType,
+ ReferenceTypeId = ReferenceTypeIds.Organizes,
+ };
+ parent.AddChild(folder);
+ AddPredefinedNode(SystemContext, folder);
+ _folders[folderNodeId] = folder;
+ }
+ }
+
+ /// Clear every registered variable + folder from the address space. Phase7Applier
+ /// calls this when Equipment/Alarm topology changes; the populator then re-adds via
+ /// EnsureFolder + WriteValue on the next pass.
public void RebuildAddressSpace()
{
lock (Lock)
@@ -86,9 +122,22 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
PredefinedNodes?.Remove(v.NodeId);
}
_variables.Clear();
+
+ foreach (var f in _folders.Values)
+ {
+ f.Parent?.RemoveChild(f);
+ PredefinedNodes?.Remove(f.NodeId);
+ }
+ _folders.Clear();
}
}
+ private FolderState ResolveParentFolder(string? parentNodeId)
+ {
+ if (string.IsNullOrEmpty(parentNodeId)) return _root!;
+ return _folders.TryGetValue(parentNodeId, out var existing) ? existing : _root!;
+ }
+
///
public override void CreateAddressSpace(IDictionary> externalReferences)
{
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs
index 35ad7d2..172a30d 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs
@@ -94,6 +94,43 @@ public sealed class Phase7Applier
return new Phase7ApplyOutcome(removedCount, addedCount, changedCount, needsRebuild);
}
+ ///
+ /// #85 — build the UNS Area/Line/Equipment folder hierarchy in the address space from a
+ /// composition snapshot. Called by OpcUaPublishActor after a rebuild so OPC UA
+ /// clients browsing the server see proper folder structure instead of flat tag ids.
+ /// Idempotent: each EnsureFolder call returns the existing folder if already
+ /// present, so re-applies are cheap.
+ ///
+ public void MaterialiseHierarchy(Phase7CompositionResult composition)
+ {
+ ArgumentNullException.ThrowIfNull(composition);
+
+ foreach (var area in composition.UnsAreas)
+ {
+ SafeEnsureFolder(area.UnsAreaId, parentNodeId: null, displayName: area.DisplayName);
+ }
+ foreach (var line in composition.UnsLines)
+ {
+ SafeEnsureFolder(line.UnsLineId, parentNodeId: line.UnsAreaId, displayName: line.DisplayName);
+ }
+ foreach (var equipment in composition.EquipmentNodes)
+ {
+ // Equipment with no UnsLineId (legacy / dev rows) hang under the root.
+ var parent = string.IsNullOrWhiteSpace(equipment.UnsLineId) ? null : equipment.UnsLineId;
+ SafeEnsureFolder(equipment.EquipmentId, parentNodeId: parent, displayName: equipment.DisplayName);
+ }
+
+ _logger.LogInformation(
+ "Phase7Applier: hierarchy materialised (areas={Areas}, lines={Lines}, equipment={Equipment})",
+ composition.UnsAreas.Count, composition.UnsLines.Count, composition.EquipmentNodes.Count);
+ }
+
+ private void SafeEnsureFolder(string nodeId, string? parentNodeId, string displayName)
+ {
+ try { _sink.EnsureFolder(nodeId, parentNodeId, displayName); }
+ catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: EnsureFolder threw for {Node}", nodeId); }
+ }
+
private void SafeWriteAlarmState(string nodeId, bool active, bool acknowledged, DateTime ts)
{
try { _sink.WriteAlarmState(nodeId, active, acknowledged, ts); }
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs
index 4a3301c..bb23222 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs
@@ -2,12 +2,30 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
-/// Outcome of — pure value tuple, no side effects.
+/// Outcome of — pure value tuple, no side effects.
+/// + carry the UNS topology so the applier can
+/// materialise the Area/Line/Equipment folder hierarchy in the address space; equipment carries
+/// its parent line id so the applier knows where to hang each equipment folder.
public sealed record Phase7CompositionResult(
+ IReadOnlyList UnsAreas,
+ IReadOnlyList UnsLines,
IReadOnlyList EquipmentNodes,
IReadOnlyList DriverInstancePlans,
- IReadOnlyList ScriptedAlarmPlans);
+ IReadOnlyList ScriptedAlarmPlans)
+{
+ /// Convenience constructor for tests + earlier callers that don't yet carry UNS topology.
+ public Phase7CompositionResult(
+ IReadOnlyList equipmentNodes,
+ IReadOnlyList driverInstancePlans,
+ IReadOnlyList scriptedAlarmPlans)
+ : this(Array.Empty(), Array.Empty(),
+ equipmentNodes, driverInstancePlans, scriptedAlarmPlans)
+ {
+ }
+}
+public sealed record UnsAreaProjection(string UnsAreaId, string DisplayName);
+public sealed record UnsLineProjection(string UnsLineId, string UnsAreaId, string DisplayName);
public sealed record EquipmentNode(string EquipmentId, string DisplayName, string UnsLineId);
public sealed record DriverInstancePlan(string DriverInstanceId, string DriverType, string ConfigJson);
public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentId, string PredicateScriptId, string MessageTemplate);
@@ -17,18 +35,38 @@ public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentI
/// driver-role host needs. Same inputs → same outputs, no logging, no DB writes. The driver-role
/// startup (Task 53) consumes the result and hands it to the node-manager factory.
///
-/// Full migration of the legacy Server.Phase7.Phase7Composer (which mutates a server-side
-/// node cache, emits trace logs, and calls into EquipmentNodeWalker) is tracked as
-/// follow-up F14. This pure version handles the projection step; the side-effecting wiring
-/// stays in the legacy code until F14 lands.
+/// #85 — the composer now carries UNS topology ( +
+/// ) so Phase7Applier can build the
+/// Area/Line/Equipment folder hierarchy in the SDK's address space. The legacy
+/// EquipmentNodeWalker integration that did this server-side is fully replaced by the
+/// (composer → applier → sink → node manager) chain.
///
public static class Phase7Composer
{
+ /// Convenience overload for legacy callers + tests that don't yet supply UNS topology.
public static Phase7CompositionResult Compose(
+ IReadOnlyList equipment,
+ IReadOnlyList driverInstances,
+ IReadOnlyList scriptedAlarms) =>
+ Compose(Array.Empty(), Array.Empty(), equipment, driverInstances, scriptedAlarms);
+
+ public static Phase7CompositionResult Compose(
+ IReadOnlyList unsAreas,
+ IReadOnlyList unsLines,
IReadOnlyList equipment,
IReadOnlyList driverInstances,
IReadOnlyList scriptedAlarms)
{
+ var areas = unsAreas
+ .OrderBy(a => a.UnsAreaId, StringComparer.Ordinal)
+ .Select(a => new UnsAreaProjection(a.UnsAreaId, a.Name))
+ .ToList();
+
+ var lines = unsLines
+ .OrderBy(l => l.UnsLineId, StringComparer.Ordinal)
+ .Select(l => new UnsLineProjection(l.UnsLineId, l.UnsAreaId, l.Name))
+ .ToList();
+
var nodes = equipment
.OrderBy(e => e.EquipmentId, StringComparer.Ordinal)
.Select(e => new EquipmentNode(e.EquipmentId, e.MachineCode, e.UnsLineId))
@@ -44,6 +82,6 @@ public static class Phase7Composer
.Select(a => new ScriptedAlarmPlan(a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId, a.MessageTemplate))
.ToList();
- return new Phase7CompositionResult(nodes, plans, alarms);
+ return new Phase7CompositionResult(areas, lines, nodes, plans, alarms);
}
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs
index cd18d98..8395816 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs
@@ -24,5 +24,8 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
=> _nodeManager.WriteAlarmState(alarmNodeId, active, acknowledged, sourceTimestampUtc);
+ public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
+ => _nodeManager.EnsureFolder(folderNodeId, parentNodeId, displayName);
+
public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace();
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs
index 8cfecd8..b2a8d12 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs
@@ -89,34 +89,34 @@ public static class DeploymentArtifact
///
public static Phase7CompositionResult ParseComposition(ReadOnlySpan blob)
{
- if (blob.IsEmpty)
- {
- return new Phase7CompositionResult(
- Array.Empty(),
- Array.Empty(),
- Array.Empty());
- }
+ if (blob.IsEmpty) return Empty();
try
{
using var doc = JsonDocument.Parse(blob.ToArray());
var root = doc.RootElement;
+ var areas = ReadArray(root, "UnsAreas", ReadAreaProjection);
+ var lines = ReadArray(root, "UnsLines", ReadLineProjection);
var equipment = ReadArray(root, "Equipment", ReadEquipmentNode);
var drivers = ReadArray(root, "DriverInstances", ReadDriverPlan);
var alarms = ReadArray(root, "ScriptedAlarms", ReadAlarmPlan);
- return new Phase7CompositionResult(equipment, drivers, alarms);
+ return new Phase7CompositionResult(areas, lines, equipment, drivers, alarms);
}
catch (JsonException)
{
- return new Phase7CompositionResult(
- Array.Empty(),
- Array.Empty(),
- Array.Empty());
+ return Empty();
}
}
+ private static Phase7CompositionResult Empty() => new(
+ Array.Empty(),
+ Array.Empty(),
+ Array.Empty(),
+ Array.Empty(),
+ Array.Empty());
+
private static IReadOnlyList ReadArray(JsonElement root, string propertyName, Func reader)
where T : class
{
@@ -137,12 +137,31 @@ public static class DeploymentArtifact
private static string IdentityOf(T item) where T : class => item switch
{
+ UnsAreaProjection a => a.UnsAreaId,
+ UnsLineProjection l => l.UnsLineId,
EquipmentNode e => e.EquipmentId,
DriverInstancePlan d => d.DriverInstanceId,
ScriptedAlarmPlan a => a.ScriptedAlarmId,
_ => string.Empty,
};
+ private static UnsAreaProjection? ReadAreaProjection(JsonElement el)
+ {
+ var id = el.TryGetProperty("UnsAreaId", out var idEl) ? idEl.GetString() : null;
+ var name = el.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : null;
+ if (string.IsNullOrWhiteSpace(id)) return null;
+ return new UnsAreaProjection(id!, name ?? id!);
+ }
+
+ private static UnsLineProjection? ReadLineProjection(JsonElement el)
+ {
+ var id = el.TryGetProperty("UnsLineId", out var idEl) ? idEl.GetString() : null;
+ var areaId = el.TryGetProperty("UnsAreaId", out var areaEl) ? areaEl.GetString() : null;
+ var name = el.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : null;
+ if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(areaId)) return null;
+ return new UnsLineProjection(id!, areaId!, name ?? id!);
+ }
+
private static EquipmentNode? ReadEquipmentNode(JsonElement el)
{
var id = el.TryGetProperty("EquipmentId", out var idEl) ? idEl.GetString() : null;
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs
index 1ef9ae7..e2187e9 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs
@@ -185,6 +185,12 @@ public sealed class OpcUaPublishActor : ReceiveActor
var outcome = _applier.Apply(plan);
_lastApplied = composition;
+
+ // #85 — after the plan diff lands, rebuild the UNS folder hierarchy so OPC UA
+ // clients see Area/Line/Equipment as proper folders. Idempotent; Phase7Applier
+ // skips folders that already exist with the same node id.
+ _applier.MaterialiseHierarchy(composition);
+
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair("kind", "rebuild"));
_log.Info("OpcUaPublish: applied rebuild (correlation={Correlation}, added={Added}, removed={Removed}, changed={Changed}, rebuild={Rebuild})",
msg.Correlation, outcome.AddedNodes, outcome.RemovedNodes, outcome.ChangedNodes, outcome.RebuildCalled);
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs
index 84b5996..fc9dee2 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs
@@ -72,6 +72,8 @@ public sealed class DeferredAddressSpaceSinkTests
=> CallQueue.Enqueue($"WV:{nodeId}");
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
=> CallQueue.Enqueue($"WA:{alarmNodeId}");
+ public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
+ => CallQueue.Enqueue($"EF:{folderNodeId}");
public void RebuildAddressSpace() => CallQueue.Enqueue("RB");
}
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs
new file mode 100644
index 0000000..09c37d5
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs
@@ -0,0 +1,135 @@
+using System.Collections.Concurrent;
+using Microsoft.Extensions.Logging.Abstractions;
+using Opc.Ua.Server;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
+
+namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
+
+///
+/// #85 — verifies builds the UNS
+/// Area/Line/Equipment folder tree through .
+/// One pure unit test (recording sink) confirms ordering + parenting; one boot-verify test
+/// drives a real and inspects the resulting predefined-node
+/// count to prove the folders land in the SDK address space.
+///
+public sealed class Phase7ApplierHierarchyTests : IDisposable
+{
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ private readonly string _pkiRoot = Path.Combine(
+ Path.GetTempPath(),
+ $"otopcua-pki-{Guid.NewGuid():N}");
+
+ [Fact]
+ public void MaterialiseHierarchy_creates_areas_then_lines_then_equipment_with_correct_parents()
+ {
+ var sink = new RecordingFolderSink();
+ var applier = new Phase7Applier(sink, NullLogger.Instance);
+
+ var composition = new Phase7CompositionResult(
+ UnsAreas: new[] { new UnsAreaProjection("area-1", "Plant North") },
+ UnsLines: new[] { new UnsLineProjection("line-1", "area-1", "Cell A") },
+ EquipmentNodes: new[] { new EquipmentNode("eq-1", "Pump-1", "line-1") },
+ DriverInstancePlans: Array.Empty(),
+ ScriptedAlarmPlans: Array.Empty());
+
+ applier.MaterialiseHierarchy(composition);
+
+ var calls = sink.Calls;
+ calls.Count.ShouldBe(3);
+ calls[0].ShouldBe(("area-1", null, "Plant North"));
+ calls[1].ShouldBe(("line-1", "area-1", "Cell A"));
+ calls[2].ShouldBe(("eq-1", "line-1", "Pump-1"));
+ }
+
+ [Fact]
+ public void MaterialiseHierarchy_orphan_equipment_hangs_under_root()
+ {
+ var sink = new RecordingFolderSink();
+ var applier = new Phase7Applier(sink, NullLogger.Instance);
+
+ var composition = new Phase7CompositionResult(
+ UnsAreas: Array.Empty(),
+ UnsLines: Array.Empty(),
+ EquipmentNodes: new[] { new EquipmentNode("eq-orphan", "Orphan", UnsLineId: "") },
+ DriverInstancePlans: Array.Empty(),
+ ScriptedAlarmPlans: Array.Empty());
+
+ applier.MaterialiseHierarchy(composition);
+
+ sink.Calls.Single().ShouldBe(("eq-orphan", null, "Orphan"));
+ }
+
+ [Fact]
+ public async Task MaterialiseHierarchy_against_real_SDK_node_manager_creates_folder_nodes()
+ {
+ await using var host = new OpcUaApplicationHost(
+ new OpcUaApplicationHostOptions
+ {
+ ApplicationName = "OtOpcUa.Hierarchy",
+ ApplicationUri = $"urn:OtOpcUa.Hierarchy:{Guid.NewGuid():N}",
+ OpcUaPort = AllocateFreePort(),
+ PublicHostname = "localhost",
+ PkiStoreRoot = _pkiRoot,
+ },
+ NullLogger.Instance);
+
+ var sdkServer = new OtOpcUaSdkServer();
+ await host.StartAsync(sdkServer, Ct);
+ sdkServer.NodeManager.ShouldNotBeNull();
+
+ var sink = new SdkAddressSpaceSink(sdkServer.NodeManager!);
+ var applier = new Phase7Applier(sink, NullLogger.Instance);
+
+ applier.MaterialiseHierarchy(new Phase7CompositionResult(
+ UnsAreas: new[] { new UnsAreaProjection("area-A", "Area A"), new UnsAreaProjection("area-B", "Area B") },
+ UnsLines: new[] { new UnsLineProjection("line-1", "area-A", "Line 1") },
+ EquipmentNodes: new[] { new EquipmentNode("eq-1", "Eq 1", "line-1"), new EquipmentNode("eq-2", "Eq 2", "line-1") },
+ DriverInstancePlans: Array.Empty(),
+ ScriptedAlarmPlans: Array.Empty()));
+
+ sdkServer.NodeManager!.FolderCount.ShouldBe(5); // 2 areas + 1 line + 2 equipment
+
+ // Idempotent: re-applying with the same composition doesn't create duplicates.
+ applier.MaterialiseHierarchy(new Phase7CompositionResult(
+ UnsAreas: new[] { new UnsAreaProjection("area-A", "Area A"), new UnsAreaProjection("area-B", "Area B") },
+ UnsLines: new[] { new UnsLineProjection("line-1", "area-A", "Line 1") },
+ EquipmentNodes: new[] { new EquipmentNode("eq-1", "Eq 1", "line-1"), new EquipmentNode("eq-2", "Eq 2", "line-1") },
+ DriverInstancePlans: Array.Empty(),
+ ScriptedAlarmPlans: Array.Empty()));
+
+ sdkServer.NodeManager!.FolderCount.ShouldBe(5);
+ }
+
+ 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;
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_pkiRoot))
+ {
+ try { Directory.Delete(_pkiRoot, recursive: true); }
+ catch { /* best-effort */ }
+ }
+ }
+
+ private sealed class RecordingFolderSink : IOpcUaAddressSpaceSink
+ {
+ private readonly ConcurrentQueue<(string NodeId, string? Parent, string DisplayName)> _calls = new();
+ public List<(string NodeId, string? Parent, string DisplayName)> Calls => _calls.ToList();
+
+ public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
+ public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) { }
+ public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
+ => _calls.Enqueue((folderNodeId, parentNodeId, displayName));
+ public void RebuildAddressSpace() { }
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs
index 9a85fd2..f866c4f 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs
@@ -125,13 +125,17 @@ public sealed class Phase7ApplierTests
private sealed class RecordingSink : IOpcUaAddressSpaceSink
{
public ConcurrentQueue<(string NodeId, bool Active, bool Acknowledged)> AlarmQueue { get; } = new();
+ public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName)> FolderQueue { get; } = new();
public int RebuildCalls;
public List<(string NodeId, bool Active, bool Acknowledged)> AlarmWrites => AlarmQueue.ToList();
+ public List<(string NodeId, string? Parent, string DisplayName)> FolderCalls => FolderQueue.ToList();
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
=> AlarmQueue.Enqueue((alarmNodeId, active, acknowledged));
+ public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
+ => FolderQueue.Enqueue((folderNodeId, parentNodeId, displayName));
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
}
@@ -145,6 +149,7 @@ public sealed class Phase7ApplierTests
{
if (_throwOnAlarmWrite) throw new InvalidOperationException("simulated sink fault");
}
+ public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
public void RebuildAddressSpace() { }
}
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs
index 462d609..13b5d88 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs
@@ -172,6 +172,7 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
public int Writes { get; private set; }
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) => Writes++;
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime occurredUtc) => Writes++;
+ public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
public void RebuildAddressSpace() { /* recorded via span */ }
}
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs
index b87b51e..9bc47e8 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs
@@ -139,6 +139,8 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
=> Calls.Enqueue($"WV:{nodeId}");
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime ts)
=> Calls.Enqueue($"WA:{alarmNodeId}");
+ public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
+ => Calls.Enqueue($"EF:{folderNodeId}");
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
}
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs
index 006a5b1..5adcf39 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs
@@ -152,6 +152,8 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime ts) =>
AlarmQueue.Enqueue((alarmNodeId, active, acknowledged, ts));
+ public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
+
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
}