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 1be31e0b..763fd5c3 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs @@ -260,7 +260,8 @@ public static class DeploymentArtifact /// 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. + /// In-cluster EquipmentIds: driver-bound equipment is attributed by its + /// driver's cluster; driver-less equipment (null DriverInstanceId) by its UNS line's area cluster. private sealed record ClusterSets(HashSet DriverIds, HashSet AreaIds, HashSet EquipmentIds); /// Build the in-cluster id sets used to filter a composition: DriverInstanceIds + UnsAreaIds @@ -279,7 +280,22 @@ public static class DeploymentArtifact 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. + // Map each UnsLine to its owning UnsArea so driver-less equipment can be attributed via + // its line's area cluster (Equipment -> UnsLine.UnsAreaId -> UnsArea.ClusterId). + var lineToArea = new Dictionary(StringComparer.Ordinal); + if (root.TryGetProperty("UnsLines", out var lines) && lines.ValueKind == JsonValueKind.Array) + { + foreach (var el in lines.EnumerateArray()) + { + if (el.ValueKind != JsonValueKind.Object) continue; + var lineId = el.TryGetProperty("UnsLineId", out var lEl) ? lEl.GetString() : null; + var areaId = el.TryGetProperty("UnsAreaId", out var aEl) ? aEl.GetString() : null; + if (!string.IsNullOrWhiteSpace(lineId) && !string.IsNullOrWhiteSpace(areaId)) + lineToArea[lineId!] = areaId!; + } + } + // Equipment carries no ClusterId — driver-bound equipment is attributed by its driver's + // cluster; driver-less equipment (null DriverInstanceId) by its UNS line's area cluster. if (root.TryGetProperty("Equipment", out var eq) && eq.ValueKind == JsonValueKind.Array) { foreach (var el in eq.EnumerateArray()) @@ -287,8 +303,12 @@ public static class DeploymentArtifact 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!); + var lineId = el.TryGetProperty("UnsLineId", out var luEl) ? luEl.GetString() : null; + if (string.IsNullOrWhiteSpace(id)) continue; + var inByDriver = di is not null && driverIds.Contains(di); + var inByLine = di is null && lineId is not null + && lineToArea.TryGetValue(lineId, out var areaOfLine) && areaIds.Contains(areaOfLine); + if (inByDriver || inByLine) equipmentIds.Add(id!); } } } 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 3b87349d..17f93746 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 @@ -521,4 +521,90 @@ public sealed class DeploymentArtifactTests comp.UnsLines.Select(l => l.UnsLineId).ShouldBe(new[] { "line-1" }); warnings.ShouldBeEmpty(); } + + /// A multi-cluster artifact with one driver-less equipment (no DriverInstanceId + /// property at all => null driver) attributed only via its UNS line's area cluster: + /// UnsArea A1 (ClusterId = ), UnsLine L1 (UnsAreaId = A1), + /// Equipment E1 (UnsLineId = L1, no driver), plus an EquipmentVirtualTag on E1. The MAIN-cluster + /// node always carries a driver so the artifact is genuinely multi-cluster (scoped, not None). + private static byte[] DriverlessEquipmentBlob(string areaCluster) => BlobOf(new + { + Clusters = new[] { new { ClusterId = "C1" }, new { ClusterId = "C2" } }, + Nodes = new[] + { + new { NodeId = "c1-1:4053", ClusterId = "C1" }, + new { NodeId = "c2-1:4053", ClusterId = "C2" }, + }, + DriverInstances = new[] + { + new { DriverInstanceId = "c1-driver", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "C1", NamespaceId = "c1-ns" }, + new { DriverInstanceId = "c2-driver", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "C2", NamespaceId = "c2-ns" }, + }, + UnsAreas = new[] { new { UnsAreaId = "A1", Name = "Area1", ClusterId = areaCluster } }, + UnsLines = new[] { new { UnsLineId = "L1", UnsAreaId = "A1", Name = "Line1" } }, + // Driver-less equipment: the DriverInstanceId property is absent => the parser sees null. + Equipment = new[] + { + new { EquipmentId = "E1", Name = "Equip1", UnsLineId = "L1" }, + }, + Scripts = new[] { new { ScriptId = "scr", SourceCode = "return 1;" } }, + VirtualTags = new[] + { + new { VirtualTagId = "vt-e1", EquipmentId = "E1", Name = "VE1", DataType = "Float", ScriptId = "scr" }, + }, + }); + + /// Verifies driver-less equipment (null DriverInstanceId) IS kept when its UNS line's area + /// is in the node's cluster — attributed via the line→area→cluster chain rather than via a driver. + /// Both the EquipmentNode and its EquipmentVirtualTag must survive the scoped parse. + [Fact] + public void Driverless_equipment_kept_when_its_line_area_is_in_cluster() + { + var comp = DeploymentArtifact.ParseComposition( + DriverlessEquipmentBlob(areaCluster: "C1"), "c1-1:4053"); + + comp.EquipmentNodes.Select(e => e.EquipmentId).ShouldBe(new[] { "E1" }); + comp.EquipmentVirtualTags.Select(v => v.VirtualTagId).ShouldBe(new[] { "vt-e1" }); + } + + /// Verifies driver-less equipment is EXCLUDED when its UNS line's area is in another cluster: + /// the node scopes to C1 but area A1 (and thus line L1, equipment E1) lives in C2. + [Fact] + public void Driverless_equipment_excluded_when_its_line_area_in_other_cluster() + { + var comp = DeploymentArtifact.ParseComposition( + DriverlessEquipmentBlob(areaCluster: "C2"), "c1-1:4053"); + + comp.EquipmentNodes.ShouldBeEmpty(); + comp.EquipmentVirtualTags.ShouldBeEmpty(); + } + + /// Verifies the unchanged driver-bound path: equipment with an in-cluster DriverInstanceId is + /// kept even when (as here) no UNS line/area rows describe it — attribution is still by driver. + [Fact] + public void Driverbound_equipment_kept_when_driver_in_cluster() + { + var blob = BlobOf(new + { + Clusters = new[] { new { ClusterId = "C1" }, new { ClusterId = "C2" } }, + Nodes = new[] + { + new { NodeId = "c1-1:4053", ClusterId = "C1" }, + new { NodeId = "c2-1:4053", ClusterId = "C2" }, + }, + DriverInstances = new[] + { + new { DriverInstanceId = "c1-driver", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "C1", NamespaceId = "c1-ns" }, + new { DriverInstanceId = "c2-driver", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "C2", NamespaceId = "c2-ns" }, + }, + Equipment = new[] + { + new { EquipmentId = "E-c1", Name = "Eq-c1", UnsLineId = "l1", DriverInstanceId = "c1-driver" }, + new { EquipmentId = "E-c2", Name = "Eq-c2", UnsLineId = "l2", DriverInstanceId = "c2-driver" }, + }, + }); + + var comp = DeploymentArtifact.ParseComposition(blob, "c1-1:4053"); + comp.EquipmentNodes.Select(e => e.EquipmentId).ShouldBe(new[] { "E-c1" }); + } }