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
@@ -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);
}
}