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
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:
@@ -27,5 +27,8 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink
|
|||||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
|
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
|
||||||
=> _inner.WriteAlarmState(alarmNodeId, active, acknowledged, 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();
|
public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ public interface IOpcUaAddressSpaceSink
|
|||||||
/// <summary>Write an alarm-condition Variable's active/acknowledged state.</summary>
|
/// <summary>Write an alarm-condition Variable's active/acknowledged state.</summary>
|
||||||
void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc);
|
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>
|
/// <summary>
|
||||||
/// Tear down + repopulate the address space. Called by <c>OpcUaPublishActor</c> after a
|
/// 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.
|
/// successful deployment apply so the node manager reflects the new config. Idempotent.
|
||||||
@@ -33,5 +41,6 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
|
|||||||
private NullOpcUaAddressSpaceSink() { }
|
private NullOpcUaAddressSpaceSink() { }
|
||||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
||||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, 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() { }
|
public void RebuildAddressSpace() { }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
public const string DefaultNamespaceUri = "https://zb.com/otopcua/ns";
|
public const string DefaultNamespaceUri = "https://zb.com/otopcua/ns";
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<string, BaseDataVariableState> _variables = new(StringComparer.Ordinal);
|
private readonly ConcurrentDictionary<string, BaseDataVariableState> _variables = new(StringComparer.Ordinal);
|
||||||
|
private readonly ConcurrentDictionary<string, FolderState> _folders = new(StringComparer.Ordinal);
|
||||||
private FolderState? _root;
|
private FolderState? _root;
|
||||||
|
|
||||||
public OtOpcUaNodeManager(IServerInternal server, ApplicationConfiguration configuration)
|
public OtOpcUaNodeManager(IServerInternal server, ApplicationConfiguration configuration)
|
||||||
@@ -36,6 +37,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
}
|
}
|
||||||
|
|
||||||
public int VariableCount => _variables.Count;
|
public int VariableCount => _variables.Count;
|
||||||
|
public int FolderCount => _folders.Count;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Apply a value write from <see cref="IOpcUaAddressSpaceSink.WriteValue"/>. Creates the
|
/// 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
|
/// <summary>
|
||||||
/// when Equipment/Alarm topology changes; the populator then re-adds via WriteValue on the
|
/// Ensure a folder node exists at <paramref name="folderNodeId"/> with the given display
|
||||||
/// next pass.</summary>
|
/// 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()
|
public void RebuildAddressSpace()
|
||||||
{
|
{
|
||||||
lock (Lock)
|
lock (Lock)
|
||||||
@@ -86,7 +122,20 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
PredefinedNodes?.Remove(v.NodeId);
|
PredefinedNodes?.Remove(v.NodeId);
|
||||||
}
|
}
|
||||||
_variables.Clear();
|
_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 />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -94,6 +94,43 @@ public sealed class Phase7Applier
|
|||||||
return new Phase7ApplyOutcome(removedCount, addedCount, changedCount, needsRebuild);
|
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)
|
private void SafeWriteAlarmState(string nodeId, bool active, bool acknowledged, DateTime ts)
|
||||||
{
|
{
|
||||||
try { _sink.WriteAlarmState(nodeId, active, acknowledged, 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;
|
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(
|
public sealed record Phase7CompositionResult(
|
||||||
|
IReadOnlyList<UnsAreaProjection> UnsAreas,
|
||||||
|
IReadOnlyList<UnsLineProjection> UnsLines,
|
||||||
IReadOnlyList<EquipmentNode> EquipmentNodes,
|
IReadOnlyList<EquipmentNode> EquipmentNodes,
|
||||||
IReadOnlyList<DriverInstancePlan> DriverInstancePlans,
|
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 EquipmentNode(string EquipmentId, string DisplayName, string UnsLineId);
|
||||||
public sealed record DriverInstancePlan(string DriverInstanceId, string DriverType, string ConfigJson);
|
public sealed record DriverInstancePlan(string DriverInstanceId, string DriverType, string ConfigJson);
|
||||||
public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentId, string PredicateScriptId, string MessageTemplate);
|
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
|
/// 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.
|
/// 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
|
/// #85 — the composer now carries UNS topology (<see cref="UnsAreaProjection"/> +
|
||||||
/// node cache, emits trace logs, and calls into <c>EquipmentNodeWalker</c>) is tracked as
|
/// <see cref="UnsLineProjection"/>) so <c>Phase7Applier</c> can build the
|
||||||
/// follow-up F14. This pure version handles the projection step; the side-effecting wiring
|
/// <c>Area/Line/Equipment</c> folder hierarchy in the SDK's address space. The legacy
|
||||||
/// stays in the legacy code until F14 lands.
|
/// <c>EquipmentNodeWalker</c> integration that did this server-side is fully replaced by the
|
||||||
|
/// (composer → applier → sink → node manager) chain.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class Phase7Composer
|
public static class Phase7Composer
|
||||||
{
|
{
|
||||||
|
/// <summary>Convenience overload for legacy callers + tests that don't yet supply UNS topology.</summary>
|
||||||
public static Phase7CompositionResult Compose(
|
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<Equipment> equipment,
|
||||||
IReadOnlyList<DriverInstance> driverInstances,
|
IReadOnlyList<DriverInstance> driverInstances,
|
||||||
IReadOnlyList<ScriptedAlarm> scriptedAlarms)
|
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
|
var nodes = equipment
|
||||||
.OrderBy(e => e.EquipmentId, StringComparer.Ordinal)
|
.OrderBy(e => e.EquipmentId, StringComparer.Ordinal)
|
||||||
.Select(e => new EquipmentNode(e.EquipmentId, e.MachineCode, e.UnsLineId))
|
.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))
|
.Select(a => new ScriptedAlarmPlan(a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId, a.MessageTemplate))
|
||||||
.ToList();
|
.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)
|
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
|
||||||
=> _nodeManager.WriteAlarmState(alarmNodeId, active, acknowledged, 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();
|
public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,33 +89,33 @@ public static class DeploymentArtifact
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static Phase7CompositionResult ParseComposition(ReadOnlySpan<byte> blob)
|
public static Phase7CompositionResult ParseComposition(ReadOnlySpan<byte> blob)
|
||||||
{
|
{
|
||||||
if (blob.IsEmpty)
|
if (blob.IsEmpty) return Empty();
|
||||||
{
|
|
||||||
return new Phase7CompositionResult(
|
|
||||||
Array.Empty<EquipmentNode>(),
|
|
||||||
Array.Empty<DriverInstancePlan>(),
|
|
||||||
Array.Empty<ScriptedAlarmPlan>());
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var doc = JsonDocument.Parse(blob.ToArray());
|
using var doc = JsonDocument.Parse(blob.ToArray());
|
||||||
var root = doc.RootElement;
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
var areas = ReadArray(root, "UnsAreas", ReadAreaProjection);
|
||||||
|
var lines = ReadArray(root, "UnsLines", ReadLineProjection);
|
||||||
var equipment = ReadArray(root, "Equipment", ReadEquipmentNode);
|
var equipment = ReadArray(root, "Equipment", ReadEquipmentNode);
|
||||||
var drivers = ReadArray(root, "DriverInstances", ReadDriverPlan);
|
var drivers = ReadArray(root, "DriverInstances", ReadDriverPlan);
|
||||||
var alarms = ReadArray(root, "ScriptedAlarms", ReadAlarmPlan);
|
var alarms = ReadArray(root, "ScriptedAlarms", ReadAlarmPlan);
|
||||||
|
|
||||||
return new Phase7CompositionResult(equipment, drivers, alarms);
|
return new Phase7CompositionResult(areas, lines, equipment, drivers, alarms);
|
||||||
}
|
}
|
||||||
catch (JsonException)
|
catch (JsonException)
|
||||||
{
|
{
|
||||||
return new Phase7CompositionResult(
|
return Empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Phase7CompositionResult Empty() => new(
|
||||||
|
Array.Empty<UnsAreaProjection>(),
|
||||||
|
Array.Empty<UnsLineProjection>(),
|
||||||
Array.Empty<EquipmentNode>(),
|
Array.Empty<EquipmentNode>(),
|
||||||
Array.Empty<DriverInstancePlan>(),
|
Array.Empty<DriverInstancePlan>(),
|
||||||
Array.Empty<ScriptedAlarmPlan>());
|
Array.Empty<ScriptedAlarmPlan>());
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IReadOnlyList<T> ReadArray<T>(JsonElement root, string propertyName, Func<JsonElement, T?> reader)
|
private static IReadOnlyList<T> ReadArray<T>(JsonElement root, string propertyName, Func<JsonElement, T?> reader)
|
||||||
where T : class
|
where T : class
|
||||||
@@ -137,12 +137,31 @@ public static class DeploymentArtifact
|
|||||||
|
|
||||||
private static string IdentityOf<T>(T item) where T : class => item switch
|
private static string IdentityOf<T>(T item) where T : class => item switch
|
||||||
{
|
{
|
||||||
|
UnsAreaProjection a => a.UnsAreaId,
|
||||||
|
UnsLineProjection l => l.UnsLineId,
|
||||||
EquipmentNode e => e.EquipmentId,
|
EquipmentNode e => e.EquipmentId,
|
||||||
DriverInstancePlan d => d.DriverInstanceId,
|
DriverInstancePlan d => d.DriverInstanceId,
|
||||||
ScriptedAlarmPlan a => a.ScriptedAlarmId,
|
ScriptedAlarmPlan a => a.ScriptedAlarmId,
|
||||||
_ => string.Empty,
|
_ => 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)
|
private static EquipmentNode? ReadEquipmentNode(JsonElement el)
|
||||||
{
|
{
|
||||||
var id = el.TryGetProperty("EquipmentId", out var idEl) ? idEl.GetString() : null;
|
var id = el.TryGetProperty("EquipmentId", out var idEl) ? idEl.GetString() : null;
|
||||||
|
|||||||
@@ -185,6 +185,12 @@ public sealed class OpcUaPublishActor : ReceiveActor
|
|||||||
|
|
||||||
var outcome = _applier.Apply(plan);
|
var outcome = _applier.Apply(plan);
|
||||||
_lastApplied = composition;
|
_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"));
|
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})",
|
_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);
|
msg.Correlation, outcome.AddedNodes, outcome.RemovedNodes, outcome.ChangedNodes, outcome.RebuildCalled);
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ public sealed class DeferredAddressSpaceSinkTests
|
|||||||
=> CallQueue.Enqueue($"WV:{nodeId}");
|
=> CallQueue.Enqueue($"WV:{nodeId}");
|
||||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
|
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
|
||||||
=> CallQueue.Enqueue($"WA:{alarmNodeId}");
|
=> CallQueue.Enqueue($"WA:{alarmNodeId}");
|
||||||
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
||||||
|
=> CallQueue.Enqueue($"EF:{folderNodeId}");
|
||||||
public void RebuildAddressSpace() => CallQueue.Enqueue("RB");
|
public void RebuildAddressSpace() => CallQueue.Enqueue("RB");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #85 — verifies <see cref="Phase7Applier.MaterialiseHierarchy"/> builds the UNS
|
||||||
|
/// Area/Line/Equipment folder tree through <see cref="IOpcUaAddressSpaceSink.EnsureFolder"/>.
|
||||||
|
/// One pure unit test (recording sink) confirms ordering + parenting; one boot-verify test
|
||||||
|
/// drives a real <see cref="OtOpcUaNodeManager"/> and inspects the resulting predefined-node
|
||||||
|
/// count to prove the folders land in the SDK address space.
|
||||||
|
/// </summary>
|
||||||
|
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<Phase7Applier>.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<DriverInstancePlan>(),
|
||||||
|
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
|
||||||
|
|
||||||
|
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<Phase7Applier>.Instance);
|
||||||
|
|
||||||
|
var composition = new Phase7CompositionResult(
|
||||||
|
UnsAreas: Array.Empty<UnsAreaProjection>(),
|
||||||
|
UnsLines: Array.Empty<UnsLineProjection>(),
|
||||||
|
EquipmentNodes: new[] { new EquipmentNode("eq-orphan", "Orphan", UnsLineId: "") },
|
||||||
|
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
|
||||||
|
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
|
||||||
|
|
||||||
|
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<OpcUaApplicationHost>.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<Phase7Applier>.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<DriverInstancePlan>(),
|
||||||
|
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>()));
|
||||||
|
|
||||||
|
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<DriverInstancePlan>(),
|
||||||
|
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>()));
|
||||||
|
|
||||||
|
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() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -125,13 +125,17 @@ public sealed class Phase7ApplierTests
|
|||||||
private sealed class RecordingSink : IOpcUaAddressSpaceSink
|
private sealed class RecordingSink : IOpcUaAddressSpaceSink
|
||||||
{
|
{
|
||||||
public ConcurrentQueue<(string NodeId, bool Active, bool Acknowledged)> AlarmQueue { get; } = new();
|
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 int RebuildCalls;
|
||||||
|
|
||||||
public List<(string NodeId, bool Active, bool Acknowledged)> AlarmWrites => AlarmQueue.ToList();
|
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 WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
||||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
|
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
|
||||||
=> AlarmQueue.Enqueue((alarmNodeId, active, acknowledged));
|
=> 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);
|
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +149,7 @@ public sealed class Phase7ApplierTests
|
|||||||
{
|
{
|
||||||
if (_throwOnAlarmWrite) throw new InvalidOperationException("simulated sink fault");
|
if (_throwOnAlarmWrite) throw new InvalidOperationException("simulated sink fault");
|
||||||
}
|
}
|
||||||
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
|
||||||
public void RebuildAddressSpace() { }
|
public void RebuildAddressSpace() { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
|
|||||||
public int Writes { get; private set; }
|
public int Writes { get; private set; }
|
||||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) => Writes++;
|
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 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 */ }
|
public void RebuildAddressSpace() { /* recorded via span */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,6 +139,8 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
|||||||
=> Calls.Enqueue($"WV:{nodeId}");
|
=> Calls.Enqueue($"WV:{nodeId}");
|
||||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime ts)
|
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime ts)
|
||||||
=> Calls.Enqueue($"WA:{alarmNodeId}");
|
=> Calls.Enqueue($"WA:{alarmNodeId}");
|
||||||
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
||||||
|
=> Calls.Enqueue($"EF:{folderNodeId}");
|
||||||
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,6 +152,8 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
|
|||||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime ts) =>
|
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime ts) =>
|
||||||
AlarmQueue.Enqueue((alarmNodeId, active, acknowledged, ts));
|
AlarmQueue.Enqueue((alarmNodeId, active, acknowledged, ts));
|
||||||
|
|
||||||
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
|
||||||
|
|
||||||
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user