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})",