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;
|
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 NamespaceId, ClusterId, Kind, NamespaceUri FROM dbo.Namespace ORDER BY ClusterId, NamespaceId;
|
||||||
SELECT DriverInstanceId, ClusterId, DriverType, NamespaceId, Name
|
SELECT DriverInstanceId, ClusterId, DriverType, NamespaceId, Name
|
||||||
FROM dbo.DriverInstance ORDER BY ClusterId, DriverInstanceId;
|
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)
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
||||||
=> _inner.EnsureFolder(folderNodeId, parentNodeId, 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();
|
public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,16 @@ public interface IOpcUaAddressSpaceSink
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName);
|
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>
|
/// <summary>
|
||||||
/// Tear down + repopulate the address space. Called by <c>OpcUaPublishActor</c> after a
|
/// 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.
|
/// 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 WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
||||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) { }
|
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) { }
|
||||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
|
||||||
|
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) { }
|
||||||
public void RebuildAddressSpace() { }
|
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
|
/// <summary>Clear every registered variable + folder from the address space. Phase7Applier
|
||||||
/// calls this when Equipment/Alarm topology changes; the populator then re-adds via
|
/// calls this when Equipment/Alarm topology changes; the populator then re-adds via
|
||||||
/// EnsureFolder + WriteValue on the next pass.</summary>
|
/// EnsureFolder + WriteValue on the next pass.</summary>
|
||||||
|
|||||||
@@ -64,16 +64,19 @@ public sealed class Phase7Applier
|
|||||||
}
|
}
|
||||||
|
|
||||||
var changedCount =
|
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 =
|
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.
|
// Any add/remove of Equipment, ScriptedAlarm, or Galaxy tag topology requires a real
|
||||||
// Driver-instance changes don't touch the address-space topology directly — they go
|
// address-space rebuild. Driver-instance changes don't touch the address-space topology
|
||||||
// through DriverHostActor's spawn-plan in Runtime.
|
// directly — they go through DriverHostActor's spawn-plan in Runtime.
|
||||||
var needsRebuild =
|
var needsRebuild =
|
||||||
plan.AddedEquipment.Count > 0 || plan.RemovedEquipment.Count > 0 ||
|
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)
|
if (needsRebuild)
|
||||||
{
|
{
|
||||||
@@ -125,12 +128,55 @@ public sealed class Phase7Applier
|
|||||||
composition.UnsAreas.Count, composition.UnsLines.Count, composition.EquipmentNodes.Count);
|
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)
|
private void SafeEnsureFolder(string nodeId, string? parentNodeId, string displayName)
|
||||||
{
|
{
|
||||||
try { _sink.EnsureFolder(nodeId, parentNodeId, displayName); }
|
try { _sink.EnsureFolder(nodeId, parentNodeId, displayName); }
|
||||||
catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: EnsureFolder threw for {Node}", nodeId); }
|
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)
|
private void SafeWriteAlarmState(string nodeId, bool active, bool acknowledged, DateTime ts)
|
||||||
{
|
{
|
||||||
try { _sink.WriteAlarmState(nodeId, active, acknowledged, 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.Entities;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||||
|
|
||||||
/// <summary>Outcome of <see cref="Phase7Composer.Compose"/> — pure value tuple, no side effects.
|
/// <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
|
/// <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
|
/// 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(
|
public sealed record Phase7CompositionResult(
|
||||||
IReadOnlyList<UnsAreaProjection> UnsAreas,
|
IReadOnlyList<UnsAreaProjection> UnsAreas,
|
||||||
IReadOnlyList<UnsLineProjection> UnsLines,
|
IReadOnlyList<UnsLineProjection> UnsLines,
|
||||||
IReadOnlyList<EquipmentNode> EquipmentNodes,
|
IReadOnlyList<EquipmentNode> EquipmentNodes,
|
||||||
IReadOnlyList<DriverInstancePlan> DriverInstancePlans,
|
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(
|
public Phase7CompositionResult(
|
||||||
IReadOnlyList<EquipmentNode> equipmentNodes,
|
IReadOnlyList<EquipmentNode> equipmentNodes,
|
||||||
IReadOnlyList<DriverInstancePlan> driverInstancePlans,
|
IReadOnlyList<DriverInstancePlan> driverInstancePlans,
|
||||||
IReadOnlyList<ScriptedAlarmPlan> scriptedAlarmPlans)
|
IReadOnlyList<ScriptedAlarmPlan> scriptedAlarmPlans)
|
||||||
: this(Array.Empty<UnsAreaProjection>(), Array.Empty<UnsLineProjection>(),
|
: 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 DriverInstancePlan(string DriverInstanceId, string DriverType, string ConfigJson);
|
||||||
public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentId, string PredicateScriptId, string MessageTemplate);
|
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>
|
/// <summary>
|
||||||
/// Pure composer that flattens the live-edit DB tables into the address-space build plan a
|
/// 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
|
/// 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>
|
/// </summary>
|
||||||
public static class Phase7Composer
|
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(
|
public static Phase7CompositionResult Compose(
|
||||||
IReadOnlyList<Equipment> equipment,
|
IReadOnlyList<Equipment> equipment,
|
||||||
IReadOnlyList<DriverInstance> driverInstances,
|
IReadOnlyList<DriverInstance> driverInstances,
|
||||||
IReadOnlyList<ScriptedAlarm> scriptedAlarms) =>
|
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(
|
public static Phase7CompositionResult Compose(
|
||||||
IReadOnlyList<UnsArea> unsAreas,
|
IReadOnlyList<UnsArea> unsAreas,
|
||||||
IReadOnlyList<UnsLine> unsLines,
|
IReadOnlyList<UnsLine> unsLines,
|
||||||
IReadOnlyList<Equipment> equipment,
|
IReadOnlyList<Equipment> equipment,
|
||||||
IReadOnlyList<DriverInstance> driverInstances,
|
IReadOnlyList<DriverInstance> driverInstances,
|
||||||
IReadOnlyList<ScriptedAlarm> scriptedAlarms)
|
IReadOnlyList<ScriptedAlarm> scriptedAlarms,
|
||||||
|
IReadOnlyList<Tag> tags,
|
||||||
|
IReadOnlyList<Namespace> namespaces)
|
||||||
{
|
{
|
||||||
var areas = unsAreas
|
var areas = unsAreas
|
||||||
.OrderBy(a => a.UnsAreaId, StringComparer.Ordinal)
|
.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))
|
.Select(a => new ScriptedAlarmPlan(a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId, a.MessageTemplate))
|
||||||
.ToList();
|
.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<Phase7Plan.DriverDelta> ChangedDrivers,
|
||||||
IReadOnlyList<ScriptedAlarmPlan> AddedAlarms,
|
IReadOnlyList<ScriptedAlarmPlan> AddedAlarms,
|
||||||
IReadOnlyList<ScriptedAlarmPlan> RemovedAlarms,
|
IReadOnlyList<ScriptedAlarmPlan> RemovedAlarms,
|
||||||
IReadOnlyList<Phase7Plan.AlarmDelta> ChangedAlarms)
|
IReadOnlyList<Phase7Plan.AlarmDelta> ChangedAlarms,
|
||||||
|
IReadOnlyList<GalaxyTagPlan> AddedGalaxyTags,
|
||||||
|
IReadOnlyList<GalaxyTagPlan> RemovedGalaxyTags,
|
||||||
|
IReadOnlyList<Phase7Plan.GalaxyTagDelta> ChangedGalaxyTags)
|
||||||
{
|
{
|
||||||
public bool IsEmpty =>
|
public bool IsEmpty =>
|
||||||
AddedEquipment.Count == 0 && RemovedEquipment.Count == 0 && ChangedEquipment.Count == 0 &&
|
AddedEquipment.Count == 0 && RemovedEquipment.Count == 0 && ChangedEquipment.Count == 0 &&
|
||||||
AddedDrivers.Count == 0 && RemovedDrivers.Count == 0 && ChangedDrivers.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 EquipmentDelta(EquipmentNode Previous, EquipmentNode Current);
|
||||||
public sealed record DriverDelta(DriverInstancePlan Previous, DriverInstancePlan Current);
|
public sealed record DriverDelta(DriverInstancePlan Previous, DriverInstancePlan Current);
|
||||||
public sealed record AlarmDelta(ScriptedAlarmPlan Previous, ScriptedAlarmPlan Current);
|
public sealed record AlarmDelta(ScriptedAlarmPlan Previous, ScriptedAlarmPlan Current);
|
||||||
|
public sealed record GalaxyTagDelta(GalaxyTagPlan Previous, GalaxyTagPlan Current);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class Phase7Planner
|
public static class Phase7Planner
|
||||||
@@ -61,10 +66,16 @@ public static class Phase7Planner
|
|||||||
a => a.ScriptedAlarmId,
|
a => a.ScriptedAlarmId,
|
||||||
(a, b) => new Phase7Plan.AlarmDelta(a, b));
|
(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(
|
return new Phase7Plan(
|
||||||
addedEq, removedEq, changedEq,
|
addedEq, removedEq, changedEq,
|
||||||
addedDrv, removedDrv, changedDrv,
|
addedDrv, removedDrv, changedDrv,
|
||||||
addedAlarm, removedAlarm, changedAlarm);
|
addedAlarm, removedAlarm, changedAlarm,
|
||||||
|
addedGalaxy, removedGalaxy, changedGalaxy);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static (IReadOnlyList<T> Added, IReadOnlyList<T> Removed, IReadOnlyList<TDelta> Changed)
|
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)
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
||||||
=> _nodeManager.EnsureFolder(folderNodeId, parentNodeId, 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();
|
public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,8 +101,9 @@ public static class DeploymentArtifact
|
|||||||
var equipment = ReadArray(root, "Equipment", ReadEquipmentNode);
|
var equipment = ReadArray(root, "Equipment", ReadEquipmentNode);
|
||||||
var drivers = ReadArray(root, "DriverInstances", ReadDriverPlan);
|
var drivers = ReadArray(root, "DriverInstances", ReadDriverPlan);
|
||||||
var alarms = ReadArray(root, "ScriptedAlarms", ReadAlarmPlan);
|
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)
|
catch (JsonException)
|
||||||
{
|
{
|
||||||
@@ -115,7 +116,87 @@ public static class DeploymentArtifact
|
|||||||
Array.Empty<UnsLineProjection>(),
|
Array.Empty<UnsLineProjection>(),
|
||||||
Array.Empty<EquipmentNode>(),
|
Array.Empty<EquipmentNode>(),
|
||||||
Array.Empty<DriverInstancePlan>(),
|
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)
|
private static IReadOnlyList<T> ReadArray<T>(JsonElement root, string propertyName, Func<JsonElement, T?> reader)
|
||||||
where T : class
|
where T : class
|
||||||
|
|||||||
@@ -45,9 +45,12 @@ public sealed class OpcUaPublishActor : ReceiveActor
|
|||||||
private int _writes;
|
private int _writes;
|
||||||
private byte _lastServiceLevel;
|
private byte _lastServiceLevel;
|
||||||
private Phase7CompositionResult _lastApplied = new(
|
private Phase7CompositionResult _lastApplied = new(
|
||||||
|
Array.Empty<UnsAreaProjection>(),
|
||||||
|
Array.Empty<UnsLineProjection>(),
|
||||||
Array.Empty<EquipmentNode>(),
|
Array.Empty<EquipmentNode>(),
|
||||||
Array.Empty<DriverInstancePlan>(),
|
Array.Empty<DriverInstancePlan>(),
|
||||||
Array.Empty<ScriptedAlarmPlan>());
|
Array.Empty<ScriptedAlarmPlan>(),
|
||||||
|
Array.Empty<GalaxyTagPlan>());
|
||||||
|
|
||||||
public int WriteCount => _writes;
|
public int WriteCount => _writes;
|
||||||
public byte LastServiceLevel => _lastServiceLevel;
|
public byte LastServiceLevel => _lastServiceLevel;
|
||||||
@@ -190,6 +193,10 @@ public sealed class OpcUaPublishActor : ReceiveActor
|
|||||||
// clients see Area/Line/Equipment as proper folders. Idempotent; Phase7Applier
|
// clients see Area/Line/Equipment as proper folders. Idempotent; Phase7Applier
|
||||||
// skips folders that already exist with the same node id.
|
// skips folders that already exist with the same node id.
|
||||||
_applier.MaterialiseHierarchy(composition);
|
_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"));
|
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})",
|
_log.Info("OpcUaPublish: applied rebuild (correlation={Correlation}, added={Added}, removed={Removed}, changed={Changed}, rebuild={Rebuild})",
|
||||||
|
|||||||
Reference in New Issue
Block a user