diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor
index b433f723..0f11fb91 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor
@@ -170,6 +170,37 @@
}
+ @* Driver-agnostic array-shape intent. Merges the root `isArray` / `arrayLength`
+ keys onto the canonical TagConfig via the pure TagArrayConfig seam so it composes
+ with the typed editor's driver-specific fields (both preserve unknown keys). When
+ checked, the server materialises a 1-D array node (ValueRank=1). Shown for EVERY
+ driver once one is picked — same place/pattern as the historize control above. *@
+ @if (!string.IsNullOrEmpty(_form.DriverInstanceId))
+ {
+
+
+
+
+
+
+
+ When checked, the server materialises this tag as a fixed-length 1-D array node.
+
+ @if (_arrayState.IsArray)
+ {
+
+
+
+
Number of elements (must be a positive whole number).
+
+ }
+
+ }
+
@* Native-alarm options: shown only when the TagConfig carries an `alarm` object (the tag
is a Part 9 condition). The "Historize to AVEVA" toggle edits the alarm.historizeToAveva
opt-out (bool?, unchecked-via-clear ⇒ absent ⇒ historize default-on at the server gate;
@@ -255,6 +286,11 @@
// the driver changes; the change handlers merge it back onto _form.TagConfig via TagHistorizeConfig.
private TagHistorizeConfig.HistorizeState _historizeState;
+ // Driver-agnostic array-shape intent (root `isArray` / `arrayLength`), reflected for the array controls.
+ // Re-read from _form.TagConfig whenever the modal (re)opens or the driver changes; the change handlers
+ // merge it back onto _form.TagConfig via TagArrayConfig (same pattern as _historizeState above).
+ private TagArrayConfig.ArrayState _arrayState;
+
// The DriverType of the currently-selected driver (drives editor dispatch). Null when no driver chosen.
private string? SelectedDriverType =>
Drivers.FirstOrDefault(d => d.Id == _form.DriverInstanceId).DriverType;
@@ -285,6 +321,8 @@
_galaxyPickedIsAlarm = false;
// The reset TagConfig carries no history intent — reflect that in the historize controls.
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
+ // Likewise the reset TagConfig carries no array intent.
+ _arrayState = TagArrayConfig.Read(_form.TagConfig);
}
// The operator picked a Galaxy reference (tag_name.AttributeName); store it as the canonical
@@ -304,10 +342,13 @@
JsonSerializer.Serialize(new { FullName = address }),
_historizeState.IsHistorized,
_historizeState.HistorianTagname);
+ // Re-merge any array intent for the same reason — a fresh {FullName} blob would otherwise drop it.
+ config = TagArrayConfig.Set(config, _arrayState.IsArray, _arrayState.ArrayLength);
_form.TagConfig = _galaxyPickedIsAlarm
? NativeAlarmModel.SeedDefaultAlarm(config)
: config;
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
+ _arrayState = TagArrayConfig.Read(_form.TagConfig);
}
private IDictionary BuildEditorParameters() => new Dictionary
@@ -372,6 +413,30 @@
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
}
+ // Toggle the root `isArray` array-shape intent in the raw TagConfig, merged via the pure TagArrayConfig
+ // seam so the typed editor's driver-specific keys are preserved. Clearing it drops `arrayLength` too
+ // (no orphan length), so the carried _arrayState.ArrayLength is irrelevant when unchecking.
+ private void OnIsArrayChanged(ChangeEventArgs e)
+ {
+ var isArray = e.Value is true;
+ _form.TagConfig = TagArrayConfig.Set(_form.TagConfig, isArray, _arrayState.ArrayLength);
+ _arrayState = TagArrayConfig.Read(_form.TagConfig);
+ }
+
+ // Merge the array length (root `arrayLength`) into the raw TagConfig. A blank/zero/negative/non-numeric
+ // entry parses to null, so the key is dropped until a positive length is typed (and SaveAsync rejects
+ // an array with no positive length).
+ private void OnArrayLengthChanged(ChangeEventArgs e)
+ {
+ var length = ParsePositiveLength(e.Value?.ToString());
+ _form.TagConfig = TagArrayConfig.Set(_form.TagConfig, _arrayState.IsArray, length);
+ _arrayState = TagArrayConfig.Read(_form.TagConfig);
+ }
+
+ // Parse the numeric-input string to a positive uint, or null for blank/zero/negative/overflow/non-numeric.
+ private static uint? ParsePositiveLength(string? raw)
+ => uint.TryParse(raw, out var u) && u > 0 ? u : null;
+
protected override void OnParametersSet()
{
// Rebuild the working form whenever the host (re)opens the modal for a fresh target.
@@ -400,6 +465,8 @@
_galaxyAddress = ReadFullName(_form.TagConfig);
// Seed the historize controls from any existing root isHistorized/historianTagname keys.
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
+ // Seed the array controls from any existing root isArray/arrayLength keys.
+ _arrayState = TagArrayConfig.Read(_form.TagConfig);
}
// Best-effort extraction of FullName from a Galaxy TagConfig; returns "" when absent or unparseable.
@@ -436,6 +503,15 @@
return;
}
+ // Driver-agnostic array-shape validation: an array tag needs a positive length. Mirrors the
+ // per-driver config validation above so a missing length is caught here rather than at deploy.
+ var arrayError = TagArrayConfig.Validate(_arrayState.IsArray, _arrayState.ArrayLength);
+ if (arrayError is not null)
+ {
+ _error = arrayError;
+ return;
+ }
+
var input = new TagInput(
_form.TagId,
_form.Name,
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagArrayConfig.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagArrayConfig.cs
new file mode 100644
index 00000000..9609a187
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagArrayConfig.cs
@@ -0,0 +1,68 @@
+using System.Text.Json.Nodes;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
+
+///
+/// Pure, driver-agnostic merge helper for the two server-side array-shape intent keys at the ROOT of a
+/// tag's TagConfig JSON: isArray (camelCase bool — omit/false default) and arrayLength
+/// (camelCase JSON number — the 1-D array length; omit when not an array). These map to what the
+/// EquipmentTagPlan reads to materialise a 1-D array OPC UA node (ValueRank=1, ArrayDimensions=[length]).
+///
+///
+/// This is a TagModal-merge seam mirroring : the TagModal owns the
+/// canonical TagConfig JSON; the driver's typed editor reads/writes its driver-specific fields on that same
+/// JSON, and the modal's "This tag is an array" checkbox + "Array length" numeric input read/write these two
+/// keys through this helper. They COMPOSE because both sides preserve every unrecognised key (delegated to
+/// / ), so neither clobbers the
+/// other's fields. These keys are intentionally NOT carried inside any per-driver typed model.
+///
+///
+public static class TagArrayConfig
+{
+ /// The two array-shape values parsed from a TagConfig JSON's root.
+ /// Whether the server materialises this tag as a 1-D array node.
+ /// The 1-D array length; null when absent (incl. when not an array).
+ public readonly record struct ArrayState(bool IsArray, uint? ArrayLength);
+
+ /// Reads the array-shape keys from a TagConfig JSON, defaulting any absent key
+ /// (null/blank/malformed input ⇒ (false, null)). A negative or non-numeric arrayLength
+ /// reads as null.
+ public static ArrayState Read(string? json)
+ {
+ var o = TagConfigJson.ParseOrNew(json);
+ return new ArrayState(
+ TagConfigJson.GetBool(o, "isArray"),
+ ReadLength(o));
+ }
+
+ /// Merges the two array-shape keys into , preserving every other
+ /// (driver-specific or unknown) key. isArray is dropped when false (absent ⇒ false at the
+ /// materialiser); arrayLength is dropped when is false or
+ /// is null (never leaves an orphan length behind a cleared isArray).
+ public static string Set(string? json, bool isArray, uint? arrayLength)
+ {
+ var o = TagConfigJson.ParseOrNew(json);
+ TagConfigJson.Set(o, "isArray", isArray ? true : null);
+ // Drop arrayLength whenever isArray is off OR no length is set, so a cleared array never
+ // leaves an orphan length key behind.
+ TagConfigJson.Set(o, "arrayLength", isArray && arrayLength is { } len ? len : null);
+ return TagConfigJson.Serialize(o);
+ }
+
+ /// Validates the array-shape intent: when is set, a positive
+ /// is required. Returns an error string when invalid, or null
+ /// when valid (a non-array is always valid regardless of length).
+ public static string? Validate(bool isArray, uint? arrayLength)
+ => isArray && (arrayLength is null or 0)
+ ? "Array length must be a positive number when 'This tag is an array' is checked."
+ : null;
+
+ // Reads arrayLength as a non-negative uint, or null when absent/null/non-numeric/negative.
+ private static uint? ReadLength(JsonObject o)
+ => o.TryGetPropertyValue("arrayLength", out var n)
+ && n is JsonValue v
+ && v.TryGetValue(out var l)
+ && l is >= 0 and <= uint.MaxValue
+ ? (uint)l
+ : null;
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagArrayConfigTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagArrayConfigTests.cs
new file mode 100644
index 00000000..a10ef54c
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagArrayConfigTests.cs
@@ -0,0 +1,177 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
+
+///
+/// Unit tests for the driver-agnostic merge helper — the pure seam the
+/// TagModal uses to read/write the root isArray / arrayLength array keys on the canonical
+/// TagConfig JSON WITHOUT disturbing the driver's own (typed-editor) fields. Both this helper and every
+/// typed editor preserve unknown keys, so they compose over the same JSON blob. Mirrors
+/// .
+///
+public sealed class TagArrayConfigTests
+{
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("{}")]
+ [InlineData("not json")]
+ public void Read_returns_defaults_for_empty_or_malformed(string? json)
+ {
+ var a = TagArrayConfig.Read(json);
+
+ a.IsArray.ShouldBeFalse();
+ a.ArrayLength.ShouldBeNull();
+ }
+
+ [Fact]
+ public void Read_reads_both_keys()
+ {
+ var a = TagArrayConfig.Read("""{"FullName":"ns=2;s=X","isArray":true,"arrayLength":8}""");
+
+ a.IsArray.ShouldBeTrue();
+ a.ArrayLength.ShouldBe(8u);
+ }
+
+ [Fact]
+ public void Set_true_with_length_writes_both_camelCase_keys()
+ {
+ var json = TagArrayConfig.Set("""{"FullName":"ns=2;s=X"}""", isArray: true, arrayLength: 8);
+
+ json.ShouldContain("\"isArray\":true");
+ json.ShouldContain("\"arrayLength\":8");
+ // The driver field is untouched.
+ json.ShouldContain("\"FullName\":\"ns=2;s=X\"");
+ }
+
+ [Fact]
+ public void Set_true_round_trips_through_Read()
+ {
+ var json = TagArrayConfig.Set("{}", isArray: true, arrayLength: 16);
+
+ var a = TagArrayConfig.Read(json);
+ a.IsArray.ShouldBeTrue();
+ a.ArrayLength.ShouldBe(16u);
+ }
+
+ [Fact]
+ public void Set_false_removes_both_keys()
+ {
+ var json = TagArrayConfig.Set(
+ """{"FullName":"ns=2;s=X","isArray":true,"arrayLength":8}""",
+ isArray: false, arrayLength: 8);
+
+ json.ShouldNotContain("isArray");
+ json.ShouldNotContain("arrayLength");
+ json.ShouldContain("\"FullName\":\"ns=2;s=X\"");
+ }
+
+ [Fact]
+ public void Set_false_removes_orphan_arrayLength()
+ {
+ // Clearing isArray must not leave an orphan arrayLength behind.
+ var json = TagArrayConfig.Set(
+ """{"isArray":true,"arrayLength":4}""",
+ isArray: false, arrayLength: 4);
+
+ json.ShouldNotContain("arrayLength");
+ json.ShouldNotContain("isArray");
+ }
+
+ [Fact]
+ public void Set_true_null_length_drops_arrayLength_key()
+ {
+ // isArray on but no length yet (pre-validation authoring state): emit isArray, no arrayLength.
+ var json = TagArrayConfig.Set("{}", isArray: true, arrayLength: null);
+
+ json.ShouldContain("\"isArray\":true");
+ json.ShouldNotContain("arrayLength");
+ }
+
+ [Fact]
+ public void Set_preserves_driver_specific_unknown_keys()
+ {
+ var json = TagArrayConfig.Set(
+ """{"register":40001,"scale":0.1,"nested":{"a":1}}""",
+ isArray: true, arrayLength: 32);
+
+ json.ShouldContain("register");
+ json.ShouldContain("40001");
+ json.ShouldContain("scale");
+ json.ShouldContain("0.1");
+ json.ShouldContain("nested");
+ json.ShouldContain("\"isArray\":true");
+ json.ShouldContain("\"arrayLength\":32");
+ }
+
+ [Fact]
+ public void Set_on_malformed_input_starts_from_empty_object()
+ {
+ var json = TagArrayConfig.Set("not json", isArray: true, arrayLength: 2);
+
+ json.ShouldContain("\"isArray\":true");
+ json.ShouldContain("\"arrayLength\":2");
+ json.ShouldStartWith("{");
+ json.ShouldEndWith("}");
+ }
+
+ [Fact]
+ public void Validate_rejects_array_with_null_length()
+ {
+ TagArrayConfig.Validate(isArray: true, arrayLength: null).ShouldNotBeNull();
+ }
+
+ [Fact]
+ public void Validate_rejects_array_with_zero_length()
+ {
+ TagArrayConfig.Validate(isArray: true, arrayLength: 0).ShouldNotBeNull();
+ }
+
+ [Fact]
+ public void Validate_accepts_array_with_positive_length()
+ {
+ TagArrayConfig.Validate(isArray: true, arrayLength: 8).ShouldBeNull();
+ }
+
+ [Fact]
+ public void Validate_accepts_non_array_regardless_of_length()
+ {
+ TagArrayConfig.Validate(isArray: false, arrayLength: null).ShouldBeNull();
+ TagArrayConfig.Validate(isArray: false, arrayLength: 0).ShouldBeNull();
+ }
+
+ ///
+ /// Full seam: the array helper and a driver-typed editor compose over the same canonical TagConfig
+ /// blob without clobbering each other. Setting the array keys, round-tripping through the OpcUaClient
+ /// typed editor (FromJson → ToJson), then reading the array keys back recovers them intact — and the
+ /// editor's own FullName field survives the array merge.
+ ///
+ [Fact]
+ public void Array_keys_survive_a_typed_editor_round_trip()
+ {
+ var withArray = TagArrayConfig.Set("""{"FullName":"ns=2;s=X"}""", isArray: true, arrayLength: 12);
+
+ var afterEditor = OpcUaClientTagConfigModel.FromJson(withArray).ToJson();
+
+ var a = TagArrayConfig.Read(afterEditor);
+ a.IsArray.ShouldBeTrue();
+ a.ArrayLength.ShouldBe(12u);
+ OpcUaClientTagConfigModel.FromJson(afterEditor).FullName.ShouldBe("ns=2;s=X");
+ }
+
+ /// The array helper and the historize helper compose over the same blob (both preserve
+ /// unknown keys), so a tag can carry both array and history intent simultaneously.
+ [Fact]
+ public void Array_and_historize_keys_compose()
+ {
+ var withHistory = TagHistorizeConfig.Set("""{"FullName":"ns=2;s=X"}""", isHistorized: true, historianTagname: "TN");
+ var withBoth = TagArrayConfig.Set(withHistory, isArray: true, arrayLength: 4);
+
+ TagArrayConfig.Read(withBoth).ArrayLength.ShouldBe(4u);
+ TagHistorizeConfig.Read(withBoth).IsHistorized.ShouldBeTrue();
+ TagHistorizeConfig.Read(withBoth).HistorianTagname.ShouldBe("TN");
+ }
+}