feat(opcua): materialise SystemPlatform tags (Galaxy) as OPC UA variables
v2-ci / build (push) Failing after 47s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped

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
  "<FolderPath>.<Name>" 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).
This commit is contained in:
Joseph Doherty
2026-05-26 15:43:22 -04:00
parent 44b8a9c7ff
commit 7dfbca6469
10 changed files with 343 additions and 20 deletions
@@ -109,6 +109,69 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
}
/// <summary>
/// Ensure a Variable node exists at <paramref name="variableNodeId"/> parented under
/// <paramref name="parentFolderNodeId"/> (or root when null). Initial value=null, quality=Bad,
/// timestamp=epoch — <see cref="WriteValue"/> fills these in once driver data flows.
/// Idempotent. Materialises Galaxy / SystemPlatform tags so they're browseable before the
/// Galaxy driver issues SubscribeBulk.
/// </summary>
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;
}
}
/// <summary>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).</summary>
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,
};
/// <summary>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.</summary>
@@ -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);
}
/// <summary>
/// Materialise Galaxy / SystemPlatform-namespace tags from a composition snapshot:
/// for each <see cref="GalaxyTagPlan"/>, ensure its FolderPath segment exists (a folder
/// under the namespace root), then ensure a Variable node sits inside that folder for
/// the leaf <see cref="GalaxyTagPlan.DisplayName"/>. Variable starts with BadWaitingForInitialData;
/// the Galaxy driver's <c>OnDataChange</c> path fills the value in once SubscribeBulk lands.
/// Idempotent.
/// </summary>
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<string>(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 "<FolderPath>.<DisplayName>" 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); }
@@ -1,25 +1,40 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// <summary>Outcome of <see cref="Phase7Composer.Compose"/> — pure value tuple, no side effects.
/// <see cref="UnsAreas"/> + <see cref="UnsLines"/> 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.</summary>
/// its parent line id so the applier knows where to hang each equipment folder.
/// <see cref="GalaxyTags"/> carries SystemPlatform-namespace tags (Galaxy hierarchy) so the
/// applier can materialise their FolderPath + Variable nodes ahead of any driver subscribe.</summary>
public sealed record Phase7CompositionResult(
IReadOnlyList<UnsAreaProjection> UnsAreas,
IReadOnlyList<UnsLineProjection> UnsLines,
IReadOnlyList<EquipmentNode> EquipmentNodes,
IReadOnlyList<DriverInstancePlan> DriverInstancePlans,
IReadOnlyList<ScriptedAlarmPlan> ScriptedAlarmPlans)
IReadOnlyList<ScriptedAlarmPlan> ScriptedAlarmPlans,
IReadOnlyList<GalaxyTagPlan> GalaxyTags)
{
/// <summary>Convenience constructor for tests + earlier callers that don't yet carry UNS topology.</summary>
/// <summary>Convenience constructor for tests + earlier callers that don't carry UNS or Galaxy data.</summary>
public Phase7CompositionResult(
IReadOnlyList<EquipmentNode> equipmentNodes,
IReadOnlyList<DriverInstancePlan> driverInstancePlans,
IReadOnlyList<ScriptedAlarmPlan> scriptedAlarmPlans)
: this(Array.Empty<UnsAreaProjection>(), Array.Empty<UnsLineProjection>(),
equipmentNodes, driverInstancePlans, scriptedAlarmPlans)
equipmentNodes, driverInstancePlans, scriptedAlarmPlans, Array.Empty<GalaxyTagPlan>())
{
}
/// <summary>Convenience constructor for callers carrying UNS but not Galaxy data.</summary>
public Phase7CompositionResult(
IReadOnlyList<UnsAreaProjection> unsAreas,
IReadOnlyList<UnsLineProjection> unsLines,
IReadOnlyList<EquipmentNode> equipmentNodes,
IReadOnlyList<DriverInstancePlan> driverInstancePlans,
IReadOnlyList<ScriptedAlarmPlan> scriptedAlarmPlans)
: this(unsAreas, unsLines, equipmentNodes, driverInstancePlans, scriptedAlarmPlans, Array.Empty<GalaxyTagPlan>())
{
}
}
@@ -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);
/// <summary>
/// One Galaxy / SystemPlatform-namespace tag from a <see cref="Tag"/> row where
/// <see cref="Tag.EquipmentId"/> is null. Carries the FolderPath segment that the applier
/// turns into a folder, the leaf <see cref="DisplayName"/> for the Variable, the OPC UA
/// <see cref="DataType"/>, and the dot-form MXAccess reference (<see cref="MxAccessRef"/>)
/// that the Galaxy driver consumes when subscribing.
/// </summary>
public sealed record GalaxyTagPlan(
string TagId,
string DriverInstanceId,
string FolderPath,
string DisplayName,
string DataType,
string MxAccessRef);
/// <summary>
/// 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
/// </summary>
public static class Phase7Composer
{
/// <summary>Convenience overload for legacy callers + tests that don't yet supply UNS topology.</summary>
/// <summary>Convenience overload for legacy callers + tests that don't yet supply UNS / Galaxy data.</summary>
public static Phase7CompositionResult Compose(
IReadOnlyList<Equipment> equipment,
IReadOnlyList<DriverInstance> driverInstances,
IReadOnlyList<ScriptedAlarm> scriptedAlarms) =>
Compose(Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), equipment, driverInstances, scriptedAlarms);
Compose(Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), equipment, driverInstances, scriptedAlarms,
Array.Empty<Tag>(), Array.Empty<Namespace>());
/// <summary>UNS-aware overload that doesn't yet supply Galaxy tags.</summary>
public static Phase7CompositionResult Compose(
IReadOnlyList<UnsArea> unsAreas,
IReadOnlyList<UnsLine> unsLines,
IReadOnlyList<Equipment> equipment,
IReadOnlyList<DriverInstance> driverInstances,
IReadOnlyList<ScriptedAlarm> scriptedAlarms) =>
Compose(unsAreas, unsLines, equipment, driverInstances, scriptedAlarms,
Array.Empty<Tag>(), Array.Empty<Namespace>());
public static Phase7CompositionResult Compose(
IReadOnlyList<UnsArea> unsAreas,
IReadOnlyList<UnsLine> unsLines,
IReadOnlyList<Equipment> equipment,
IReadOnlyList<DriverInstance> driverInstances,
IReadOnlyList<ScriptedAlarm> scriptedAlarms)
IReadOnlyList<ScriptedAlarm> scriptedAlarms,
IReadOnlyList<Tag> tags,
IReadOnlyList<Namespace> 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);
}
}
@@ -21,16 +21,21 @@ public sealed record Phase7Plan(
IReadOnlyList<Phase7Plan.DriverDelta> ChangedDrivers,
IReadOnlyList<ScriptedAlarmPlan> AddedAlarms,
IReadOnlyList<ScriptedAlarmPlan> RemovedAlarms,
IReadOnlyList<Phase7Plan.AlarmDelta> ChangedAlarms)
IReadOnlyList<Phase7Plan.AlarmDelta> ChangedAlarms,
IReadOnlyList<GalaxyTagPlan> AddedGalaxyTags,
IReadOnlyList<GalaxyTagPlan> RemovedGalaxyTags,
IReadOnlyList<Phase7Plan.GalaxyTagDelta> 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<T> Added, IReadOnlyList<T> Removed, IReadOnlyList<TDelta> Changed)
@@ -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();
}