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