diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs
index 12e5adf5..95ad6e9d 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs
@@ -205,6 +205,91 @@ public static class DeploymentArtifact
}
}
+ /// Cluster-scoped overload: the address-space composition a node should materialise given
+ /// its NodeId. Filters every projection to the node's own ClusterId (see ).
+ /// The deployment artifact blob.
+ /// This node's identity in "host:port" form.
+ /// The filtered composition per the node's scoping decision.
+ public static Phase7CompositionResult ParseComposition(ReadOnlySpan blob, string nodeId)
+ {
+ var scope = ResolveClusterScope(blob, nodeId);
+ if (scope.Mode == ClusterFilterMode.None) return ParseComposition(blob);
+ if (scope.Mode == ClusterFilterMode.Suppress) return Empty();
+
+ var full = ParseComposition(blob);
+ var sets = BuildClusterSets(blob, scope.ClusterId!);
+ return new Phase7CompositionResult(
+ full.UnsAreas.Where(a => sets.AreaIds.Contains(a.UnsAreaId)).ToArray(),
+ full.UnsLines.Where(l => sets.AreaIds.Contains(l.UnsAreaId)).ToArray(),
+ full.EquipmentNodes.Where(e => sets.EquipmentIds.Contains(e.EquipmentId)).ToArray(),
+ full.DriverInstancePlans.Where(d => sets.DriverIds.Contains(d.DriverInstanceId)).ToArray(),
+ full.ScriptedAlarmPlans.Where(a => sets.EquipmentIds.Contains(a.EquipmentId)).ToArray(),
+ full.GalaxyTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray())
+ {
+ EquipmentTags = full.EquipmentTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray(),
+ };
+ }
+
+ /// The in-cluster id sets used to filter a composition.
+ /// DriverInstanceIds whose row carries the in-scope ClusterId.
+ /// UnsAreaIds whose row carries the in-scope ClusterId.
+ /// EquipmentIds whose owning DriverInstanceId is in-cluster.
+ private sealed record ClusterSets(HashSet DriverIds, HashSet AreaIds, HashSet EquipmentIds);
+
+ /// Build the in-cluster id sets used to filter a composition: DriverInstanceIds + UnsAreaIds
+ /// that directly carry the ClusterId, plus EquipmentIds whose DriverInstanceId is in-cluster.
+ /// The deployment artifact blob.
+ /// The node's ClusterId to scope to.
+ /// The resolved in-cluster id sets (empty on parse failure => empty composition).
+ private static ClusterSets BuildClusterSets(ReadOnlySpan blob, string clusterId)
+ {
+ var driverIds = new HashSet(StringComparer.Ordinal);
+ var areaIds = new HashSet(StringComparer.Ordinal);
+ var equipmentIds = new HashSet(StringComparer.Ordinal);
+ try
+ {
+ using var doc = JsonDocument.Parse(blob.ToArray());
+ var root = doc.RootElement;
+ CollectIdsWhereCluster(root, "DriverInstances", "DriverInstanceId", clusterId, driverIds);
+ CollectIdsWhereCluster(root, "UnsAreas", "UnsAreaId", clusterId, areaIds);
+ // Equipment carries no ClusterId — include it when its DriverInstanceId is in-cluster.
+ if (root.TryGetProperty("Equipment", out var eq) && eq.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var el in eq.EnumerateArray())
+ {
+ if (el.ValueKind != JsonValueKind.Object) continue;
+ var di = el.TryGetProperty("DriverInstanceId", out var diEl) ? diEl.GetString() : null;
+ var id = el.TryGetProperty("EquipmentId", out var idEl) ? idEl.GetString() : null;
+ if (!string.IsNullOrWhiteSpace(id) && di is not null && driverIds.Contains(di))
+ equipmentIds.Add(id!);
+ }
+ }
+ }
+ catch (JsonException) { /* empty sets => nothing matches => empty composition */ }
+ return new ClusterSets(driverIds, areaIds, equipmentIds);
+ }
+
+ /// Collect each row's value from whose
+ /// ClusterId equals (case-insensitive, matching the codebase convention).
+ /// The artifact root element.
+ /// The array property name to scan (e.g. "DriverInstances").
+ /// The id field to collect from each in-cluster row.
+ /// The ClusterId rows must match.
+ /// The set to collect matching ids into.
+ private static void CollectIdsWhereCluster(
+ JsonElement root, string arrayName, string idField, string clusterId, HashSet into)
+ {
+ if (!root.TryGetProperty(arrayName, out var arr) || arr.ValueKind != JsonValueKind.Array) return;
+ foreach (var el in arr.EnumerateArray())
+ {
+ if (el.ValueKind != JsonValueKind.Object) continue;
+ var cid = el.TryGetProperty("ClusterId", out var cEl) ? cEl.GetString() : null;
+ if (!string.Equals(cid, clusterId, StringComparison.OrdinalIgnoreCase)) continue;
+ var id = el.TryGetProperty(idField, out var idEl) ? idEl.GetString() : null;
+ if (!string.IsNullOrWhiteSpace(id)) into.Add(id!);
+ }
+ }
+
private static Phase7CompositionResult Empty() => new(
Array.Empty(),
Array.Empty(),
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs
index aab6757c..c40986ac 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs
@@ -329,4 +329,64 @@ public sealed class DeploymentArtifactTests
scope.Mode.ShouldBe(ClusterFilterMode.ScopeTo);
scope.ClusterId.ShouldBe("MAIN");
}
+
+ private static object MultiClusterSnapshotWithTags() => new
+ {
+ Clusters = new[] { new { ClusterId = "MAIN" }, new { ClusterId = "SITE-A" } },
+ Nodes = new[]
+ {
+ new { NodeId = "central-1:4053", ClusterId = "MAIN" },
+ new { NodeId = "site-a-1:4053", ClusterId = "SITE-A" },
+ },
+ DriverInstances = new[]
+ {
+ new { DriverInstanceId = "main-galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "main-ns" },
+ new { DriverInstanceId = "sa-galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", ClusterId = "SITE-A", NamespaceId = "sa-ns" },
+ },
+ Namespaces = new[]
+ {
+ new { NamespaceId = "main-ns", ClusterId = "MAIN", Kind = 1 },
+ new { NamespaceId = "sa-ns", ClusterId = "SITE-A", Kind = 1 },
+ },
+ Tags = new[]
+ {
+ new { TagId = "t-main", DriverInstanceId = "main-galaxy", EquipmentId = (string?)null, Name = "M1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" },
+ new { TagId = "t-sa", DriverInstanceId = "sa-galaxy", EquipmentId = (string?)null, Name = "S1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" },
+ },
+ };
+
+ [Fact]
+ public void ParseComposition_scoped_keeps_only_my_clusters_drivers_and_tags()
+ {
+ var blob = BlobOf(MultiClusterSnapshotWithTags());
+
+ var main = DeploymentArtifact.ParseComposition(blob, "central-1:4053");
+ main.DriverInstancePlans.Select(d => d.DriverInstanceId).ShouldBe(new[] { "main-galaxy" });
+ main.GalaxyTags.Select(t => t.TagId).ShouldBe(new[] { "t-main" });
+
+ var siteA = DeploymentArtifact.ParseComposition(blob, "site-a-1:4053");
+ siteA.DriverInstancePlans.Select(d => d.DriverInstanceId).ShouldBe(new[] { "sa-galaxy" });
+ siteA.GalaxyTags.Select(t => t.TagId).ShouldBe(new[] { "t-sa" });
+ }
+
+ [Fact]
+ public void ParseComposition_scoped_unknown_node_is_empty()
+ {
+ var comp = DeploymentArtifact.ParseComposition(BlobOf(MultiClusterSnapshotWithTags()), "ghost-9:4053");
+ comp.GalaxyTags.ShouldBeEmpty();
+ comp.DriverInstancePlans.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void ParseComposition_single_cluster_node_id_overload_matches_legacy()
+ {
+ var blob = BlobOf(new
+ {
+ Clusters = new[] { new { ClusterId = "MAIN" } },
+ Nodes = new[] { new { NodeId = "n1:4053", ClusterId = "MAIN" } },
+ DriverInstances = new[] { new { DriverInstanceId = "d1", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "ns" } },
+ });
+ DeploymentArtifact.ParseComposition(blob, "anything:4053").DriverInstancePlans.Count
+ .ShouldBe(DeploymentArtifact.ParseComposition(blob).DriverInstancePlans.Count);
+ }
}