using Microsoft.Extensions.Logging; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; 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 Task 47 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 Phase7Applier { private readonly IOpcUaAddressSpaceSink _sink; private readonly ILogger _logger; /// Initializes a new instance of the Phase7Applier class. /// The OPC UA address space sink to apply changes to. /// The logger instance. public Phase7Applier(IOpcUaAddressSpaceSink sink, ILogger logger) { ArgumentNullException.ThrowIfNull(sink); ArgumentNullException.ThrowIfNull(logger); _sink = sink; _logger = logger; } /// /// 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 Phase7ApplyOutcome summarizing the applied changes. public Phase7ApplyOutcome Apply(Phase7Plan plan) { ArgumentNullException.ThrowIfNull(plan); if (plan.IsEmpty) { _logger.LogDebug("Phase7Applier: plan is empty; skipping sink writes"); return new Phase7ApplyOutcome(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 Phase7ApplyOutcome.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; 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/Writable, or an edited VirtualTag expression) 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. var needsRebuild = 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.Count > 0 || plan.AddedEquipmentVirtualTags.Count > 0 || plan.RemovedEquipmentVirtualTags.Count > 0 || plan.ChangedEquipmentVirtualTags.Count > 0; if (needsRebuild) { try { _sink.RebuildAddressSpace(); } catch (Exception ex) { _logger.LogError(ex, "Phase7Applier: sink.RebuildAddressSpace threw"); } } _logger.LogInformation( "Phase7Applier: applied plan (added={Added}, removed={Removed}, changed={Changed}, rebuild={Rebuild})", addedCount, removedCount, changedCount, needsRebuild); return new Phase7ApplyOutcome(removedCount, addedCount, changedCount, needsRebuild); } /// /// #85 — 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(Phase7CompositionResult 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( "Phase7Applier: 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. /// /// Task 0 architecture decisions (recorded per the equipment-namespace-structure /// plan). Decision #1 = A — a sink-based pass, NOT a reuse of /// EquipmentNodeWalker: no sink-backed IAddressSpaceBuilder adapter exists /// (GenericDriverNodeManager.CapturingBuilder 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); 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(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(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( "Phase7Applier: equipment tags materialised (tags={Tags}, equipment={Equipment})", composition.EquipmentTags.Count, composition.EquipmentTags.Select(t => t.EquipmentId).Distinct(StringComparer.Ordinal).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(Phase7CompositionResult 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( "Phase7Applier: 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(Phase7CompositionResult 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( "Phase7Applier: 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, "Phase7Applier: 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, "Phase7Applier: EnsureVariable threw for {Node}", nodeId); } } /// 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, "Phase7Applier: 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, "Phase7Applier: MaterialiseAlarmCondition threw for {Node}", alarmNodeId); } } } /// Summary of one apply pass. Useful for tests + audit-log entries on the deploy path. public sealed record Phase7ApplyOutcome( int RemovedNodes, int AddedNodes, int ChangedNodes, bool RebuildCalled);