using Microsoft.Extensions.Logging; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; /// /// Side-effecting orchestrator over . Drives an /// to materialise the diff between two /// snapshots: /// /// /// RemovedEquipment / RemovedAlarms — write Bad-quality on every removed /// node id then call RebuildAddressSpace at the end so the sink can /// actually tear down the OPC UA folders + variables. /// AddedEquipment / AddedAlarms — same Rebuild trigger (real SDK NodeManager /// will repopulate from the persisted artifact). For now we record the work. /// ChangedEquipment / ChangedAlarms — record what changed; the SDK adapter /// that lands in F10b will decide between in-place property writes and /// tear-down + rebuild. /// /// /// This is the side-effecting layer deferred to F14. It stays pure-of-SDK so /// production binds a real SDK sink, dev/Mac binds , /// and tests can capture every call. /// public sealed class AddressSpaceApplier { private readonly IOpcUaAddressSpaceSink _sink; private readonly ILogger _logger; private readonly IHistorianProvisioning _provisioning; private readonly IHistorizedTagSubscriptionSink _historizedSubscriptions; /// Initializes a new instance of the AddressSpaceApplier class. /// The OPC UA address space sink to apply changes to. /// The logger instance. /// /// Optional historian tag provisioner — when an address space is (re)built, historized added /// tags are auto-ensured in the historian via . /// Defaults (a null argument) to the no-op , so every /// existing two-argument call site compiles and behaves unchanged. The provisioning round-trip is /// dispatched fire-and-forget off (which runs on the OPC UA publish actor's /// pinned thread), so it can never block or break a deploy. /// /// /// Optional continuous-historization feed — when an address space is (re)applied, the add/remove /// delta of historized tag refs (resolved EXACTLY as the provisioning hook above) is pushed to the /// recorder so its dependency-mux interest converges to the currently-historized set. Defaults /// (a null argument) to the no-op , so every /// existing call site compiles and behaves unchanged. The feed is a single non-blocking post off /// and is wrapped so it can never block or break a deploy. /// public AddressSpaceApplier( IOpcUaAddressSpaceSink sink, ILogger logger, IHistorianProvisioning? provisioning = null, IHistorizedTagSubscriptionSink? historizedSubscriptions = null) { ArgumentNullException.ThrowIfNull(sink); ArgumentNullException.ThrowIfNull(logger); _sink = sink; _logger = logger; _provisioning = provisioning ?? NullHistorianProvisioning.Instance; _historizedSubscriptions = historizedSubscriptions ?? NullHistorizedTagSubscriptionSink.Instance; } /// /// Apply to the sink. Returns a summary of what was applied so /// callers (OpcUaPublishActor) can correlate the work back to the originating deployment. /// /// The plan to apply. /// A AddressSpaceApplyOutcome summarizing the applied changes. public AddressSpaceApplyOutcome Apply(AddressSpacePlan plan) { ArgumentNullException.ThrowIfNull(plan); if (plan.IsEmpty) { _logger.LogDebug("AddressSpaceApplier: plan is empty; skipping sink writes"); return new AddressSpaceApplyOutcome(RemovedNodes: 0, AddedNodes: 0, ChangedNodes: 0, RebuildCalled: false); } var ts = DateTime.UtcNow; var removedCount = 0; foreach (var eq in plan.RemovedEquipment) { SafeWriteAlarmCondition(eq.EquipmentId, RemovedConditionState, ts); removedCount++; } foreach (var alarm in plan.RemovedAlarms) { SafeWriteAlarmCondition(alarm.ScriptedAlarmId, RemovedConditionState, ts); removedCount++; } // Removed equipment tags / VirtualTags are plain variable nodes (no Part 9 condition to write // before tear-down), but they ARE real removals — count them so AddressSpaceApplyOutcome.RemovedNodes // is accurate on a removed-tag-only deploy, which now reaches the rebuild path below. removedCount += plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count; var changedCount = plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count + plan.ChangedEquipmentTags.Count + plan.ChangedEquipmentVirtualTags.Count + // A UNS Area/Line rename is an in-place change to an existing folder node. plan.RenamedFolders.Count; var addedCount = plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count + plan.AddedEquipmentTags.Count + plan.AddedEquipmentVirtualTags.Count; // Any add / remove / in-place CHANGE of Equipment, ScriptedAlarm, Equipment tag, or Equipment // VirtualTag topology requires a real address-space rebuild — the materialise passes re-derive // every node from the composition, so a changed-only deploy (e.g. a renamed equipment, a // re-severitied alarm, a flipped tag dataType) must still rebuild or the running server keeps // the stale node. // ChangedDrivers is deliberately EXCLUDED: a driver-instance config change doesn't touch the // address-space topology — it routes through DriverHostActor's spawn-plan in Runtime, which // re-spawns the affected driver actor without re-materialising any nodes. // F10b (vtag skip): a CHANGED VirtualTag whose ONLY differences are Expression/DependencyRefs/ // Historize is node-IRRELEVANT (see VtagDeltaIsNodeIrrelevant) — its materialised node is // byte-identical and the vtag engine adopts those edits via VirtualTagHostActor's INDEPENDENT // respawn (DriverHostActor → ApplyVirtualTags), so it skips the rebuild and PRESERVES every // client's server-wide subscriptions. Any structural / node-affecting vtag change (Name/ // FolderPath/DataType) — or any non-vtag change anywhere — still forces a full rebuild. // F10b (surgical tag write): a CHANGED equipment tag whose ONLY differences are Writable / // IsHistorized / HistorianTagname / DataType / IsArray / ArrayLength (a plain value variable — no // alarm condition node) can be updated IN PLACE on the existing node via // ISurgicalAddressSpaceSink.UpdateTagAttributes (see TagDeltaIsSurgicalEligible), avoiding the full // rebuild and preserving subscriptions. The shape fields (DataType/IsArray/ArrayLength) are now // surgical because the sink swaps DataType + ValueRank + ArrayDimensions in place and raises a // GeneralModelChangeEvent. Any other tag difference (FullName/Name/DriverInstanceId/identity/alarm) — // or a sink that lacks the surgical capability, or a node that turns out missing — falls back to a // full rebuild (safe default). var structuralRebuild = plan.AddedEquipment.Count > 0 || plan.RemovedEquipment.Count > 0 || plan.ChangedEquipment.Count > 0 || plan.AddedAlarms.Count > 0 || plan.RemovedAlarms.Count > 0 || plan.ChangedAlarms.Count > 0 || plan.AddedEquipmentTags.Count > 0 || plan.RemovedEquipmentTags.Count > 0 || plan.ChangedEquipmentTags.Any(d => !TagDeltaIsSurgicalEligible(d)) || plan.AddedEquipmentVirtualTags.Count > 0 || plan.RemovedEquipmentVirtualTags.Count > 0 || plan.ChangedEquipmentVirtualTags.Any(d => !VtagDeltaIsNodeIrrelevant(d)); var surgicalTagDeltas = plan.ChangedEquipmentTags.Where(TagDeltaIsSurgicalEligible).ToList(); // UNS Area / Line renames are surgically applicable (in-place DisplayName swap) // when no structural rebuild fires. When a rebuild DOES fire (any add/remove/structural change), // MaterialiseHierarchy re-creates every folder with the new display names, so the renames are // covered for free and need no separate surgical pass. var renamedFolders = plan.RenamedFolders; var rebuilt = false; var rebuildFailed = false; if (structuralRebuild) { rebuildFailed = !SafeRebuild(); rebuilt = true; } else if (surgicalTagDeltas.Count > 0 || renamedFolders.Count > 0) { if (_sink is ISurgicalAddressSpaceSink surgical) { var allApplied = true; // Folder renames first — an in-place DisplayName swap on the existing Area/Line folder. foreach (var rename in renamedFolders) { bool ok; try { ok = surgical.UpdateFolderDisplayName(rename.FolderNodeId, rename.NewDisplayName); } catch (Exception ex) { _logger.LogError(ex, "AddressSpaceApplier: surgical UpdateFolderDisplayName threw for {Node}", rename.FolderNodeId); ok = false; } if (!ok) { allApplied = false; break; } } foreach (var d in allApplied ? surgicalTagDeltas : Enumerable.Empty()) { // Compute the node id + writable + historian + shape EXACTLY as MaterialiseEquipmentTags // would so the in-place update matches what a rebuild would have produced. Array tags are // forced read-only (same as EnsureVariable: the driver write path doesn't handle arrays). var nodeId = EquipmentNodeIds.Variable(d.Current.EquipmentId, d.Current.FolderPath, d.Current.Name); var writable = d.Current.Writable && !d.Current.IsArray; var historian = d.Current.IsHistorized ? (string.IsNullOrWhiteSpace(d.Current.HistorianTagname) ? d.Current.FullName : d.Current.HistorianTagname) : null; bool ok; try { ok = surgical.UpdateTagAttributes(nodeId, writable, historian, d.Current.DataType, d.Current.IsArray, d.Current.ArrayLength); } catch (Exception ex) { _logger.LogError(ex, "AddressSpaceApplier: surgical UpdateTagAttributes threw for {Node}", nodeId); ok = false; } if (!ok) { allApplied = false; break; } } if (!allApplied) { rebuildFailed = !SafeRebuild(); rebuilt = true; } } else { // Sink lacks the surgical capability ⇒ rebuild (safe default). rebuildFailed = !SafeRebuild(); rebuilt = true; } } _logger.LogInformation( "AddressSpaceApplier: applied plan (added={Added}, removed={Removed}, changed={Changed}, surgicalTags={Surgical}, renamedFolders={Renamed}, rebuild={Rebuild})", addedCount, removedCount, changedCount, rebuilt ? 0 : surgicalTagDeltas.Count, rebuilt ? 0 : renamedFolders.Count, rebuilt); // After the address-space work has completed, auto-provision the historian for the added // historized tags. This is fully detached (fire-and-forget) and wrapped so it can NEVER block // or break the deploy — Apply has already produced its outcome and returns it regardless. ProvisionHistorizedTags(plan); // Alongside provisioning: feed the continuous-historization recorder the add/remove delta of // historized tag refs this plan changes, so its dependency-mux interest converges to exactly the // currently-historized set. Same non-blocking + throw-safe discipline as the provisioning hook. FeedHistorizedRefs(plan); return new AddressSpaceApplyOutcome(removedCount, addedCount, changedCount, rebuilt, rebuildFailed); } /// /// 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 /// list only and the gateway round-trip is dispatched fire-and-forget. The whole hook is wrapped /// in try/catch — a synchronously-throwing provisioner (or any request-building fault) is /// swallowed so it cannot break a deploy. /// /// The plan whose added historized tags to ensure in the historian. private void ProvisionHistorizedTags(AddressSpacePlan plan) { try { List? requests = null; foreach (var tag in plan.AddedEquipmentTags) { // Only historized value variables are provisioned. Native-alarm tags materialise as // Part 9 condition nodes (never historized value variables) — the materialiser resolves // a historian tagname only for the non-alarm branch, so mirror that and skip them. if (!tag.IsHistorized || tag.Alarm is not null) continue; // Parse the driver-agnostic data type from the tag's DataType string. An unparseable // type is skipped (logged at Debug) rather than faulting the hook. if (!Enum.TryParse(tag.DataType, ignoreCase: true, out var dataType)) { _logger.LogDebug( "AddressSpaceApplier: skipping historian provisioning for an added historized tag whose data type '{DataType}' is not a DriverDataType", tag.DataType); continue; } // Resolve the historian name EXACTLY as MaterialiseEquipmentTags does: a null/blank // override falls back to the driver-side FullName. var historianName = string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname; (requests ??= new List()).Add( new HistorianTagProvisionRequest(historianName, dataType, EngineeringUnit: null, Description: tag.Name)); } if (requests is null) return; // Fire-and-forget OFF the apply path. Never await/.Wait()/.Result here — Apply must return // its outcome without blocking on the gateway. The continuation observes the task so a // faulted provisioning never becomes an unobserved exception, and logs the tally. var provisionCount = requests.Count; var dispatch = _provisioning.EnsureTagsAsync(requests, CancellationToken.None); _ = dispatch.ContinueWith( t => { if (!t.IsCompletedSuccessfully) { // Faulted OR canceled — never reach t.Result (which would re-throw and // leave this discarded continuation unobserved). _logger.LogWarning(t.Exception?.GetBaseException(), "AddressSpaceApplier: historian provisioning of {Count} tag(s) did not complete; deploy unaffected", provisionCount); return; } // Emit a tally on EVERY successful dispatch (not only on Failed/Skipped) so a fully- // successful provisioning is observable AND a dormant no-op is detectable: the no-op // NullHistorianProvisioning reports Requested=0 regardless of input, so a line showing // dispatched={N} but requested=0 unmistakably flags "provisioning is not wired" — the // exact invisibility that let the dormant-provisioner bug ship unnoticed. var result = t.Result; _logger.LogInformation( "AddressSpaceApplier: historian provisioning completed (dispatched={Dispatched}, requested={Requested}, ensured={Ensured}, skipped={Skipped}, failed={Failed})", provisionCount, result.Requested, result.Ensured, result.Skipped, result.Failed); }, CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default); } catch (Exception ex) { // A synchronous fault (e.g. the provisioner throws before returning a task) must not break // the deploy. Apply has already produced its outcome. _logger.LogWarning(ex, "AddressSpaceApplier: historian provisioning hook faulted synchronously; deploy unaffected"); } } /// /// Feed the continuous-historization recorder the add/remove delta of historized tag refs this /// plan changes, so its dependency-mux interest converges to exactly the currently-historized set /// after every deploy. The plan is a pure DIFF (an incremental/surgical apply carries a delta, not /// the full set), so a delta feed is the only convergent design this hook can produce — the /// recorder keeps the full set and re-registers it. Each ref is resolved EXACTLY as /// / MaterialiseEquipmentTags resolve it /// (override-or-FullName), and only non-alarm historized value variables count (native-alarm tags /// materialise as Part 9 condition nodes, never historized value variables). Runs on the OPC UA /// publish actor's pinned thread, so the only work here is building two small ref lists; the /// downstream feed is a single non-blocking post behind the sink. The whole hook is wrapped so a /// faulting feed can never block or break a deploy. /// /// The plan whose historized-ref changes drive the recorder's interest set. private void FeedHistorizedRefs(AddressSpacePlan plan) { try { List? added = null; List? removed = null; // Added historized value variables → new interest. foreach (var tag in plan.AddedEquipmentTags) { if (HistorizedRef(tag) is { } r) (added ??= new List()).Add(r); } // Removed historized value variables → drop interest. foreach (var tag in plan.RemovedEquipmentTags) { if (HistorizedRef(tag) is { } r) (removed ??= new List()).Add(r); } // Changed tags: the historized ref may have flipped on/off or been renamed (override/FullName // change). Compare previous-vs-current resolved ref PAIRS (record equality compares both the // mux ref and the historian name) — an unchanged pair is a no-op. foreach (var d in plan.ChangedEquipmentTags) { var prev = HistorizedRef(d.Previous); var cur = HistorizedRef(d.Current); if (prev == cur) continue; if (prev is not null) (removed ??= new List()).Add(prev); if (cur is not null) (added ??= new List()).Add(cur); } if (added is null && removed is null) return; _historizedSubscriptions.UpdateHistorizedRefs( added ?? (IReadOnlyList)Array.Empty(), removed ?? (IReadOnlyList)Array.Empty()); } catch (Exception ex) { // A synchronous fault in the feed (or in building the ref lists) must not break the deploy. // Apply has already produced its outcome. _logger.LogWarning(ex, "AddressSpaceApplier: historized-ref subscription feed faulted; deploy unaffected"); } } /// /// Resolve the historized tag ref for as a /// carrying BOTH identifiers the recorder needs: the /// MuxRef = the driver-side FullName the dependency mux fans values by, and the /// HistorianName = the value the EnsureTags hook / materialiser write under (a non-alarm /// historized value variable's HistorianTagname override, else its FullName). The /// two diverge ONLY when an override is set. Returns null when the tag is not a historized /// value variable (not historized, or a native-alarm condition node). /// /// The equipment tag to resolve a historized ref for. /// The resolved historized ref pair, or null when the tag is not a historized value variable. private static HistorizedTagRef? HistorizedRef(EquipmentTagPlan tag) => tag.IsHistorized && tag.Alarm is null ? new HistorizedTagRef( tag.FullName, string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname) : null; /// Rebuild the sink's address space, swallowing (and Error-logging) any fault. /// Returns true on success, false when the sink threw — the caller threads the /// result into so a broken rebuild is /// visible instead of reported as optimistic success (archreview 01/S-1). /// true when the rebuild completed; false when the sink threw. private bool SafeRebuild() { try { _sink.RebuildAddressSpace(); return true; } catch (Exception ex) { _logger.LogError(ex, "AddressSpaceApplier: sink.RebuildAddressSpace threw"); return false; } } /// /// Build the UNS Area/Line/Equipment folder hierarchy in the address space from a /// composition snapshot. Called by OpcUaPublishActor after a rebuild so OPC UA /// clients browsing the server see proper folder structure instead of flat tag ids. /// Idempotent: each EnsureFolder call returns the existing folder if already /// present, so re-applies are cheap. /// /// The composition result containing the hierarchy to materialise. public void MaterialiseHierarchy(AddressSpaceComposition 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( "AddressSpaceApplier: hierarchy materialised (areas={Areas}, lines={Lines}, equipment={Equipment})", composition.UnsAreas.Count, composition.UnsLines.Count, composition.EquipmentNodes.Count); } /// /// Materialise Equipment-namespace tags from a composition snapshot. /// For each , /// ensure its optional FolderPath sub-folder under the existing equipment folder, then /// ensure a Variable (NodeId = FullName, the driver-side ref) inside it. Variables /// start BadWaitingForInitialData; the driver fills live values in a later milestone. /// Idempotent. This is a sink-based pass, NOT a reuse of EquipmentNodeWalker: no /// sink-backed IAddressSpaceBuilder adapter exists, and the walker re-creates the /// whole Area/Line/Equipment tree with browse-path NodeIds, incompatible with this path's /// logical-Id NodeIds and the already-materialised equipment folders. This pass adds ONLY /// variables (and any per-tag sub-folder); owns the /// equipment folders and this pass never re-creates them. The sink's EnsureVariable /// takes a plain string dataType (not a DriverAttributeInfo). /// /// The composition result containing the equipment tags to materialise. public void MaterialiseEquipmentTags(AddressSpaceComposition 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 (never re-create // the equipment folder here). var foldersCreated = new HashSet(StringComparer.Ordinal); foreach (var tag in composition.EquipmentTags) { if (string.IsNullOrWhiteSpace(tag.FolderPath)) continue; var folderNodeId = EquipmentNodeIds.SubFolder(tag.EquipmentId, tag.FolderPath); if (!foldersCreated.Add(folderNodeId)) continue; SafeEnsureFolder(folderNodeId, parentNodeId: tag.EquipmentId, displayName: tag.FolderPath); } // Variables: NodeId is FOLDER-SCOPED ("/"), NOT the raw FullName — a driver // ref (e.g. a Modbus register) is not unique across identical machines, so FullName-as-NodeId // would collide in the sink (EnsureVariable keys on NodeId) and drop all but one machine's // signal. The driver-side FullName lives on EquipmentTagPlan for the later values milestone to // route by. Parent is the FolderPath sub-folder when set, else the equipment folder directly. // Per-variable idempotency relies on the sink's own EnsureVariable. foreach (var tag in composition.EquipmentTags) { var parent = string.IsNullOrWhiteSpace(tag.FolderPath) ? tag.EquipmentId : EquipmentNodeIds.SubFolder(tag.EquipmentId, tag.FolderPath); var nodeId = EquipmentNodeIds.Variable(tag.EquipmentId, tag.FolderPath, tag.Name); if (tag.Alarm is not null) { // Native alarm tag → a real Part 9 condition node (reuses the scripted-alarm path), // NOT a value variable. Parent is the sub-folder when set, else the equipment folder. SafeMaterialiseAlarmCondition(nodeId, parent, tag.Name, tag.Alarm.AlarmType, tag.Alarm.Severity, isNative: true); } else { // Phase C: a historized tag materialises Historizing + HistoryRead. Resolve the effective // historian tagname HERE (default-vs-override): a null/blank override falls back to the // driver-side FullName; null means the tag is not historized at all. string? historianTagname = tag.IsHistorized ? (string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname) : null; // Array writes are out of scope (Phase 4c read-only surface): force array tags read-only // even if authored ReadWrite, so a client write cannot reach the driver write path which // does not handle arrays (e.g. S7 BoxValueForWrite would crash). var writable = tag.Writable && !tag.IsArray; SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, writable, historianTagname, tag.IsArray, tag.ArrayLength); } } _logger.LogInformation( "AddressSpaceApplier: equipment tags materialised (tags={Tags}, equipment={Equipment})", composition.EquipmentTags.Count, composition.EquipmentTags.Select(t => t.EquipmentId).Distinct(StringComparer.Ordinal).Count()); } /// /// Materialise driver-discovered nodes (FixedTree) under an equipment at runtime. Idempotent: /// re-applies are cheap (the sink's EnsureFolder/EnsureVariable early-return on existing nodes), so /// this is safely re-run after every address-space rebuild. Folders are ensured parent-first. /// Emits a NodeAdded model-change so connected clients can refresh. Discovered nodes are read-only /// value nodes; array discovered nodes (rare) are forced read-only like the equipment-tag pass. /// /// The equipment root node the discovered nodes hang under; the /// NodeAdded model-change is announced under this node. /// The discovered folders to ensure (parent-first by depth). /// The discovered variables to ensure (read-only value nodes). public void MaterialiseDiscoveredNodes( string equipmentRootNodeId, IReadOnlyList folders, IReadOnlyList variables) { ArgumentException.ThrowIfNullOrEmpty(equipmentRootNodeId); ArgumentNullException.ThrowIfNull(folders); ArgumentNullException.ThrowIfNull(variables); if (folders.Count == 0 && variables.Count == 0) return; // Parent-first: a child folder's parent must exist before it. Ordering by '/' count == depth. foreach (var f in folders.OrderBy(f => f.NodeId.Count(c => c == '/'))) SafeEnsureFolder(f.NodeId, f.ParentNodeId, f.DisplayName); foreach (var v in variables) { // Mirror MaterialiseEquipmentTags: arrays forced read-only (the driver write path can't handle arrays). var writable = v.Writable && !v.IsArray; SafeEnsureVariable(v.NodeId, v.ParentNodeId, v.DisplayName, v.DataType, writable, historianTagname: null, isArray: v.IsArray, arrayLength: v.ArrayLength); } _sink.RaiseNodesAddedModelChange(equipmentRootNodeId); _logger.LogInformation( "AddressSpaceApplier: discovered nodes materialised under {Equipment} (folders={Folders}, vars={Vars})", equipmentRootNodeId, folders.Count, variables.Count); } /// /// Materialise Equipment-namespace VirtualTags from a composition snapshot — the VirtualTag /// analogue of . For each , /// ensure its optional FolderPath sub-folder under the existing equipment folder (in /// practice FolderPath is empty for VirtualTags, so this is usually a no-op), then ensure /// a Variable inside it. Like the tag pass, the variable's NodeId is FOLDER-SCOPED /// (parent/Name) — NOT the or /// — so identically-named VirtualTags on /// different equipments never collide in the sink (which keys on NodeId). Variables start /// BadWaitingForInitialData; VirtualTagActor fills live values in a later milestone. /// Idempotent (per-variable idempotency relies on the sink's own EnsureVariable). /// /// The composition result containing the equipment VirtualTags to materialise. public void MaterialiseEquipmentVirtualTags(AddressSpaceComposition composition) { ArgumentNullException.ThrowIfNull(composition); if (composition.EquipmentVirtualTags.Count == 0) return; // Sub-folders first — a VirtualTag's FolderPath becomes one folder UNDER its equipment folder // (deduped per distinct equipment+path). VirtualTags with no FolderPath hang directly under the // equipment folder, which MaterialiseHierarchy already created (never re-create it here). var foldersCreated = new HashSet(StringComparer.Ordinal); foreach (var v in composition.EquipmentVirtualTags) { if (string.IsNullOrWhiteSpace(v.FolderPath)) continue; var folderNodeId = EquipmentNodeIds.SubFolder(v.EquipmentId, v.FolderPath); if (!foldersCreated.Add(folderNodeId)) continue; SafeEnsureFolder(folderNodeId, parentNodeId: v.EquipmentId, displayName: v.FolderPath); } // Variables: NodeId is FOLDER-SCOPED ("/"), mirroring the equipment-tag pass. // Parent is the FolderPath sub-folder when set, else the equipment folder directly. // NOTE (H5): a VirtualTag's Historize flag is honoured on the WRITE side only — VirtualTagHostActor // forwards historized results to IHistoryWriter. It is intentionally NOT materialised as an SDK // Historizing/HistoryRead variable here (no server-side OPC UA HistoryRead for vtags), so these // stay plain read-only computed-output nodes. foreach (var v in composition.EquipmentVirtualTags) { var parent = string.IsNullOrWhiteSpace(v.FolderPath) ? v.EquipmentId : EquipmentNodeIds.SubFolder(v.EquipmentId, v.FolderPath); var nodeId = EquipmentNodeIds.Variable(v.EquipmentId, v.FolderPath, v.Name); // VirtualTags are computed outputs — read-only nodes (no inbound write). SafeEnsureVariable(nodeId, parent, v.Name, v.DataType, writable: false); } _logger.LogInformation( "AddressSpaceApplier: equipment virtualtags materialised (vtags={Vtags}, equipment={Equipment})", composition.EquipmentVirtualTags.Count, composition.EquipmentVirtualTags.Select(v => v.EquipmentId).Distinct(StringComparer.Ordinal).Count()); } /// /// T14 — materialise real OPC UA Part 9 AlarmConditionState nodes from a composition /// snapshot. For each enabled , register a /// condition node (keyed by its , which /// is the same id OpcUaPublishActor.AlarmStateUpdate targets) under its equipment folder. /// Disabled alarms are skipped — they expose no node. Must run AFTER /// so the equipment folders exist. Idempotent (the sink's /// MaterialiseAlarmCondition re-creates cleanly on re-apply). /// /// The composition result containing the scripted alarms to materialise. public void MaterialiseScriptedAlarms(AddressSpaceComposition composition) { ArgumentNullException.ThrowIfNull(composition); if (composition.EquipmentScriptedAlarms.Count == 0) return; var materialised = 0; foreach (var alarm in composition.EquipmentScriptedAlarms) { if (!alarm.Enabled) continue; SafeMaterialiseAlarmCondition(alarm.ScriptedAlarmId, alarm.EquipmentId, alarm.Name, alarm.AlarmType, alarm.Severity, isNative: false); materialised++; } _logger.LogInformation( "AddressSpaceApplier: scripted alarms materialised (alarms={Alarms}, equipment={Equipment})", materialised, composition.EquipmentScriptedAlarms.Where(a => a.Enabled) .Select(a => a.EquipmentId).Distinct(StringComparer.Ordinal).Count()); } private void SafeEnsureFolder(string nodeId, string? parentNodeId, string displayName) { try { _sink.EnsureFolder(nodeId, parentNodeId, displayName); } catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: EnsureFolder threw for {Node}", nodeId); } } private void SafeEnsureVariable(string nodeId, string? parentNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength); } catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: EnsureVariable threw for {Node}", nodeId); } } // A VirtualTag's materialised OPC UA node (MaterialiseEquipmentVirtualTags) is derived ONLY from // {EquipmentId, FolderPath, Name, DataType}. Expression/DependencyRefs/Historize are engine/write-side // only and are adopted by VirtualTagHostActor's INDEPENDENT respawn (DriverHostActor → ApplyVirtualTags), // so a delta changing ONLY those three leaves a byte-identical node and needs no address-space rebuild. // Whitelist-of-may-differ via `with` + the record's custom Equals: any OTHER field difference (current // or future) makes the override unequal → falls back to a full rebuild (safe default). private static bool VtagDeltaIsNodeIrrelevant(AddressSpacePlan.EquipmentVirtualTagDelta d) => (d.Previous with { Expression = d.Current.Expression, DependencyRefs = d.Current.DependencyRefs, Historize = d.Current.Historize, }).Equals(d.Current); // F10b: a CHANGED equipment tag whose ONLY differences are Writable / IsHistorized / // HistorianTagname / DataType / IsArray / ArrayLength (a plain value variable — no alarm condition node) // can be updated IN PLACE on the existing node via ISurgicalAddressSpaceSink.UpdateTagAttributes, // avoiding a full rebuild (preserving subscriptions). The presentation-shape fields (DataType / IsArray / // ArrayLength) join the whitelist now that the surgical sink swaps DataType + ValueRank + ArrayDimensions // in place (and raises a GeneralModelChangeEvent). FullName / DriverInstanceId / Name / identity / alarm // differences still fall through to a rebuild — FullName/DriverInstanceId re-route the node to a different // driver point, Name re-derives the NodeId, and an alarm flip turns the node into a Part 9 condition. The // override-unequal default also covers any future field. // REACH (live-verified): the shape path only fires for drivers whose TagConfig carries a stable // top-level "FullName" (Galaxy = tag_name.AttributeName; OpcUaClient = the node id) — there a DataType/array // edit leaves FullName untouched ⇒ surgical. For structured-TagConfig protocol drivers (Modbus/S7/AbCip/…) // AddressSpaceComposer.ExtractTagFullName falls back to the RAW TagConfig blob as FullName, so a DataType/ // array edit also mutates that blob ⇒ FullName differs ⇒ this returns false ⇒ full rebuild. That is the // correct safe default (a protocol driver's subscription needs re-spawning for a new shape anyway). private static bool TagDeltaIsSurgicalEligible(AddressSpacePlan.EquipmentTagDelta d) => d.Previous.Alarm is null && d.Current.Alarm is null && (d.Previous with { Writable = d.Current.Writable, IsHistorized = d.Current.IsHistorized, HistorianTagname = d.Current.HistorianTagname, DataType = d.Current.DataType, IsArray = d.Current.IsArray, ArrayLength = d.Current.ArrayLength, }).Equals(d.Current); /// The "no-event" condition state written to a removed equipment / alarm node before the /// rebuild tears it down: inactive, acked, confirmed, enabled, unshelved, severity 0, empty message. /// Drives Retain to false so a removed condition stops replaying on ConditionRefresh. private static readonly AlarmConditionSnapshot RemovedConditionState = new( Active: false, Acknowledged: true, Confirmed: true, Enabled: true, Shelving: AlarmShelvingKind.Unshelved, Severity: 0, Message: string.Empty); private void SafeWriteAlarmCondition(string nodeId, AlarmConditionSnapshot state, DateTime ts) { try { _sink.WriteAlarmCondition(nodeId, state, ts); } catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: WriteAlarmCondition threw for {Node}", nodeId); } } private void SafeMaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative) { try { _sink.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative); } catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: MaterialiseAlarmCondition threw for {Node}", alarmNodeId); } } } /// Summary of one apply pass. Useful for tests + audit-log entries on the deploy path. /// means a rebuild was ATTEMPTED; means the /// attempt threw (the sink's address space is now stale/partial). counts /// swallowed per-node sink failures in 's own passes (the removal /// alarm-condition writes). The Materialise* passes report their own failed-node tallies via their /// int returns — the publish actor sums both channels so a degraded apply is operator-visible /// (Error log + otopcua.opcua.apply.failed meter) instead of reported as optimistic success /// (archreview 01/S-1). New trailing fields are defaulted so every existing construction compiles. public sealed record AddressSpaceApplyOutcome( int RemovedNodes, int AddedNodes, int ChangedNodes, bool RebuildCalled, bool RebuildFailed = false, int FailedNodes = 0);