feat(opcua): announce pure-add nodes via Part 3 NodeAdded model-change (R2-07 T3)

This commit is contained in:
Joseph Doherty
2026-07-13 11:48:02 -04:00
parent 0d717e4785
commit 9dbc363ef0
3 changed files with 157 additions and 1 deletions
@@ -218,6 +218,63 @@ public sealed class AddressSpaceApplier
return new AddressSpaceApplyOutcome(removedCount, addedCount, changedCount, rebuilt, rebuildFailed, failedNodes);
}
/// <summary>
/// Compute the deduplicated, deterministically-ordered set of affected PARENT node ids to announce
/// (Part 3 <c>NodeAdded</c>) after a pure-add apply's Materialise passes have created the new nodes.
/// For each added tag/vtag the parent is its materialise folder (the equipment folder, or the
/// <c>FolderPath</c> sub-folder — the exact ids the Materialise passes place children at); for each
/// added scripted alarm the parent is its equipment folder (where the condition node parents); for
/// each added equipment the parent is its UNS line folder when set, else the equipment's own new
/// folder id (a valid Part 3 announcement of the folder itself — the root has no announceable id).
/// Pure — no sink interaction — so it is unit-testable in isolation and safe to call before the
/// nodes exist. Ordinal-sorted so the announce sequence is stable.
/// </summary>
/// <param name="plan">The plan whose added nodes' parents to announce.</param>
/// <returns>The distinct affected parent node ids, ordinal-sorted.</returns>
public IReadOnlyList<string> ComputeAddAnnouncements(AddressSpacePlan plan)
{
ArgumentNullException.ThrowIfNull(plan);
var ids = new HashSet<string>(StringComparer.Ordinal);
foreach (var t in plan.AddedEquipmentTags) ids.Add(MaterialiseParent(t.EquipmentId, t.FolderPath));
foreach (var v in plan.AddedEquipmentVirtualTags) ids.Add(MaterialiseParent(v.EquipmentId, v.FolderPath));
foreach (var a in plan.AddedAlarms) ids.Add(a.EquipmentId);
foreach (var eq in plan.AddedEquipment)
ids.Add(string.IsNullOrWhiteSpace(eq.UnsLineId) ? eq.EquipmentId : eq.UnsLineId);
var list = ids.ToList();
list.Sort(StringComparer.Ordinal);
return list;
}
/// <summary>The materialise-parent node id a tag/vtag's variable (or native-alarm condition) hangs
/// under: the equipment folder when <paramref name="folderPath"/> is null/empty, else the FolderPath
/// sub-folder. Mirrors the parent derivation the Materialise passes use.</summary>
/// <param name="equipmentId">The owning equipment folder id.</param>
/// <param name="folderPath">The tag/vtag FolderPath, or null/empty for "directly under the equipment".</param>
/// <returns>The materialise-parent node id.</returns>
private static string MaterialiseParent(string equipmentId, string? folderPath) =>
string.IsNullOrWhiteSpace(folderPath) ? equipmentId : EquipmentNodeIds.SubFolder(equipmentId, folderPath);
/// <summary>
/// Announce a Part 3 <c>GeneralModelChangeEvent(NodeAdded)</c> per distinct affected parent from
/// <see cref="ComputeAddAnnouncements"/>, so model-aware clients re-browse the parents the new nodes
/// were added under. Called by <c>OpcUaPublishActor</c> AFTER the Materialise passes (the nodes exist
/// by then) and ONLY on a non-rebuild apply — after a full rebuild the announcement is moot
/// (subscriptions are dead anyway). Uses the existing, guard-covered
/// <see cref="IOpcUaAddressSpaceSink.RaiseNodesAddedModelChange"/> — no new sink member. Each call is
/// Safe-wrapped: a faulting announcement can never break a deploy (the nodes already stand).
/// </summary>
/// <param name="plan">The plan whose added nodes to announce.</param>
public void AnnounceAddedNodes(AddressSpacePlan plan)
{
foreach (var id in ComputeAddAnnouncements(plan))
{
try { _sink.RaiseNodesAddedModelChange(id); }
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: RaiseNodesAddedModelChange threw for {Node}", id); }
}
}
/// <summary>
/// Auto-provision the historian for the added historized equipment tags. Runs on the OPC UA
/// publish actor's pinned thread, so the synchronous portion is kept to building the request