feat(opcua): materialise SystemPlatform tags (Galaxy) as OPC UA variables
Some checks failed
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
Some checks failed
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:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -22,6 +22,16 @@ public interface IOpcUaAddressSpaceSink
|
||||
/// </summary>
|
||||
void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName);
|
||||
|
||||
/// <summary>
|
||||
/// Ensure a Variable node exists at <paramref name="variableNodeId"/>, parented under
|
||||
/// <paramref name="parentFolderNodeId"/> (or the namespace root when null). Created with
|
||||
/// Bad quality + null value; subsequent <see cref="WriteValue"/> calls update both.
|
||||
/// Used by <c>Phase7Applier</c> to materialise Galaxy / SystemPlatform tags ahead of any
|
||||
/// driver-side subscribe so OPC UA clients can browse them. Idempotent.
|
||||
/// </summary>
|
||||
/// <param name="dataType">OPC UA built-in type name ("Boolean" / "Int32" / "Float" / etc.).</param>
|
||||
void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType);
|
||||
|
||||
/// <summary>
|
||||
/// Tear down + repopulate the address space. Called by <c>OpcUaPublishActor</c> 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() { }
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<UnsLineProjection>(),
|
||||
Array.Empty<EquipmentNode>(),
|
||||
Array.Empty<DriverInstancePlan>(),
|
||||
Array.Empty<ScriptedAlarmPlan>());
|
||||
Array.Empty<ScriptedAlarmPlan>(),
|
||||
Array.Empty<GalaxyTagPlan>());
|
||||
|
||||
/// <summary>
|
||||
/// Cross-reference the artifact's Tags + Namespaces + DriverInstances arrays to find
|
||||
/// SystemPlatform-namespace tags (Galaxy hierarchy), then emit one <see cref="GalaxyTagPlan"/>
|
||||
/// per qualifying tag. Mirrors <c>Phase7Composer.Compose</c>'s filter so a compose-side
|
||||
/// plan and an artifact-decode plan agree on the same set of tags.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<GalaxyTagPlan> BuildGalaxyTagPlans(JsonElement root, IReadOnlyList<DriverInstancePlan> drivers)
|
||||
{
|
||||
if (!root.TryGetProperty("Tags", out var tagsArr) || tagsArr.ValueKind != JsonValueKind.Array)
|
||||
return Array.Empty<GalaxyTagPlan>();
|
||||
if (!root.TryGetProperty("Namespaces", out var nsArr) || nsArr.ValueKind != JsonValueKind.Array)
|
||||
return Array.Empty<GalaxyTagPlan>();
|
||||
if (!root.TryGetProperty("DriverInstances", out var diArr) || diArr.ValueKind != JsonValueKind.Array)
|
||||
return Array.Empty<GalaxyTagPlan>();
|
||||
|
||||
// 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<string>(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<string, string>(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<GalaxyTagPlan>(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<T> ReadArray<T>(JsonElement root, string propertyName, Func<JsonElement, T?> reader)
|
||||
where T : class
|
||||
|
||||
@@ -45,9 +45,12 @@ public sealed class OpcUaPublishActor : ReceiveActor
|
||||
private int _writes;
|
||||
private byte _lastServiceLevel;
|
||||
private Phase7CompositionResult _lastApplied = new(
|
||||
Array.Empty<UnsAreaProjection>(),
|
||||
Array.Empty<UnsLineProjection>(),
|
||||
Array.Empty<EquipmentNode>(),
|
||||
Array.Empty<DriverInstancePlan>(),
|
||||
Array.Empty<ScriptedAlarmPlan>());
|
||||
Array.Empty<ScriptedAlarmPlan>(),
|
||||
Array.Empty<GalaxyTagPlan>());
|
||||
|
||||
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<string, object?>("kind", "rebuild"));
|
||||
_log.Info("OpcUaPublish: applied rebuild (correlation={Correlation}, added={Added}, removed={Removed}, changed={Changed}, rebuild={Rebuild})",
|
||||
|
||||
Reference in New Issue
Block a user