diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs index 8dba58f4..fe6d6e7e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs @@ -211,7 +211,7 @@ public sealed class Phase7Applier string? historianTagname = tag.IsHistorized ? (string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname) : null; - SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, tag.Writable, historianTagname); + SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, tag.Writable, historianTagname, tag.IsArray, tag.ArrayLength); } } @@ -309,9 +309,9 @@ public sealed class Phase7Applier catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: EnsureFolder threw for {Node}", nodeId); } } - private void SafeEnsureVariable(string nodeId, string? parentNodeId, string displayName, string dataType, bool writable, string? historianTagname = null) + private void SafeEnsureVariable(string nodeId, string? parentNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { - try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, historianTagname); } + try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength); } catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: EnsureVariable threw for {Node}", nodeId); } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs index 4d683672..fa34f1ee 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs @@ -83,6 +83,11 @@ public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentI /// historianTagname string (Phase C); a null means the historian /// tagname defaults to (resolved later, not here). Both are parsed identically /// on the artifact-decode side for byte-parity. +/// / carry the optional array intent parsed from +/// Tag.TagConfig's isArray bool + arrayLength uint: when +/// the variable materialises as a 1-D array (ValueRank=OneDimension, +/// ArrayDimensions=[ArrayLength]) rather than a scalar. A null means +/// length 0 (unbounded). Both are parsed identically on the artifact-decode side for byte-parity. /// public sealed record EquipmentTagPlan( string TagId, @@ -95,7 +100,9 @@ public sealed record EquipmentTagPlan( bool Writable, EquipmentTagAlarmInfo? Alarm, bool IsHistorized = false, - string? HistorianTagname = null); + string? HistorianTagname = null, + bool IsArray = false, + uint? ArrayLength = null); /// Native-alarm intent parsed from an equipment tag's TagConfig.alarm object. Null ⇒ /// the tag is a plain value variable. is an OPC UA Part 9 subtype string @@ -352,6 +359,7 @@ public static class Phase7Composer .Select(t => { var (isHistorized, historianTagname) = ExtractTagHistorize(t.TagConfig); + var (isArray, arrayLength) = ExtractTagArray(t.TagConfig); return new EquipmentTagPlan( TagId: t.TagId, EquipmentId: t.EquipmentId!, @@ -363,7 +371,9 @@ public static class Phase7Composer Writable: t.AccessLevel == TagAccessLevel.ReadWrite, Alarm: ExtractTagAlarm(t.TagConfig), IsHistorized: isHistorized, - HistorianTagname: historianTagname); + HistorianTagname: historianTagname, + IsArray: isArray, + ArrayLength: arrayLength); }) .ToList(); @@ -537,4 +547,34 @@ public static class Phase7Composer } catch (JsonException) { return (false, null); } } + + /// Parses the optional array intent from a tag's TagConfig JSON: the isArray + /// bool (absent / not a bool / non-object root / blank / malformed ⇒ false) and the optional + /// arrayLength uint. The length is honoured ONLY when isArray is true AND the prop is a + /// JSON number that fits uint (else null ⇒ unbounded, resolved later). Mirrors + /// exactly in structure + null/blank/non-object/malformed-JSON + /// tolerance. Never throws. The artifact-decode side + /// (DeploymentArtifact.ExtractTagArray) MUST parse identically (byte-parity). + internal static (bool IsArray, uint? ArrayLength) ExtractTagArray(string? tagConfig) + { + if (string.IsNullOrWhiteSpace(tagConfig)) return (false, null); + try + { + using var doc = JsonDocument.Parse(tagConfig); + if (doc.RootElement.ValueKind != JsonValueKind.Object) return (false, null); + var isArray = doc.RootElement.TryGetProperty("isArray", out var aEl) + && (aEl.ValueKind == JsonValueKind.True || aEl.ValueKind == JsonValueKind.False) + && aEl.GetBoolean(); + uint? arrayLength = null; + if (isArray + && doc.RootElement.TryGetProperty("arrayLength", out var lEl) + && lEl.ValueKind == JsonValueKind.Number + && lEl.TryGetUInt32(out var len)) + { + arrayLength = len; + } + return (isArray, arrayLength); + } + catch (JsonException) { return (false, null); } + } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagArrayTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagArrayTests.cs new file mode 100644 index 00000000..ece237b3 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagArrayTests.cs @@ -0,0 +1,155 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.OpcUaServer; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// +/// Verifies parses the optional array intent from a +/// tag's TagConfig JSON exactly as parses +/// the historize intent: the isArray bool (absent / not a bool / non-object root / blank / +/// malformed ⇒ false) and the optional arrayLength uint (only honoured when +/// isArray is true AND the prop is a JSON number that fits uint; else null). Never +/// throws. Also pins the end-to-end thread-through onto +/// / . +/// +public class ExtractTagArrayTests +{ + [Theory] + // isArray true with an explicit arrayLength. + [InlineData("{\"FullName\":\"T.A\",\"isArray\":true,\"arrayLength\":16}", true, (uint)16)] + // isArray true, no arrayLength ⇒ length null. + [InlineData("{\"FullName\":\"T.A\",\"isArray\":true}", true, null)] + // Absent isArray ⇒ false (arrayLength ignored even if present). + [InlineData("{\"FullName\":\"T.A\"}", false, null)] + // arrayLength present but isArray false ⇒ length null (only honoured when the flag is true). + [InlineData("{\"FullName\":\"T.A\",\"isArray\":false,\"arrayLength\":16}", false, null)] + // arrayLength absent-with-flag honoured-as-null when isArray true but no length. + [InlineData("{\"FullName\":\"T.A\",\"isArray\":true,\"arrayLength\":0}", true, (uint)0)] + // null / empty / malformed-JSON / array-root ⇒ (false, null), never throws. + [InlineData(null, false, null)] + [InlineData("", false, null)] + [InlineData("not json {", false, null)] + [InlineData("[1,2]", false, null)] + // Wrong type for isArray (string, not bool) ⇒ false. + [InlineData("{\"isArray\":\"yes\"}", false, null)] + // Wrong type for arrayLength (string, not number) ⇒ length null, flag still honoured. + [InlineData("{\"isArray\":true,\"arrayLength\":\"16\"}", true, null)] + // Negative arrayLength does not fit uint ⇒ length null, flag still honoured. + [InlineData("{\"isArray\":true,\"arrayLength\":-1}", true, null)] + public void ExtractTagArray_parses_or_returns_defaults(string? cfg, bool expectedIsArray, uint? expectedLength) + { + var (isArray, arrayLength) = Phase7Composer.ExtractTagArray(cfg); + isArray.ShouldBe(expectedIsArray); + arrayLength.ShouldBe(expectedLength); + } + + /// End-to-end: an equipment tag whose TagConfig carries isArray/arrayLength + /// surfaces those on its through , + /// exactly as the historize keys thread through. + [Fact] + public void Compose_threads_array_keys_onto_equipment_tag_plan() + { + var ns = new Namespace + { + NamespaceId = "ns-eq", + ClusterId = "c1", + Kind = NamespaceKind.Equipment, + NamespaceUri = "urn:eq", + }; + var driver = new DriverInstance + { + DriverInstanceId = "drv-1", + ClusterId = "c1", + NamespaceId = "ns-eq", + Name = "Modbus1", + DriverType = "Modbus", + DriverConfig = "{}", + }; + var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" }; + var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" }; + var equip = new Equipment + { + EquipmentId = "eq-1", + DriverInstanceId = "drv-1", + UnsLineId = "line-1", + Name = "Machine_001", + MachineCode = "MACHINE_001", + }; + var arrayTag = new Tag + { + TagId = "tag-arr", + DriverInstanceId = "drv-1", + EquipmentId = "eq-1", + FolderPath = null, + Name = "Buffer", + DataType = "Int16", + AccessLevel = TagAccessLevel.Read, + TagConfig = "{\"FullName\":\"40001\",\"isArray\":true,\"arrayLength\":16}", + }; + + var result = Phase7Composer.Compose( + new[] { area }, new[] { line }, new[] { equip }, + new[] { driver }, Array.Empty(), + new[] { arrayTag }, new[] { ns }); + + var tag = result.EquipmentTags.ShouldHaveSingleItem(); + tag.TagId.ShouldBe("tag-arr"); + tag.IsArray.ShouldBeTrue(); + tag.ArrayLength.ShouldBe((uint)16); + } + + /// End-to-end: a scalar equipment tag (no array keys) yields IsArray=false, ArrayLength=null. + [Fact] + public void Compose_leaves_scalar_equipment_tag_plan_unflagged() + { + var ns = new Namespace + { + NamespaceId = "ns-eq", + ClusterId = "c1", + Kind = NamespaceKind.Equipment, + NamespaceUri = "urn:eq", + }; + var driver = new DriverInstance + { + DriverInstanceId = "drv-1", + ClusterId = "c1", + NamespaceId = "ns-eq", + Name = "Modbus1", + DriverType = "Modbus", + DriverConfig = "{}", + }; + var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" }; + var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" }; + var equip = new Equipment + { + EquipmentId = "eq-1", + DriverInstanceId = "drv-1", + UnsLineId = "line-1", + Name = "Machine_001", + MachineCode = "MACHINE_001", + }; + var scalarTag = new Tag + { + TagId = "tag-scalar", + DriverInstanceId = "drv-1", + EquipmentId = "eq-1", + FolderPath = null, + Name = "Speed", + DataType = "Float", + AccessLevel = TagAccessLevel.Read, + TagConfig = "{\"FullName\":\"40005\"}", + }; + + var result = Phase7Composer.Compose( + new[] { area }, new[] { line }, new[] { equip }, + new[] { driver }, Array.Empty(), + new[] { scalarTag }, new[] { ns }); + + var tag = result.EquipmentTags.ShouldHaveSingleItem(); + tag.IsArray.ShouldBeFalse(); + tag.ArrayLength.ShouldBeNull(); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7PlannerTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7PlannerTests.cs index 5bdb7bb8..2f8c68fe 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7PlannerTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7PlannerTests.cs @@ -174,6 +174,42 @@ public sealed class Phase7PlannerTests plan.RemovedEquipmentVirtualTags.ShouldBeEmpty(); } + /// An equipment Tag with the same id but a toggled IsArray flag (and otherwise + /// identical fields) must route to ChangedEquipmentTags. This pins that IsArray is part of + /// (record value-equality) so an array-flag-only deploy is not + /// a silent no-op at the diff/IsEmpty gate — same posture as the Historize flag. + [Fact] + public void Same_id_with_toggled_isarray_routes_to_ChangedEquipmentTags() + { + var prev = new Phase7CompositionResult( + Array.Empty(), Array.Empty(), Array.Empty()) + { + EquipmentTags = new[] + { + new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16", + FullName: "40001", Writable: false, Alarm: null, IsArray: false, ArrayLength: null), + }, + }; + var next = new Phase7CompositionResult( + Array.Empty(), Array.Empty(), Array.Empty()) + { + EquipmentTags = new[] + { + new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16", + FullName: "40001", Writable: false, Alarm: null, IsArray: true, ArrayLength: 16), + }, + }; + + var plan = Phase7Planner.Compute(prev, next); + + plan.IsEmpty.ShouldBeFalse(); + plan.ChangedEquipmentTags.Single().Previous.IsArray.ShouldBeFalse(); + plan.ChangedEquipmentTags.Single().Current.IsArray.ShouldBeTrue(); + plan.ChangedEquipmentTags.Single().Current.ArrayLength.ShouldBe((uint)16); + plan.AddedEquipmentTags.ShouldBeEmpty(); + plan.RemovedEquipmentTags.ShouldBeEmpty(); + } + /// Regression guard for structural equality on : /// two snapshots containing the SAME VirtualTag built from SEPARATE list instances must diff to an empty plan /// (IReadOnlyList equality is BY REFERENCE without the custom Equals override, so every VirtualTag with