feat(opcua): materialise Equipment-namespace tags in the live rebuild
Add Phase7Applier.MaterialiseEquipmentTags — a sink-based pass (Task-0 decision A) that ensures each EquipmentTagPlan's Variable (NodeId = FullName) under its existing equipment folder, nesting any FolderPath as a sub-folder. Wire it into OpcUaPublishActor.HandleRebuild after the Galaxy pass. Variables start BadWaitingForInitialData; never re-creates equipment folders (decision #4).
This commit is contained in:
@@ -172,6 +172,67 @@ public sealed class Phase7Applier
|
||||
composition.GalaxyTags.Count, foldersCreated.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Materialise Equipment-namespace tags from a composition snapshot — the equipment-signal
|
||||
/// analogue of <see cref="MaterialiseGalaxyTags"/>. For each <see cref="EquipmentTagPlan"/>,
|
||||
/// ensure its optional <c>FolderPath</c> sub-folder under the existing equipment folder, then
|
||||
/// ensure a Variable (NodeId = <c>FullName</c>, the driver-side ref) inside it. Variables
|
||||
/// start BadWaitingForInitialData; the driver fills live values in a later milestone.
|
||||
/// Idempotent.
|
||||
/// <para>
|
||||
/// <b>Task 0 architecture decisions (recorded per the equipment-namespace-structure
|
||||
/// plan).</b> Decision #1 = <b>A</b> — a sink-based pass, NOT a reuse of
|
||||
/// <c>EquipmentNodeWalker</c>: no sink-backed <c>IAddressSpaceBuilder</c> adapter exists
|
||||
/// (<c>GenericDriverNodeManager.CapturingBuilder</c> decorates another builder, not the
|
||||
/// sink), and the walker re-creates the whole Area/Line/Equipment tree with browse-path
|
||||
/// NodeIds — incompatible with this path's logical-Id NodeIds (decision #3) and the
|
||||
/// already-materialised equipment folders (decision #4). Decision #4 = this pass adds
|
||||
/// ONLY variables (and any per-tag sub-folder); <see cref="MaterialiseHierarchy"/> owns
|
||||
/// the equipment folders and this pass never re-creates them. The sink's
|
||||
/// <c>EnsureVariable</c> takes a plain <c>string dataType</c> (not a DriverAttributeInfo).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="composition">The composition result containing the equipment tags to materialise.</param>
|
||||
public void MaterialiseEquipmentTags(Phase7CompositionResult composition)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(composition);
|
||||
if (composition.EquipmentTags.Count == 0) return;
|
||||
|
||||
// Sub-folders first — a tag's FolderPath becomes one folder UNDER its equipment folder
|
||||
// (deduped per distinct equipment+path). Tags with no FolderPath hang directly under the
|
||||
// equipment folder, which MaterialiseHierarchy already created (decision #4: never re-create
|
||||
// the equipment folder here).
|
||||
var foldersCreated = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var tag in composition.EquipmentTags)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tag.FolderPath)) continue;
|
||||
var folderNodeId = EquipmentSubFolderNodeId(tag.EquipmentId, tag.FolderPath);
|
||||
if (!foldersCreated.Add(folderNodeId)) continue;
|
||||
SafeEnsureFolder(folderNodeId, parentNodeId: tag.EquipmentId, displayName: tag.FolderPath);
|
||||
}
|
||||
|
||||
// Variables: NodeId = FullName (the driver-side reference → read/write routing key). Parent
|
||||
// is the FolderPath sub-folder when set, else the equipment folder directly. Like the Galaxy
|
||||
// pass, per-variable idempotency relies on the sink's own EnsureVariable idempotency.
|
||||
foreach (var tag in composition.EquipmentTags)
|
||||
{
|
||||
var parent = string.IsNullOrWhiteSpace(tag.FolderPath)
|
||||
? tag.EquipmentId
|
||||
: EquipmentSubFolderNodeId(tag.EquipmentId, tag.FolderPath);
|
||||
SafeEnsureVariable(tag.FullName, parent, tag.Name, tag.DataType);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Phase7Applier: equipment tags materialised (tags={Tags}, equipment={Equipment})",
|
||||
composition.EquipmentTags.Count,
|
||||
composition.EquipmentTags.Select(t => t.EquipmentId).Distinct(StringComparer.Ordinal).Count());
|
||||
}
|
||||
|
||||
/// <summary>Deterministic NodeId for a tag's FolderPath sub-folder, scoped under its equipment
|
||||
/// folder so two equipments' identically-named sub-folders never collide.</summary>
|
||||
private static string EquipmentSubFolderNodeId(string equipmentId, string folderPath) =>
|
||||
$"{equipmentId}/{folderPath}";
|
||||
|
||||
private void SafeEnsureFolder(string nodeId, string? parentNodeId, string displayName)
|
||||
{
|
||||
try { _sink.EnsureFolder(nodeId, parentNodeId, displayName); }
|
||||
|
||||
@@ -230,6 +230,11 @@ public sealed class OpcUaPublishActor : ReceiveActor
|
||||
// + Variable node exist so clients can browse them. The Galaxy driver fills values
|
||||
// on a future SubscribeBulk pass; until then variables show BadWaitingForInitialData.
|
||||
_applier.MaterialiseGalaxyTags(composition);
|
||||
// Equipment-namespace tags get their own pass: ensures each signal's Variable (and any
|
||||
// FolderPath sub-folder) exists under its already-materialised equipment folder so
|
||||
// clients can browse them. Live values arrive in a later milestone; until then the
|
||||
// variables show BadWaitingForInitialData.
|
||||
_applier.MaterialiseEquipmentTags(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})",
|
||||
|
||||
Reference in New Issue
Block a user