namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
///
/// Pure diff between two snapshots — the
/// previous currently-applied composition and the next from a freshly-applied
/// deployment. Three lists per entity class (Equipment / DriverInstance / ScriptedAlarm)
/// captured by stable identity: added items are new, removed items have to be torn down,
/// changed items have the same identity but at least one field differs.
///
/// OpcUaPublishActor's RebuildAddressSpace consumes this against a real
/// binding so re-applies only mutate the
/// nodes that actually changed — full tear-down + rebuild is reserved for first-boot or
/// drastic schema flips.
///
public sealed record AddressSpacePlan(
IReadOnlyList AddedEquipment,
IReadOnlyList RemovedEquipment,
IReadOnlyList ChangedEquipment,
IReadOnlyList AddedDrivers,
IReadOnlyList RemovedDrivers,
IReadOnlyList ChangedDrivers,
IReadOnlyList AddedAlarms,
IReadOnlyList RemovedAlarms,
IReadOnlyList ChangedAlarms)
{
///
/// Equipment-namespace tag diff sets, keyed by . Added as
/// init-only members (defaulting empty) rather than positional parameters so existing
/// AddressSpacePlan construction sites compile unchanged — consistent with how
/// was added. Without these, an
/// incremental deploy that changes ONLY equipment tags produced an empty plan and
/// OpcUaPublishActor.HandleRebuild short-circuited before materialising them.
///
public IReadOnlyList AddedEquipmentTags { get; init; } = Array.Empty();
///
public IReadOnlyList RemovedEquipmentTags { get; init; } = Array.Empty();
///
public IReadOnlyList ChangedEquipmentTags { get; init; } = Array.Empty();
///
/// Equipment-namespace VirtualTag diff sets, keyed by .
/// The value-side analogue of : a VirtualTag carries an
/// Expression evaluated over DependencyRefs, so a deploy that changes ONLY
/// VirtualTags (e.g. a new computed signal or an edited formula) must still produce a
/// non-empty plan and drive a rebuild — without these the diff was blind to VirtualTags and
/// such a deploy silently no-op'd. Added as init-only members (defaulting empty) for the same
/// compile-compatibility reason as .
///
public IReadOnlyList AddedEquipmentVirtualTags { get; init; } = Array.Empty();
///
public IReadOnlyList RemovedEquipmentVirtualTags { get; init; } = Array.Empty();
///
public IReadOnlyList ChangedEquipmentVirtualTags { get; init; } = Array.Empty();
///
/// UNS Area / Line folder renames: a folder whose stable id is unchanged but
/// whose DisplayName differs between the previous + next composition. A deploy whose ONLY
/// change is an Area or Line rename produces NO Equipment / Driver / Alarm / Tag / VirtualTag
/// delta, so without this set the plan would be and
/// OpcUaPublishActor.HandleRebuild would short-circuit BEFORE the apply path runs, leaving
/// the folder's stale OPC UA DisplayName until some unrelated structural change forced a
/// rebuild. applies each rename IN PLACE via
/// (folder NodeId =
/// the area's UnsAreaId / line's UnsLineId, the exact ids MaterialiseHierarchy
/// uses), preserving every client's subscriptions; a sink lacking the surgical capability or a
/// missing folder falls back to a full rebuild. Added as an init-only member (defaulting empty) so
/// every existing AddressSpacePlan construction site compiles unchanged — consistent with
/// the EquipmentTag / EquipmentVirtualTag diff sets above.
///
public IReadOnlyList RenamedFolders { get; init; } = Array.Empty();
///
/// v3 Batch 4 — the Raw subtree's container diff (Folder/Driver/Device/TagGroup nodes),
/// keyed by (the RawPath). A rename of any container changes
/// its RawPath (and every descendant's), so it manifests as remove(old)+add(new) — an OPC UA NodeId
/// is immutable identity. Init-only, defaulting empty, so existing construction sites compile
/// unchanged (consistent with the EquipmentTag diff sets above).
///
public IReadOnlyList AddedRawContainers { get; init; } = Array.Empty();
///
public IReadOnlyList RemovedRawContainers { get; init; } = Array.Empty();
///
public IReadOnlyList ChangedRawContainers { get; init; } = Array.Empty();
///
/// v3 Batch 4 — the Raw subtree's tag diff, keyed by (the
/// RawPath). A raw tag rename changes its RawPath ⇒ remove(old NodeId)+add(new NodeId) here
/// (the immutable-NodeId rule); an attribute-only edit (historize / writable / array / alarm) keeps
/// the same NodeId ⇒ a . Init-only, defaulting empty.
///
public IReadOnlyList AddedRawTags { get; init; } = Array.Empty();
///
public IReadOnlyList RemovedRawTags { get; init; } = Array.Empty();
///
public IReadOnlyList ChangedRawTags { get; init; } = Array.Empty();
///
/// v3 Batch 4 — the UNS subtree's reference-Variable diff, keyed by
/// (the stable row id, NOT the NodeId). Keying on
/// the row id is what makes a backing-raw-tag rename a re-point (same reference row, same
/// effective name ⇒ same UNS NodeId, but — the
/// Organizes target + fan-out source — moves) rather than a remove+add; and a
/// display-name-override edit a (same row id, NodeId + effective name
/// change) with NO raw-side change. Init-only, defaulting empty.
///
public IReadOnlyList AddedUnsReferenceVariables { get; init; } = Array.Empty();
///
public IReadOnlyList RemovedUnsReferenceVariables { get; init; } = Array.Empty();
///
public IReadOnlyList ChangedUnsReferenceVariables { get; init; } = Array.Empty();
/// Gets a value indicating whether the composition plan contains no changes.
public bool IsEmpty =>
AddedEquipment.Count == 0 && RemovedEquipment.Count == 0 && ChangedEquipment.Count == 0 &&
AddedDrivers.Count == 0 && RemovedDrivers.Count == 0 && ChangedDrivers.Count == 0 &&
AddedAlarms.Count == 0 && RemovedAlarms.Count == 0 && ChangedAlarms.Count == 0 &&
AddedEquipmentTags.Count == 0 && RemovedEquipmentTags.Count == 0 && ChangedEquipmentTags.Count == 0 &&
AddedEquipmentVirtualTags.Count == 0 && RemovedEquipmentVirtualTags.Count == 0 && ChangedEquipmentVirtualTags.Count == 0 &&
AddedRawContainers.Count == 0 && RemovedRawContainers.Count == 0 && ChangedRawContainers.Count == 0 &&
AddedRawTags.Count == 0 && RemovedRawTags.Count == 0 && ChangedRawTags.Count == 0 &&
AddedUnsReferenceVariables.Count == 0 && RemovedUnsReferenceVariables.Count == 0 && ChangedUnsReferenceVariables.Count == 0 &&
RenamedFolders.Count == 0;
public sealed record EquipmentDelta(EquipmentNode Previous, EquipmentNode Current);
public sealed record DriverDelta(DriverInstancePlan Previous, DriverInstancePlan Current);
public sealed record AlarmDelta(ScriptedAlarmPlan Previous, ScriptedAlarmPlan Current);
public sealed record EquipmentTagDelta(EquipmentTagPlan Previous, EquipmentTagPlan Current);
public sealed record EquipmentVirtualTagDelta(EquipmentVirtualTagPlan Previous, EquipmentVirtualTagPlan Current);
/// A Raw container node present in both snapshots (same RawPath) with ≥1 differing field.
public sealed record RawContainerDelta(RawContainerNode Previous, RawContainerNode Current);
/// A Raw tag present in both snapshots (same RawPath) with ≥1 differing attribute (historize /
/// writable / array / alarm / referencing-equipment set). A rename is NOT a delta — it is remove+add.
public sealed record RawTagDelta(RawTagPlan Previous, RawTagPlan Current);
/// A UNS reference variable present in both snapshots (same UnsTagReferenceId) with ≥1 differing
/// field — a backing-tag re-point (BackingRawPath moved) or a display-name-override edit (NodeId +
/// effective name changed).
public sealed record UnsReferenceDelta(UnsReferenceVariable Previous, UnsReferenceVariable Current);
/// One renamed UNS Area / Line folder: the stable folder
/// (the area's UnsAreaId or line's UnsLineId, the exact
/// NodeId MaterialiseHierarchy placed the folder at) and the
/// to apply in place.
/// The folder's stable NodeId (area/line id) — unchanged by a rename.
/// The new display name to apply.
public sealed record FolderRename(string FolderNodeId, string NewDisplayName);
}
public static class AddressSpacePlanner
{
///
/// Diff two compositions, emitting Added/Removed/Changed sets per entity class.
/// Identity is the entity's stable id (EquipmentId, DriverInstanceId, ScriptedAlarmId).
/// Element equality on the projection records doubles as the "did this change" check,
/// so any field difference moves an item from "stable" to ChangedX.
///
/// The previous composition result.
/// The new composition result.
/// The diff plan between the two compositions.
public static AddressSpacePlan Compute(AddressSpaceComposition previous, AddressSpaceComposition next)
{
ArgumentNullException.ThrowIfNull(previous);
ArgumentNullException.ThrowIfNull(next);
var (addedEq, removedEq, changedEq) = DiffById(
previous.EquipmentNodes, next.EquipmentNodes,
n => n.EquipmentId,
(a, b) => new AddressSpacePlan.EquipmentDelta(a, b));
var (addedDrv, removedDrv, changedDrv) = DiffById(
previous.DriverInstancePlans, next.DriverInstancePlans,
d => d.DriverInstanceId,
(a, b) => new AddressSpacePlan.DriverDelta(a, b));
var (addedAlarm, removedAlarm, changedAlarm) = DiffById(
previous.ScriptedAlarmPlans, next.ScriptedAlarmPlans,
a => a.ScriptedAlarmId,
(a, b) => new AddressSpacePlan.AlarmDelta(a, b));
var (addedEqTags, removedEqTags, changedEqTags) = DiffById(
previous.EquipmentTags, next.EquipmentTags,
t => t.TagId,
(a, b) => new AddressSpacePlan.EquipmentTagDelta(a, b));
// VirtualTags diff by VirtualTagId, mirroring the EquipmentTags pass. EquipmentVirtualTagPlan
// overrides record equality to compare ALL fields by value — scalars (Expression/DataType/
// Name/FolderPath) plus DependencyRefs element-wise (SequenceEqual). So a no-op redeploy (fresh
// list instances, identical contents) correctly diffs to empty; only a real content change is
// flagged as changed.
var (addedVTags, removedVTags, changedVTags) = DiffById(
previous.EquipmentVirtualTags, next.EquipmentVirtualTags,
t => t.VirtualTagId,
(a, b) => new AddressSpacePlan.EquipmentVirtualTagDelta(a, b));
// Raw subtree — containers keyed by RawPath (NodeId). A rename changes the RawPath, so it surfaces
// as remove(old)+add(new) here (an OPC UA NodeId is immutable identity). Attribute-only container
// changes (none today — a container carries only DisplayName == leaf name, which lives in the
// RawPath) fall to Changed, kept for completeness / future fields.
var (addedRawContainers, removedRawContainers, changedRawContainers) = DiffById(
previous.RawContainers, next.RawContainers,
c => c.NodeId,
(a, b) => new AddressSpacePlan.RawContainerDelta(a, b));
// Raw subtree — tags keyed by RawPath (NodeId). A rename ⇒ remove(old NodeId)+add(new NodeId); an
// attribute-only edit (historize / writable / array / alarm) ⇒ Changed. RawTagPlan overrides record
// equality to compare ReferencingEquipmentPaths element-wise so a no-op redeploy diffs empty.
var (addedRawTags, removedRawTags, changedRawTags) = DiffById(
previous.RawTags, next.RawTags,
t => t.NodeId,
(a, b) => new AddressSpacePlan.RawTagDelta(a, b));
// UNS subtree — reference variables keyed by the STABLE UnsTagReferenceId (NOT the NodeId). Keying on
// the row id is what makes a backing-raw-tag rename a re-point (same row + effective name ⇒ same UNS
// NodeId, but BackingRawPath moves) rather than a remove+add, and a display-name-override edit a
// Changed delta (same row id, NodeId + effective name changed) with no raw-side entry.
var (addedUnsRefs, removedUnsRefs, changedUnsRefs) = DiffById(
previous.UnsReferenceVariables, next.UnsReferenceVariables,
v => v.UnsTagReferenceId,
(a, b) => new AddressSpacePlan.UnsReferenceDelta(a, b));
// UNS Area / Line renames: a folder whose stable id is unchanged but whose
// DisplayName differs. Diffed by stable id (UnsAreaId / UnsLineId) so an Area/Line whose ONLY
// change is its friendly name is no longer a silent no-op at the IsEmpty gate. The folder NodeId
// IS the area/line id (the exact scheme MaterialiseHierarchy uses), so the rename carries it
// directly. Areas first, then lines; each list is independently sorted by id for determinism.
var renamedFolders = DiffRenames(previous.UnsAreas, next.UnsAreas, a => a.UnsAreaId, a => a.DisplayName)
.Concat(DiffRenames(previous.UnsLines, next.UnsLines, l => l.UnsLineId, l => l.DisplayName))
.ToList();
return new AddressSpacePlan(
addedEq, removedEq, changedEq,
addedDrv, removedDrv, changedDrv,
addedAlarm, removedAlarm, changedAlarm)
{
AddedEquipmentTags = addedEqTags,
RemovedEquipmentTags = removedEqTags,
ChangedEquipmentTags = changedEqTags,
AddedEquipmentVirtualTags = addedVTags,
RemovedEquipmentVirtualTags = removedVTags,
ChangedEquipmentVirtualTags = changedVTags,
AddedRawContainers = addedRawContainers,
RemovedRawContainers = removedRawContainers,
ChangedRawContainers = changedRawContainers,
AddedRawTags = addedRawTags,
RemovedRawTags = removedRawTags,
ChangedRawTags = changedRawTags,
AddedUnsReferenceVariables = addedUnsRefs,
RemovedUnsReferenceVariables = removedUnsRefs,
ChangedUnsReferenceVariables = changedUnsRefs,
RenamedFolders = renamedFolders,
};
}
///
/// Emit a for every folder present in
/// BOTH snapshots (matched by stable ) whose
/// differs (ordinal). Added/removed folders are NOT renames — they're handled by the equipment /
/// hierarchy rebuild path — so this pass only flags an in-place display-name change on a surviving
/// folder. Sorted by id for deterministic ordering.
///
private static List DiffRenames(
IReadOnlyList previous,
IReadOnlyList next,
Func identity,
Func displayName) where T : class
{
var prevById = previous.ToDictionary(identity, StringComparer.Ordinal);
var renames = new List();
foreach (var n in next)
{
if (prevById.TryGetValue(identity(n), out var p)
&& !string.Equals(displayName(p), displayName(n), StringComparison.Ordinal))
{
renames.Add(new AddressSpacePlan.FolderRename(identity(n), displayName(n)));
}
}
renames.Sort((a, b) => string.CompareOrdinal(a.FolderNodeId, b.FolderNodeId));
return renames;
}
private static (IReadOnlyList Added, IReadOnlyList Removed, IReadOnlyList Changed)
DiffById(
IReadOnlyList previous,
IReadOnlyList next,
Func identity,
Func deltaFactory) where T : class
{
var prevById = previous.ToDictionary(identity, StringComparer.Ordinal);
var nextById = next.ToDictionary(identity, StringComparer.Ordinal);
var added = new List();
var removed = new List();
var changed = new List();
foreach (var (id, p) in prevById)
{
if (!nextById.TryGetValue(id, out var n)) { removed.Add(p); continue; }
if (!EqualityComparer.Default.Equals(p, n)) changed.Add(deltaFactory(p, n));
}
foreach (var (id, n) in nextById)
{
if (!prevById.ContainsKey(id)) added.Add(n);
}
added.Sort((a, b) => string.CompareOrdinal(identity(a), identity(b)));
removed.Sort((a, b) => string.CompareOrdinal(identity(a), identity(b)));
return (added, removed, changed);
}
}