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(); /// /// OpcUaServer-001 — 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(); /// 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 && 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); /// OpcUaServer-001 — 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. 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)); // OpcUaServer-001 — 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, RenamedFolders = renamedFolders, }; } /// /// OpcUaServer-001 — 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); } }