From 7dfbca646981103ba280e4398a1f3646516b059a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 15:43:22 -0400 Subject: [PATCH] feat(opcua): materialise SystemPlatform tags (Galaxy) as OPC UA variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the gap where Tag rows with EquipmentId=NULL + Namespace.Kind=SystemPlatform (Galaxy hierarchy) existed in ConfigDb but were never surfaced in the OPC UA address space. Now they materialise as Variable nodes under a folder named for their FolderPath, browseable through any OPC UA client. Layers touched: - IOpcUaAddressSpaceSink: new EnsureVariable(nodeId, parentFolderId, displayName, dataType) signature on the sink interface, NullSink, DeferredSink, SdkSink. - OtOpcUaNodeManager.EnsureVariable: creates a BaseDataVariableState parented under the named folder (or root), initial Value=null + StatusCode=BadWaitingForInitialData; resolves Tag.DataType strings to the matching OPC UA built-in NodeId. Idempotent. - Phase7CompositionResult: new GalaxyTags collection of GalaxyTagPlan records carrying (TagId, DriverInstanceId, FolderPath, DisplayName, DataType, MxAccessRef). Constructor overloads keep existing call sites compiling. - Phase7Composer.Compose: now takes Tag + Namespace inputs, filters for SystemPlatform-namespace tags with EquipmentId=NULL, emits GalaxyTagPlan rows with MXAccess ref "FolderPath.Name". - Phase7Plan: new AddedGalaxyTags / RemovedGalaxyTags / ChangedGalaxyTags collections + GalaxyTagDelta record; IsEmpty + needsRebuild updated. - Phase7Planner.Compute: diffs GalaxyTags by TagId via existing DiffById helper. - DeploymentArtifact.ParseComposition: reads the Tags + Namespaces + DriverInstances arrays the ConfigComposer already emits, applies the same SystemPlatform filter, returns the same GalaxyTagPlan list as the composer so artifact-side and compose-side plans agree. - Phase7Applier: new MaterialiseGalaxyTags pass that ensures one folder per distinct FolderPath then one Variable per tag. NodeId for the variable is "." matching the MXAccess ref so the future Galaxy SubscribeBulk wiring can address them directly. - OpcUaPublishActor.RebuildAddressSpace: invokes MaterialiseGalaxyTags after MaterialiseHierarchy. _lastApplied initialiser updated for the new ctor. - seed-clusters.sql: pre-existing TestMachine_001.TestAlarm001..003 rows needed no change — the composer/applier now picks them up automatically. Verified end-to-end via docker-dev: deploy click → driver-a logs "Phase7Applier: Galaxy tags materialised (tags=3, folders=1)" → OPC UA Client CLI browses the three Variable nodes under TestMachine_001 folder. Reads return BadWaitingForInitialData status (expected — Galaxy driver's SubscribeBulk wiring to push values into the nodes is the remaining follow-up). --- docker-dev/seed/seed-clusters.sql | 31 +++++++ .../OpcUa/DeferredAddressSpaceSink.cs | 3 + .../OpcUa/IOpcUaAddressSpaceSink.cs | 11 +++ .../OtOpcUaNodeManager.cs | 63 ++++++++++++++ .../Phase7Applier.cs | 58 +++++++++++-- .../Phase7Composer.cs | 83 ++++++++++++++++-- .../Phase7Plan.cs | 17 +++- .../SdkAddressSpaceSink.cs | 3 + .../Drivers/DeploymentArtifact.cs | 85 ++++++++++++++++++- .../OpcUa/OpcUaPublishActor.cs | 9 +- 10 files changed, 343 insertions(+), 20 deletions(-) diff --git a/docker-dev/seed/seed-clusters.sql b/docker-dev/seed/seed-clusters.sql index d47d40e..675f021 100644 --- a/docker-dev/seed/seed-clusters.sql +++ b/docker-dev/seed/seed-clusters.sql @@ -150,6 +150,36 @@ IF NOT EXISTS (SELECT 1 FROM dbo.DriverInstance WHERE DriverInstanceId = 'MAIN-g } }'); +------------------------------------------------------------------------------ +-- Galaxy test tags — TestMachine_001.TestAlarm001..003 +-- +-- SystemPlatform-namespace tags have EquipmentId=NULL and use FolderPath + +-- Name to address the MXAccess item. The Galaxy driver subscribes via the +-- "FolderPath.Name" MXAccess reference form; OPC UA browse path is the +-- equivalent "FolderPath/Name" under the SystemPlatform namespace. +------------------------------------------------------------------------------ + +IF NOT EXISTS (SELECT 1 FROM dbo.Tag WHERE TagId = 'MAIN-galaxy-TestMachine_001-TestAlarm001') + INSERT INTO dbo.Tag + (TagRowId, TagId, DriverInstanceId, DeviceId, EquipmentId, Name, FolderPath, DataType, AccessLevel, WriteIdempotent, PollGroupId, TagConfig) + VALUES + (NEWID(), 'MAIN-galaxy-TestMachine_001-TestAlarm001', 'MAIN-galaxy-mxgw', NULL, NULL, + 'TestAlarm001', 'TestMachine_001', 'Boolean', 0, 0, NULL, N'{}'); + +IF NOT EXISTS (SELECT 1 FROM dbo.Tag WHERE TagId = 'MAIN-galaxy-TestMachine_001-TestAlarm002') + INSERT INTO dbo.Tag + (TagRowId, TagId, DriverInstanceId, DeviceId, EquipmentId, Name, FolderPath, DataType, AccessLevel, WriteIdempotent, PollGroupId, TagConfig) + VALUES + (NEWID(), 'MAIN-galaxy-TestMachine_001-TestAlarm002', 'MAIN-galaxy-mxgw', NULL, NULL, + 'TestAlarm002', 'TestMachine_001', 'Boolean', 0, 0, NULL, N'{}'); + +IF NOT EXISTS (SELECT 1 FROM dbo.Tag WHERE TagId = 'MAIN-galaxy-TestMachine_001-TestAlarm003') + INSERT INTO dbo.Tag + (TagRowId, TagId, DriverInstanceId, DeviceId, EquipmentId, Name, FolderPath, DataType, AccessLevel, WriteIdempotent, PollGroupId, TagConfig) + VALUES + (NEWID(), 'MAIN-galaxy-TestMachine_001-TestAlarm003', 'MAIN-galaxy-mxgw', NULL, NULL, + 'TestAlarm003', 'TestMachine_001', 'Boolean', 0, 0, NULL, N'{}'); + COMMIT TRANSACTION; ------------------------------------------------------------------------------ @@ -162,3 +192,4 @@ SELECT NodeId, ClusterId, Host, OpcUaPort, ApplicationUri, ServiceLevelBase SELECT NamespaceId, ClusterId, Kind, NamespaceUri FROM dbo.Namespace ORDER BY ClusterId, NamespaceId; SELECT DriverInstanceId, ClusterId, DriverType, NamespaceId, Name FROM dbo.DriverInstance ORDER BY ClusterId, DriverInstanceId; +SELECT TagId, DriverInstanceId, FolderPath, Name, DataType FROM dbo.Tag ORDER BY DriverInstanceId, FolderPath, Name; diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs index 5cd8384..09ff634 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs @@ -30,5 +30,8 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) => _inner.EnsureFolder(folderNodeId, parentNodeId, displayName); + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) + => _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType); + public void RebuildAddressSpace() => _inner.RebuildAddressSpace(); } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs index 7082ddf..a4690e1 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs @@ -22,6 +22,16 @@ public interface IOpcUaAddressSpaceSink /// void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName); + /// + /// Ensure a Variable node exists at , parented under + /// (or the namespace root when null). Created with + /// Bad quality + null value; subsequent calls update both. + /// Used by Phase7Applier to materialise Galaxy / SystemPlatform tags ahead of any + /// driver-side subscribe so OPC UA clients can browse them. Idempotent. + /// + /// OPC UA built-in type name ("Boolean" / "Int32" / "Float" / etc.). + void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType); + /// /// Tear down + repopulate the address space. Called by OpcUaPublishActor after a /// successful deployment apply so the node manager reflects the new config. Idempotent. @@ -42,5 +52,6 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { } public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) { } public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { } + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) { } public void RebuildAddressSpace() { } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index b71af57..4f1a88c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -109,6 +109,69 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } } + /// + /// Ensure a Variable node exists at parented under + /// (or root when null). Initial value=null, quality=Bad, + /// timestamp=epoch — fills these in once driver data flows. + /// Idempotent. Materialises Galaxy / SystemPlatform tags so they're browseable before the + /// Galaxy driver issues SubscribeBulk. + /// + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) + { + ArgumentException.ThrowIfNullOrEmpty(variableNodeId); + ArgumentException.ThrowIfNullOrEmpty(displayName); + + // If already present, leave it alone (idempotent re-applies). + if (_variables.ContainsKey(variableNodeId)) return; + + lock (Lock) + { + if (_variables.ContainsKey(variableNodeId)) return; + + var parent = ResolveParentFolder(parentFolderNodeId); + var variable = new BaseDataVariableState(parent) + { + NodeId = new NodeId(variableNodeId, NamespaceIndex), + BrowseName = new QualifiedName(variableNodeId, NamespaceIndex), + DisplayName = displayName, + TypeDefinitionId = VariableTypeIds.BaseDataVariableType, + ReferenceTypeId = ReferenceTypeIds.Organizes, + DataType = ResolveBuiltInDataType(dataType), + ValueRank = ValueRanks.Scalar, + AccessLevel = AccessLevels.CurrentRead, + UserAccessLevel = AccessLevels.CurrentRead, + Historizing = false, + Value = null, + StatusCode = StatusCodes.BadWaitingForInitialData, + Timestamp = DateTime.MinValue, + }; + parent.AddChild(variable); + AddPredefinedNode(SystemContext, variable); + _variables[variableNodeId] = variable; + } + } + + /// Map a Tag.DataType string ("Boolean", "Int32", "Float", "Double", "String", + /// "DateTime") to the OPC UA built-in NodeId. Unknown names fall back to BaseDataType + /// (matches CreateVariable's default for lazy-created nodes). + private static NodeId ResolveBuiltInDataType(string dataType) => dataType switch + { + "Boolean" => DataTypeIds.Boolean, + "SByte" => DataTypeIds.SByte, + "Byte" => DataTypeIds.Byte, + "Int16" => DataTypeIds.Int16, + "UInt16" => DataTypeIds.UInt16, + "Int32" => DataTypeIds.Int32, + "UInt32" => DataTypeIds.UInt32, + "Int64" => DataTypeIds.Int64, + "UInt64" => DataTypeIds.UInt64, + "Float" => DataTypeIds.Float, + "Double" => DataTypeIds.Double, + "String" => DataTypeIds.String, + "DateTime" => DataTypeIds.DateTime, + _ => DataTypeIds.BaseDataType, + }; + /// Clear every registered variable + folder from the address space. Phase7Applier /// calls this when Equipment/Alarm topology changes; the populator then re-adds via /// EnsureFolder + WriteValue on the next pass. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs index 172a30d..780d63a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs @@ -64,16 +64,19 @@ public sealed class Phase7Applier } var changedCount = - plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count; + plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count + + plan.ChangedGalaxyTags.Count; var addedCount = - plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count; + plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count + + plan.AddedGalaxyTags.Count; - // Any add/remove of Equipment or ScriptedAlarm requires a real address-space rebuild. - // Driver-instance changes don't touch the address-space topology directly — they go - // through DriverHostActor's spawn-plan in Runtime. + // Any add/remove of Equipment, ScriptedAlarm, or Galaxy tag topology requires a real + // address-space rebuild. Driver-instance changes don't touch the address-space topology + // directly — they go through DriverHostActor's spawn-plan in Runtime. var needsRebuild = plan.AddedEquipment.Count > 0 || plan.RemovedEquipment.Count > 0 || - plan.AddedAlarms.Count > 0 || plan.RemovedAlarms.Count > 0; + plan.AddedAlarms.Count > 0 || plan.RemovedAlarms.Count > 0 || + plan.AddedGalaxyTags.Count > 0 || plan.RemovedGalaxyTags.Count > 0; if (needsRebuild) { @@ -125,12 +128,55 @@ public sealed class Phase7Applier composition.UnsAreas.Count, composition.UnsLines.Count, composition.EquipmentNodes.Count); } + /// + /// Materialise Galaxy / SystemPlatform-namespace tags from a composition snapshot: + /// for each , ensure its FolderPath segment exists (a folder + /// under the namespace root), then ensure a Variable node sits inside that folder for + /// the leaf . Variable starts with BadWaitingForInitialData; + /// the Galaxy driver's OnDataChange path fills the value in once SubscribeBulk lands. + /// Idempotent. + /// + public void MaterialiseGalaxyTags(Phase7CompositionResult composition) + { + ArgumentNullException.ThrowIfNull(composition); + if (composition.GalaxyTags.Count == 0) return; + + // Folders first — each distinct FolderPath becomes one folder under the root. + var foldersCreated = new HashSet(StringComparer.Ordinal); + foreach (var tag in composition.GalaxyTags) + { + if (string.IsNullOrWhiteSpace(tag.FolderPath)) continue; + if (!foldersCreated.Add(tag.FolderPath)) continue; + SafeEnsureFolder(tag.FolderPath, parentNodeId: null, displayName: tag.FolderPath); + } + + // Variables: NodeId is "." so it matches the MXAccess ref the + // Galaxy driver subscribes to. Browse-path lookup via OPC UA Translate is the canonical + // resolution; flat NodeId keeps the address space lookup cheap. + foreach (var tag in composition.GalaxyTags) + { + var nodeId = string.IsNullOrWhiteSpace(tag.FolderPath) ? tag.DisplayName : tag.MxAccessRef; + var parent = string.IsNullOrWhiteSpace(tag.FolderPath) ? null : tag.FolderPath; + SafeEnsureVariable(nodeId, parent, tag.DisplayName, tag.DataType); + } + + _logger.LogInformation( + "Phase7Applier: Galaxy tags materialised (tags={Tags}, folders={Folders})", + composition.GalaxyTags.Count, foldersCreated.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) + { + try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType); } + catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: EnsureVariable threw for {Node}", nodeId); } + } + private void SafeWriteAlarmState(string nodeId, bool active, bool acknowledged, DateTime ts) { try { _sink.WriteAlarmState(nodeId, active, acknowledged, ts); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs index bb23222..946c42a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs @@ -1,25 +1,40 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; /// Outcome of — pure value tuple, no side effects. /// + carry the UNS topology so the applier can /// materialise the Area/Line/Equipment folder hierarchy in the address space; equipment carries -/// its parent line id so the applier knows where to hang each equipment folder. +/// its parent line id so the applier knows where to hang each equipment folder. +/// carries SystemPlatform-namespace tags (Galaxy hierarchy) so the +/// applier can materialise their FolderPath + Variable nodes ahead of any driver subscribe. public sealed record Phase7CompositionResult( IReadOnlyList UnsAreas, IReadOnlyList UnsLines, IReadOnlyList EquipmentNodes, IReadOnlyList DriverInstancePlans, - IReadOnlyList ScriptedAlarmPlans) + IReadOnlyList ScriptedAlarmPlans, + IReadOnlyList GalaxyTags) { - /// Convenience constructor for tests + earlier callers that don't yet carry UNS topology. + /// Convenience constructor for tests + earlier callers that don't carry UNS or Galaxy data. public Phase7CompositionResult( IReadOnlyList equipmentNodes, IReadOnlyList driverInstancePlans, IReadOnlyList scriptedAlarmPlans) : this(Array.Empty(), Array.Empty(), - equipmentNodes, driverInstancePlans, scriptedAlarmPlans) + equipmentNodes, driverInstancePlans, scriptedAlarmPlans, Array.Empty()) + { + } + + /// Convenience constructor for callers carrying UNS but not Galaxy data. + public Phase7CompositionResult( + IReadOnlyList unsAreas, + IReadOnlyList unsLines, + IReadOnlyList equipmentNodes, + IReadOnlyList driverInstancePlans, + IReadOnlyList scriptedAlarmPlans) + : this(unsAreas, unsLines, equipmentNodes, driverInstancePlans, scriptedAlarmPlans, Array.Empty()) { } } @@ -30,6 +45,21 @@ public sealed record EquipmentNode(string EquipmentId, string DisplayName, strin public sealed record DriverInstancePlan(string DriverInstanceId, string DriverType, string ConfigJson); public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentId, string PredicateScriptId, string MessageTemplate); +/// +/// One Galaxy / SystemPlatform-namespace tag from a row where +/// is null. Carries the FolderPath segment that the applier +/// turns into a folder, the leaf for the Variable, the OPC UA +/// , and the dot-form MXAccess reference () +/// that the Galaxy driver consumes when subscribing. +/// +public sealed record GalaxyTagPlan( + string TagId, + string DriverInstanceId, + string FolderPath, + string DisplayName, + string DataType, + string MxAccessRef); + /// /// Pure composer that flattens the live-edit DB tables into the address-space build plan a /// driver-role host needs. Same inputs → same outputs, no logging, no DB writes. The driver-role @@ -43,19 +73,32 @@ public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentI /// public static class Phase7Composer { - /// Convenience overload for legacy callers + tests that don't yet supply UNS topology. + /// Convenience overload for legacy callers + tests that don't yet supply UNS / Galaxy data. public static Phase7CompositionResult Compose( IReadOnlyList equipment, IReadOnlyList driverInstances, IReadOnlyList scriptedAlarms) => - Compose(Array.Empty(), Array.Empty(), equipment, driverInstances, scriptedAlarms); + Compose(Array.Empty(), Array.Empty(), equipment, driverInstances, scriptedAlarms, + Array.Empty(), Array.Empty()); + + /// UNS-aware overload that doesn't yet supply Galaxy tags. + public static Phase7CompositionResult Compose( + IReadOnlyList unsAreas, + IReadOnlyList unsLines, + IReadOnlyList equipment, + IReadOnlyList driverInstances, + IReadOnlyList scriptedAlarms) => + Compose(unsAreas, unsLines, equipment, driverInstances, scriptedAlarms, + Array.Empty(), Array.Empty()); public static Phase7CompositionResult Compose( IReadOnlyList unsAreas, IReadOnlyList unsLines, IReadOnlyList equipment, IReadOnlyList driverInstances, - IReadOnlyList scriptedAlarms) + IReadOnlyList scriptedAlarms, + IReadOnlyList tags, + IReadOnlyList namespaces) { var areas = unsAreas .OrderBy(a => a.UnsAreaId, StringComparer.Ordinal) @@ -82,6 +125,30 @@ public static class Phase7Composer .Select(a => new ScriptedAlarmPlan(a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId, a.MessageTemplate)) .ToList(); - return new Phase7CompositionResult(areas, lines, nodes, plans, alarms); + // SystemPlatform tags = Galaxy tags. Match each tag to its DriverInstance and that + // driver's Namespace; emit only when the namespace kind is SystemPlatform AND the tag + // has no EquipmentId (per the entity invariant for SystemPlatform). + var driversById = driverInstances.ToDictionary(d => d.DriverInstanceId, StringComparer.Ordinal); + var namespacesById = namespaces.ToDictionary(n => n.NamespaceId, StringComparer.Ordinal); + + var galaxyTags = tags + .Where(t => t.EquipmentId is null) + .Where(t => driversById.TryGetValue(t.DriverInstanceId, out var di) + && namespacesById.TryGetValue(di.NamespaceId, out var ns) + && ns.Kind == NamespaceKind.SystemPlatform) + .OrderBy(t => t.DriverInstanceId, StringComparer.Ordinal) + .ThenBy(t => t.FolderPath, StringComparer.Ordinal) + .ThenBy(t => t.Name, StringComparer.Ordinal) + .Select(t => new GalaxyTagPlan( + TagId: t.TagId, + DriverInstanceId: t.DriverInstanceId, + FolderPath: t.FolderPath ?? string.Empty, + DisplayName: t.Name, + DataType: t.DataType, + // MXAccess reference: "FolderPath.Name" when FolderPath is set, else just "Name". + MxAccessRef: string.IsNullOrWhiteSpace(t.FolderPath) ? t.Name : $"{t.FolderPath}.{t.Name}")) + .ToList(); + + return new Phase7CompositionResult(areas, lines, nodes, plans, alarms, galaxyTags); } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Plan.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Plan.cs index 284998c..7a48f9b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Plan.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Plan.cs @@ -21,16 +21,21 @@ public sealed record Phase7Plan( IReadOnlyList ChangedDrivers, IReadOnlyList AddedAlarms, IReadOnlyList RemovedAlarms, - IReadOnlyList ChangedAlarms) + IReadOnlyList ChangedAlarms, + IReadOnlyList AddedGalaxyTags, + IReadOnlyList RemovedGalaxyTags, + IReadOnlyList ChangedGalaxyTags) { 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; + AddedAlarms.Count == 0 && RemovedAlarms.Count == 0 && ChangedAlarms.Count == 0 && + AddedGalaxyTags.Count == 0 && RemovedGalaxyTags.Count == 0 && ChangedGalaxyTags.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 GalaxyTagDelta(GalaxyTagPlan Previous, GalaxyTagPlan Current); } public static class Phase7Planner @@ -61,10 +66,16 @@ public static class Phase7Planner a => a.ScriptedAlarmId, (a, b) => new Phase7Plan.AlarmDelta(a, b)); + var (addedGalaxy, removedGalaxy, changedGalaxy) = DiffById( + previous.GalaxyTags, next.GalaxyTags, + t => t.TagId, + (a, b) => new Phase7Plan.GalaxyTagDelta(a, b)); + return new Phase7Plan( addedEq, removedEq, changedEq, addedDrv, removedDrv, changedDrv, - addedAlarm, removedAlarm, changedAlarm); + addedAlarm, removedAlarm, changedAlarm, + addedGalaxy, removedGalaxy, changedGalaxy); } private static (IReadOnlyList Added, IReadOnlyList Removed, IReadOnlyList Changed) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs index 8395816..b013f85 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs @@ -27,5 +27,8 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) => _nodeManager.EnsureFolder(folderNodeId, parentNodeId, displayName); + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) + => _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType); + public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace(); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs index b2a8d12..b7b4992 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs @@ -101,8 +101,9 @@ public static class DeploymentArtifact var equipment = ReadArray(root, "Equipment", ReadEquipmentNode); var drivers = ReadArray(root, "DriverInstances", ReadDriverPlan); var alarms = ReadArray(root, "ScriptedAlarms", ReadAlarmPlan); + var galaxyTags = BuildGalaxyTagPlans(root, drivers); - return new Phase7CompositionResult(areas, lines, equipment, drivers, alarms); + return new Phase7CompositionResult(areas, lines, equipment, drivers, alarms, galaxyTags); } catch (JsonException) { @@ -115,7 +116,87 @@ public static class DeploymentArtifact Array.Empty(), Array.Empty(), Array.Empty(), - Array.Empty()); + Array.Empty(), + Array.Empty()); + + /// + /// Cross-reference the artifact's Tags + Namespaces + DriverInstances arrays to find + /// SystemPlatform-namespace tags (Galaxy hierarchy), then emit one + /// per qualifying tag. Mirrors Phase7Composer.Compose's filter so a compose-side + /// plan and an artifact-decode plan agree on the same set of tags. + /// + private static IReadOnlyList BuildGalaxyTagPlans(JsonElement root, IReadOnlyList drivers) + { + if (!root.TryGetProperty("Tags", out var tagsArr) || tagsArr.ValueKind != JsonValueKind.Array) + return Array.Empty(); + if (!root.TryGetProperty("Namespaces", out var nsArr) || nsArr.ValueKind != JsonValueKind.Array) + return Array.Empty(); + if (!root.TryGetProperty("DriverInstances", out var diArr) || diArr.ValueKind != JsonValueKind.Array) + return Array.Empty(); + + // namespaceId → Kind ("SystemPlatform"/"Equipment"/"Simulated") — enum serialises as int by default, + // but ConfigComposer's snapshot uses default JsonSerializer which writes numbers. Tolerate both. + var systemPlatformNamespaces = new HashSet(StringComparer.Ordinal); + foreach (var el in nsArr.EnumerateArray()) + { + if (el.ValueKind != JsonValueKind.Object) continue; + var id = el.TryGetProperty("NamespaceId", out var idEl) ? idEl.GetString() : null; + if (string.IsNullOrWhiteSpace(id)) continue; + if (!el.TryGetProperty("Kind", out var kindEl)) continue; + var isSystemPlatform = kindEl.ValueKind switch + { + JsonValueKind.Number => kindEl.GetInt32() == 1, // NamespaceKind.SystemPlatform = 1 + JsonValueKind.String => string.Equals(kindEl.GetString(), "SystemPlatform", StringComparison.Ordinal), + _ => false, + }; + if (isSystemPlatform) systemPlatformNamespaces.Add(id!); + } + + // driverInstanceId → namespaceId + var driverToNamespace = new Dictionary(StringComparer.Ordinal); + foreach (var el in diArr.EnumerateArray()) + { + if (el.ValueKind != JsonValueKind.Object) continue; + var id = el.TryGetProperty("DriverInstanceId", out var idEl) ? idEl.GetString() : null; + var ns = el.TryGetProperty("NamespaceId", out var nsEl) ? nsEl.GetString() : null; + if (!string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(ns)) + driverToNamespace[id!] = ns!; + } + + var result = new List(tagsArr.GetArrayLength()); + foreach (var el in tagsArr.EnumerateArray()) + { + if (el.ValueKind != JsonValueKind.Object) continue; + // Skip tags with non-null EquipmentId (Equipment-namespace tags belong to a different path). + if (el.TryGetProperty("EquipmentId", out var eqEl) && eqEl.ValueKind != JsonValueKind.Null) continue; + + var tagId = el.TryGetProperty("TagId", out var tEl) ? tEl.GetString() : null; + var di = el.TryGetProperty("DriverInstanceId", out var diEl) ? diEl.GetString() : null; + var name = el.TryGetProperty("Name", out var nmEl) ? nmEl.GetString() : null; + var folder = el.TryGetProperty("FolderPath", out var fpEl) && fpEl.ValueKind != JsonValueKind.Null + ? fpEl.GetString() : null; + var dataType = el.TryGetProperty("DataType", out var dtEl) ? dtEl.GetString() : null; + + if (string.IsNullOrWhiteSpace(tagId) || string.IsNullOrWhiteSpace(di) || string.IsNullOrWhiteSpace(name)) + continue; + if (!driverToNamespace.TryGetValue(di!, out var nsId)) continue; + if (!systemPlatformNamespaces.Contains(nsId)) continue; + + var folderPath = folder ?? string.Empty; + var mxRef = string.IsNullOrWhiteSpace(folderPath) ? name! : $"{folderPath}.{name}"; + result.Add(new GalaxyTagPlan(tagId!, di!, folderPath, name!, dataType ?? "BaseDataType", mxRef)); + } + + result.Sort((a, b) => + { + var byDriver = string.CompareOrdinal(a.DriverInstanceId, b.DriverInstanceId); + if (byDriver != 0) return byDriver; + var byFolder = string.CompareOrdinal(a.FolderPath, b.FolderPath); + if (byFolder != 0) return byFolder; + return string.CompareOrdinal(a.DisplayName, b.DisplayName); + }); + return result; + } private static IReadOnlyList ReadArray(JsonElement root, string propertyName, Func reader) where T : class diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs index e2187e9..d0e5132 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -45,9 +45,12 @@ public sealed class OpcUaPublishActor : ReceiveActor private int _writes; private byte _lastServiceLevel; private Phase7CompositionResult _lastApplied = new( + Array.Empty(), + Array.Empty(), Array.Empty(), Array.Empty(), - Array.Empty()); + Array.Empty(), + Array.Empty()); public int WriteCount => _writes; public byte LastServiceLevel => _lastServiceLevel; @@ -190,6 +193,10 @@ public sealed class OpcUaPublishActor : ReceiveActor // clients see Area/Line/Equipment as proper folders. Idempotent; Phase7Applier // skips folders that already exist with the same node id. _applier.MaterialiseHierarchy(composition); + // Galaxy / SystemPlatform tags get their own pass: ensures their FolderPath folder + // + Variable node exist so clients can browse them. The Galaxy driver fills values + // on a future SubscribeBulk pass; until then variables show BadWaitingForInitialData. + _applier.MaterialiseGalaxyTags(composition); OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair("kind", "rebuild")); _log.Info("OpcUaPublish: applied rebuild (correlation={Correlation}, added={Added}, removed={Removed}, changed={Changed}, rebuild={Rebuild})",