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