diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs
index 5fd6b3a3..d5f18681 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs
@@ -1,6 +1,8 @@
using System.CommandLine;
using System.CommandLine.Parsing;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
+using ZB.MOM.WW.ScadaBridge.Commons.Types;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
@@ -239,9 +241,16 @@ public static class TemplateCommands
internal const string ElementTypeOptionDescription =
"Element scalar type for a List attribute (String, Int32, Float, Double, Boolean, DateTime). Required when --data-type is List.";
- /// The element scalar types permitted for a List attribute (matches the Management API).
+ ///
+ /// The element scalar types permitted for a List attribute — derived from the
+ /// single source of truth, ,
+ /// so the CLI never drifts from the codec/Management API.
+ ///
private static readonly string[] ValidElementScalars =
- { "String", "Int32", "Float", "Double", "Boolean", "DateTime" };
+ Enum.GetValues()
+ .Where(AttributeValueCodec.IsValidElementType)
+ .Select(t => t.ToString())
+ .ToArray();
///
/// Validates the --data-type / --element-type combination client-side so
@@ -268,7 +277,8 @@ public static class TemplateCommands
return false;
}
- if (!ValidElementScalars.Contains(elementType!.Trim(), StringComparer.OrdinalIgnoreCase))
+ if (!Enum.TryParse(elementType!.Trim(), ignoreCase: true, out var parsed)
+ || !AttributeValueCodec.IsValidElementType(parsed))
{
error = $"Invalid --element-type '{elementType}'. Valid List element scalars are: "
+ string.Join(", ", ValidElementScalars) + ".";
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/AttributeValueCodec.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/AttributeValueCodec.cs
index b848b79e..36e34411 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/AttributeValueCodec.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/AttributeValueCodec.cs
@@ -64,7 +64,13 @@ public static class AttributeValueCodec
_ => el.GetRawText() // number/bool → "10" / "1.5" / "true"
};
- private static Type ElementClrType(DataType t) => t switch
+ ///
+ /// The CLR element type backing a of the given
+ /// element scalar — the single source of truth for the List element CLR
+ /// mapping. Throws for an unsupported element
+ /// type (see ).
+ ///
+ public static Type ElementClrType(DataType t) => t switch
{
DataType.String => typeof(string),
DataType.Int32 => typeof(int),
@@ -75,6 +81,49 @@ public static class AttributeValueCodec
_ => throw new FormatException($"Unsupported list element type '{t}'.")
};
+ ///
+ /// Coerces a live CLR enumerable (e.g. an OPC UA array) into a typed
+ /// List<> for the given element type,
+ /// converting each element with invariant culture (round-trip parse for
+ /// DateTime). Strings are parsed; other CLR types are converted via
+ /// . This is the object-input counterpart to the
+ /// string-input parsing in : callers holding decoded JSON
+ /// strings go through ; callers holding a runtime
+ /// collection use this. Throws for an
+ /// unsupported element type and may throw on an element that cannot be
+ /// converted (the caller decides how to handle the failure).
+ ///
+ public static IList CoerceEnumerable(IEnumerable source, DataType elementType)
+ {
+ ArgumentNullException.ThrowIfNull(source);
+ var clrType = ElementClrType(elementType);
+ var list = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(clrType))!;
+ foreach (var element in source)
+ list.Add(CoerceElement(element, elementType));
+ return list;
+ }
+
+ private static object CoerceElement(object? element, DataType t)
+ {
+ if (element is null)
+ throw new FormatException("List elements may not be null.");
+
+ var c = CultureInfo.InvariantCulture;
+ return t switch
+ {
+ DataType.String => Convert.ToString(element, c)
+ ?? throw new FormatException("Null string element."),
+ DataType.Int32 => element is string si ? int.Parse(si, c) : Convert.ToInt32(element, c),
+ DataType.Float => element is string sf ? float.Parse(sf, c) : Convert.ToSingle(element, c),
+ DataType.Double => element is string sd ? double.Parse(sd, c) : Convert.ToDouble(element, c),
+ DataType.Boolean => element is string sb ? bool.Parse(sb) : Convert.ToBoolean(element, c),
+ DataType.DateTime => element is string sdt
+ ? DateTime.Parse(sdt, c, DateTimeStyles.RoundtripKind)
+ : Convert.ToDateTime(element, c),
+ _ => throw new FormatException($"Unsupported list element type '{t}'.")
+ };
+ }
+
private static object? ParseScalar(string? s, DataType t)
{
if (s is null) throw new FormatException("List elements may not be null.");
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs
index 327cf6e9..db05db94 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs
@@ -896,20 +896,12 @@ public class InstanceActor : ReceiveActor
try
{
- // Construct the typed list INSIDE the try: although the six valid
- // element types resolved by ListElementClrType cannot throw today,
- // keeping ListElementClrType / MakeGenericType / CreateInstance inside
- // the guarded block means any future change that introduces a throw
- // here is caught and turned into a Bad-quality result rather than
+ // Coerce INSIDE the try: although the six valid element types cannot
+ // throw on construction today, keeping the (shared) codec coercion
+ // inside the guarded block means any future change that introduces a
+ // throw is caught and turned into a Bad-quality result rather than
// escaping into the actor and tripping supervision.
- var clrType = ListElementClrType(elementType);
- var list = (System.Collections.IList)Activator.CreateInstance(
- typeof(List<>).MakeGenericType(clrType))!;
-
- foreach (var element in enumerable)
- list.Add(CoerceElement(element, elementType));
-
- typedList = list;
+ typedList = AttributeValueCodec.CoerceEnumerable(enumerable, elementType);
return true;
}
catch (Exception ex)
@@ -922,46 +914,6 @@ public class InstanceActor : ReceiveActor
}
}
- private static Type ListElementClrType(DataType t) => t switch
- {
- DataType.String => typeof(string),
- DataType.Int32 => typeof(int),
- DataType.Float => typeof(float),
- DataType.Double => typeof(double),
- DataType.Boolean => typeof(bool),
- DataType.DateTime => typeof(DateTime),
- _ => throw new FormatException($"Unsupported list element type '{t}'.")
- };
-
- private static object CoerceElement(object? element, DataType t)
- {
- if (element is null)
- throw new FormatException("List elements may not be null.");
-
- var culture = System.Globalization.CultureInfo.InvariantCulture;
- return t switch
- {
- DataType.String => Convert.ToString(element, culture)
- ?? throw new FormatException("Null string element."),
- DataType.Int32 => element is string si
- ? int.Parse(si, culture)
- : Convert.ToInt32(element, culture),
- DataType.Float => element is string sf
- ? float.Parse(sf, culture)
- : Convert.ToSingle(element, culture),
- DataType.Double => element is string sd
- ? double.Parse(sd, culture)
- : Convert.ToDouble(element, culture),
- DataType.Boolean => element is string sb
- ? bool.Parse(sb)
- : Convert.ToBoolean(element, culture),
- DataType.DateTime => element is string sdt
- ? DateTime.Parse(sdt, culture, System.Globalization.DateTimeStyles.RoundtripKind)
- : Convert.ToDateTime(element, culture),
- _ => throw new FormatException($"Unsupported list element type '{t}'.")
- };
- }
-
private void HandleConnectionQualityChanged(ConnectionQualityChanged qualityChanged)
{
_logger.LogWarning("Connection {Connection} quality changed to {Quality} for instance {Instance}",
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/AttributeValueCodecTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/AttributeValueCodecTests.cs
index fa717bdb..3f81ba37 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/AttributeValueCodecTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/AttributeValueCodecTests.cs
@@ -231,4 +231,56 @@ public class AttributeValueCodecTests
[InlineData(DataType.List, false)]
public void IsValidElementType_MatchesScalarSet(DataType t, bool expected) =>
Assert.Equal(expected, AttributeValueCodec.IsValidElementType(t));
+
+ // ── ElementClrType + CoerceEnumerable: the shared object-input coercion ──
+ // entry point reused by InstanceActor (live OPC UA arrays). Distinct from the
+ // string-input path in Decode (JSON elements): here elements arrive as boxed
+ // CLR values and are converted via Convert.ToXxx (or parsed when string).
+
+ [Theory]
+ [InlineData(DataType.String, typeof(string))]
+ [InlineData(DataType.Int32, typeof(int))]
+ [InlineData(DataType.Float, typeof(float))]
+ [InlineData(DataType.Double, typeof(double))]
+ [InlineData(DataType.Boolean, typeof(bool))]
+ [InlineData(DataType.DateTime, typeof(DateTime))]
+ public void ElementClrType_MapsEachScalar(DataType t, Type expected) =>
+ Assert.Equal(expected, AttributeValueCodec.ElementClrType(t));
+
+ [Fact]
+ public void ElementClrType_RejectsNonElementType() =>
+ Assert.Throws(() => AttributeValueCodec.ElementClrType(DataType.List));
+
+ [Fact]
+ public void CoerceEnumerable_BuildsTypedList()
+ {
+ var list = AttributeValueCodec.CoerceEnumerable(new object[] { 10, 20, 30 }, DataType.Int32);
+ var typed = Assert.IsType>(list);
+ Assert.Equal(new[] { 10, 20, 30 }, typed);
+ }
+
+ [Fact]
+ public void CoerceEnumerable_ConvertsMixedClrElementTypes()
+ {
+ // Ints and numeric strings both coerce to List via Convert/parse —
+ // the object-input behavior InstanceActor's MV-8 coercion depends on.
+ var list = AttributeValueCodec.CoerceEnumerable(new object[] { 1, "2.5", 3.75 }, DataType.Double);
+ var typed = Assert.IsType>(list);
+ Assert.Equal(new[] { 1.0, 2.5, 3.75 }, typed);
+ }
+
+ [Fact]
+ public void CoerceEnumerable_DateTimeRoundtripsViaInvariantCulture()
+ {
+ var when = new DateTime(2026, 6, 19, 8, 30, 0, DateTimeKind.Utc);
+ var list = AttributeValueCodec.CoerceEnumerable(
+ new object[] { when.ToString("O", CultureInfo.InvariantCulture) }, DataType.DateTime);
+ var typed = Assert.IsType>(list);
+ Assert.Equal(when, typed[0]);
+ }
+
+ [Fact]
+ public void CoerceEnumerable_ThrowsOnUnsupportedElementType() =>
+ Assert.Throws(
+ () => AttributeValueCodec.CoerceEnumerable(new object[] { new byte[] { 1 } }, DataType.Binary));
}