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