feat(opcua): #85 UNS Area/Line/Equipment folder hierarchy in SDK
Some checks failed
v2-ci / build (push) Failing after 42s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (push) Has been skipped

Phase7Composer now carries UnsAreaProjection + UnsLineProjection lists so
the applier can materialise the full UNS topology in the OPC UA address
space. New IOpcUaAddressSpaceSink.EnsureFolder(folderNodeId, parentNodeId,
displayName) seam (no-op default, recorded in tests, forwarded by
DeferredAddressSpaceSink, implemented by SdkAddressSpaceSink). The SDK-
side OtOpcUaNodeManager gains an EnsureFolder API that creates
FolderState nodes with proper parent linkage; RebuildAddressSpace now
clears folders too so re-applies don't accumulate stale topology.

Phase7Applier.MaterialiseHierarchy walks composition.UnsAreas →
composition.UnsLines → composition.EquipmentNodes, calling EnsureFolder
with the correct parent at each level. Idempotent — calling twice with
the same composition is a no-op. OpcUaPublishActor.HandleRebuild invokes
it after Phase7Applier.Apply so OPC UA clients browsing the server now
see Area/Line/Equipment as proper folders rather than flat tag ids.

DeploymentArtifact.ParseComposition reads UnsAreas + UnsLines from the
JSON snapshot the ControlPlane emits, populating the new fields when
present.

Phase7Composer.Compose now accepts UnsAreas + UnsLines; a 3-arg overload
preserves the old signature for legacy callers + existing tests. The
Phase7CompositionResult convenience ctor likewise keeps the planner
tests working without UNS data.

3 new hierarchy tests (pure unit + boot-verify against a real
OtOpcUaSdkServer); OpcUaServer suite is 48/48 green (was 45, +3),
Runtime 74/74 unchanged.

Closes #85.
This commit is contained in:
Joseph Doherty
2026-05-26 10:48:56 -04:00
parent 9d86287d08
commit 607dc51dec
14 changed files with 333 additions and 22 deletions

View File

@@ -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();
}

View File

@@ -14,6 +14,14 @@ public interface IOpcUaAddressSpaceSink
/// <summary>Write an alarm-condition Variable's active/acknowledged state.</summary>
void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc);
/// <summary>
/// Ensure a folder node exists under the given parent. Used by <c>Phase7Applier</c> to
/// materialise the UNS Area/Line/Equipment hierarchy in the address space. When
/// <paramref name="parentNodeId"/> is null the folder is parented under the namespace
/// root. Idempotent: calling twice with the same id is safe.
/// </summary>
void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName);
/// <summary>
/// Tear down + repopulate the address space. Called by <c>OpcUaPublishActor</c> 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() { }
}

View File

@@ -27,6 +27,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
public const string DefaultNamespaceUri = "https://zb.com/otopcua/ns";
private readonly ConcurrentDictionary<string, BaseDataVariableState> _variables = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, FolderState> _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;
/// <summary>
/// Apply a value write from <see cref="IOpcUaAddressSpaceSink.WriteValue"/>. Creates the
@@ -73,9 +75,43 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
}
/// <summary>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.</summary>
/// <summary>
/// Ensure a folder node exists at <paramref name="folderNodeId"/> with the given display
/// name, parented under <paramref name="parentNodeId"/> (or the namespace root when null).
/// #85 — used by <see cref="Phase7Applier"/> 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.
/// </summary>
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;
}
}
/// <summary>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.</summary>
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!;
}
/// <inheritdoc />
public override void CreateAddressSpace(IDictionary<NodeId, IList<IReference>> externalReferences)
{

View File

@@ -94,6 +94,43 @@ public sealed class Phase7Applier
return new Phase7ApplyOutcome(removedCount, addedCount, changedCount, needsRebuild);
}
/// <summary>
/// #85 — build the UNS Area/Line/Equipment folder hierarchy in the address space from a
/// composition snapshot. Called by <c>OpcUaPublishActor</c> after a rebuild so OPC UA
/// clients browsing the server see proper folder structure instead of flat tag ids.
/// Idempotent: each <c>EnsureFolder</c> call returns the existing folder if already
/// present, so re-applies are cheap.
/// </summary>
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); }

View File

@@ -2,12 +2,30 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// <summary>Outcome of <see cref="Phase7Composer.Compose"/> — pure value tuple, no side effects.</summary>
/// <summary>Outcome of <see cref="Phase7Composer.Compose"/> — pure value tuple, no side effects.
/// <see cref="UnsAreas"/> + <see cref="UnsLines"/> 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.</summary>
public sealed record Phase7CompositionResult(
IReadOnlyList<UnsAreaProjection> UnsAreas,
IReadOnlyList<UnsLineProjection> UnsLines,
IReadOnlyList<EquipmentNode> EquipmentNodes,
IReadOnlyList<DriverInstancePlan> DriverInstancePlans,
IReadOnlyList<ScriptedAlarmPlan> ScriptedAlarmPlans);
IReadOnlyList<ScriptedAlarmPlan> ScriptedAlarmPlans)
{
/// <summary>Convenience constructor for tests + earlier callers that don't yet carry UNS topology.</summary>
public Phase7CompositionResult(
IReadOnlyList<EquipmentNode> equipmentNodes,
IReadOnlyList<DriverInstancePlan> driverInstancePlans,
IReadOnlyList<ScriptedAlarmPlan> scriptedAlarmPlans)
: this(Array.Empty<UnsAreaProjection>(), Array.Empty<UnsLineProjection>(),
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 <c>Server.Phase7.Phase7Composer</c> (which mutates a server-side
/// node cache, emits trace logs, and calls into <c>EquipmentNodeWalker</c>) 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 (<see cref="UnsAreaProjection"/> +
/// <see cref="UnsLineProjection"/>) so <c>Phase7Applier</c> can build the
/// <c>Area/Line/Equipment</c> folder hierarchy in the SDK's address space. The legacy
/// <c>EquipmentNodeWalker</c> integration that did this server-side is fully replaced by the
/// (composer → applier → sink → node manager) chain.
/// </summary>
public static class Phase7Composer
{
/// <summary>Convenience overload for legacy callers + tests that don't yet supply UNS topology.</summary>
public static Phase7CompositionResult Compose(
IReadOnlyList<Equipment> equipment,
IReadOnlyList<DriverInstance> driverInstances,
IReadOnlyList<ScriptedAlarm> scriptedAlarms) =>
Compose(Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), equipment, driverInstances, scriptedAlarms);
public static Phase7CompositionResult Compose(
IReadOnlyList<UnsArea> unsAreas,
IReadOnlyList<UnsLine> unsLines,
IReadOnlyList<Equipment> equipment,
IReadOnlyList<DriverInstance> driverInstances,
IReadOnlyList<ScriptedAlarm> 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);
}
}

View File

@@ -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();
}

View File

@@ -89,34 +89,34 @@ public static class DeploymentArtifact
/// </summary>
public static Phase7CompositionResult ParseComposition(ReadOnlySpan<byte> blob)
{
if (blob.IsEmpty)
{
return new Phase7CompositionResult(
Array.Empty<EquipmentNode>(),
Array.Empty<DriverInstancePlan>(),
Array.Empty<ScriptedAlarmPlan>());
}
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<EquipmentNode>(),
Array.Empty<DriverInstancePlan>(),
Array.Empty<ScriptedAlarmPlan>());
return Empty();
}
}
private static Phase7CompositionResult Empty() => new(
Array.Empty<UnsAreaProjection>(),
Array.Empty<UnsLineProjection>(),
Array.Empty<EquipmentNode>(),
Array.Empty<DriverInstancePlan>(),
Array.Empty<ScriptedAlarmPlan>());
private static IReadOnlyList<T> ReadArray<T>(JsonElement root, string propertyName, Func<JsonElement, T?> reader)
where T : class
{
@@ -137,12 +137,31 @@ public static class DeploymentArtifact
private static string IdentityOf<T>(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;

View File

@@ -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<string, object?>("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);