feat(opcua): #85 UNS Area/Line/Equipment folder hierarchy in SDK
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
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:
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user