diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATDriverOptions.cs
index 8fddaad4..dbd33435 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATDriverOptions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATDriverOptions.cs
@@ -70,13 +70,19 @@ public sealed record TwinCATDeviceOptions(
/// One TwinCAT-backed OPC UA variable. is the full TwinCAT
/// symbolic name (e.g. MAIN.bStart, GVL.Counter, Motor1.Status.Running).
///
+///
+/// When non-null, this tag is a 1-D array of elements of
+/// . Drives IsArray/ArrayDim at discovery and a
+/// native ADS array read at runtime (Phase 4c). null = scalar (the default).
+///
public sealed record TwinCATTagDefinition(
string Name,
string DeviceHostAddress,
string SymbolPath,
TwinCATDataType DataType,
bool Writable = true,
- bool WriteIdempotent = false);
+ bool WriteIdempotent = false,
+ int? ArrayLength = null);
/// Probe options for TwinCAT connection monitoring.
public sealed class TwinCATProbeOptions
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATEquipmentTagParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATEquipmentTagParser.cs
index 09e0e41c..4c6131b7 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATEquipmentTagParser.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATEquipmentTagParser.cs
@@ -29,9 +29,14 @@ public static class TwinCATEquipmentTagParser
if (string.IsNullOrWhiteSpace(symbolPath)) return false;
var deviceHostAddress = ReadString(root, "deviceHostAddress");
var dataType = ReadEnum(root, "dataType", TwinCATDataType.DInt);
+ // Array intent — same shape the runtime/OPC-UA foundation parses (camelCase
+ // `isArray` bool + `arrayLength` uint). arrayLength is honoured ONLY when isArray
+ // is true AND it is a positive JSON number, so a stale length behind a cleared
+ // isArray never produces an orphan array tag (Phase 4c).
+ var arrayLength = ReadArrayLength(root);
def = new TwinCATTagDefinition(
Name: reference, DeviceHostAddress: deviceHostAddress, SymbolPath: symbolPath,
- DataType: dataType, Writable: true);
+ DataType: dataType, Writable: true, ArrayLength: arrayLength);
return true;
}
catch (JsonException) { return false; }
@@ -46,4 +51,18 @@ public static class TwinCATEquipmentTagParser
private static string ReadString(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
? e.GetString() ?? "" : "";
+
+ ///
+ /// Reads the optional 1-D array length: arrayLength (a positive uint) honoured ONLY
+ /// when isArray is the JSON literal true. Returns null (scalar) when
+ /// isArray is absent/false, when arrayLength is absent / non-numeric / zero / negative.
+ ///
+ private static int? ReadArrayLength(JsonElement o)
+ {
+ if (!o.TryGetProperty("isArray", out var aEl) || aEl.ValueKind != JsonValueKind.True)
+ return null;
+ if (!o.TryGetProperty("arrayLength", out var lEl) || lEl.ValueKind != JsonValueKind.Number)
+ return null;
+ return lEl.TryGetInt32(out var len) && len > 0 ? len : null;
+ }
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs
index 16325afb..a19da231 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs
@@ -97,12 +97,15 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
/// The ADS symbol path to read from.
/// The TwinCAT data type.
/// Optional bit index for BOOL values within larger containers.
+ /// When non-null, read a 1-D array of this many
+ /// elements; the boxed value is the element-typed CLR array (e.g. int[]). Phase 4c.
/// The cancellation token.
/// A tuple containing the value and OPC UA status code.
public async Task<(object? value, uint status)> ReadValueAsync(
string symbolPath,
TwinCATDataType type,
int? bitIndex,
+ int? arrayCount,
CancellationToken cancellationToken)
{
try
@@ -112,7 +115,8 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
// container as its widest unsigned primitive and extract the bit locally. The
// .N suffix added by TwinCATSymbolPath.ToAdsSymbolName needs to come back off
// first. uint covers WORD / DWORD containers; BYTE-sized bit containers are
- // rare in real code and promoting to uint is harmless for them.
+ // rare in real code and promoting to uint is harmless for them. Bit-within-word
+ // is inherently scalar — arrayCount does not apply.
if (bitIndex is int bit && type == TwinCATDataType.Bool)
{
var parent = StripBitSuffix(symbolPath);
@@ -123,6 +127,25 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
return (ExtractBit(parentResult.Value, bit), TwinCATStatusMapper.Good);
}
+ // Array read — ADS reads array symbols natively into a typed managed array. We ask
+ // the SDK for the element-CLR-type's 1-D array (e.g. int[]); the .NET binding
+ // marshals the whole array in one read. The returned value is already the boxed
+ // element-typed array (int[] / float[] / bool[] / string[] / …), so the runtime's
+ // OPC UA layer sees a proper 1-D array value. ASSUMPTION (no live ADS here):
+ // AdsClient.ReadValueAsync(symbol, elementType.MakeArrayType(), ct) returns the
+ // typed array — this matches Beckhoff's documented generic ReadValue surface
+ // (InfoSys tcadsnetref / SumRead samples). Verified only against the fake client.
+ if (arrayCount is int count && count > 0)
+ {
+ var elementType = MapToClrType(type);
+ var arrayType = elementType.MakeArrayType();
+ var arrResult = await _client.ReadValueAsync(symbolPath, arrayType, cancellationToken)
+ .ConfigureAwait(false);
+ if (arrResult.ErrorCode != AdsErrorCode.NoError)
+ return (null, MapAndSignal((uint)arrResult.ErrorCode));
+ return (arrResult.Value, TwinCATStatusMapper.Good);
+ }
+
var clrType = MapToClrType(type);
var result = await _client.ReadValueAsync(symbolPath, clrType, cancellationToken)
.ConfigureAwait(false);
@@ -313,12 +336,49 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
// distinctly from a genuine browse failure; a yield break would let a partial
// symbol set appear as a fully successful discovery (Driver.TwinCAT-010).
cancellationToken.ThrowIfCancellationRequested();
- var mapped = MapSymbolTypeName(symbol.DataType?.Name);
+ var (mapped, arrayLength) = MapSymbolType(symbol.DataType);
var readOnly = !IsSymbolWritable(symbol);
- yield return new TwinCATDiscoveredSymbol(symbol.InstancePath, mapped, readOnly);
+ yield return new TwinCATDiscoveredSymbol(symbol.InstancePath, mapped, readOnly, arrayLength);
}
}
+ ///
+ /// Resolves a symbol's to the driver's atomic
+ /// plus an optional 1-D array length.
+ ///
+ /// A 1-D array symbol (Category == Array, count 1)
+ /// surfaces as its ELEMENT type with the dimension's as
+ /// the array length. ASSUMPTION (no live ADS here): the array exposes
+ /// with + a
+ /// collection whose single carries
+ /// — matches the TwinCAT TypeSystem surface
+ /// (TwinCAT.TypeSystem.IArrayType / IDimensionCollection.GetDimensionLengths()).
+ /// Verified by unit test against the fake client only.
+ ///
+ /// Multi-dimensional arrays (Dimensions.Count > 1, including jagged) are NOT in scope for
+ /// Phase 4c — they fall through as a scalar/unsupported null (which DiscoverAsync drops),
+ /// never silently mis-reported as a 1-D array.
+ ///
+ private static (TwinCATDataType? type, int? arrayLength) MapSymbolType(IDataType? dataType)
+ {
+ if (dataType is null) return (null, null);
+
+ if (dataType.Category == DataTypeCategory.Array && dataType is IArrayType arr)
+ {
+ // 1-D only. A multi-dim / jagged array is out of atomic scope → drop (null).
+ var dims = arr.Dimensions;
+ if (arr.IsJagged || dims is null || dims.Count != 1) return (null, null);
+
+ var element = MapSymbolTypeName(arr.ElementType?.Name);
+ if (element is null) return (null, null); // element type is itself unsupported (UDT array etc.)
+
+ var length = dims[0].ElementCount;
+ return length > 0 ? (element, length) : (null, null);
+ }
+
+ return (MapSymbolTypeName(dataType.Name), null);
+ }
+
private static TwinCATDataType? MapSymbolTypeName(string? typeName)
{
if (typeName is null) return null;
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs
index 03a12b4b..f76bbb9e 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs
@@ -40,11 +40,15 @@ public interface ITwinCATClient : IDisposable
/// The ADS symbol path.
/// The target data type.
/// Optional bit index for bit extraction within a word.
+ /// When non-null, read a 1-D array of this many
+ /// elements; the boxed value is the element-typed CLR array (int[] / float[] /
+ /// bool[] / string[] / …). When null, read a scalar (Phase 4c).
/// Cancellation token for the read operation.
Task<(object? value, uint status)> ReadValueAsync(
string symbolPath,
TwinCATDataType type,
int? bitIndex,
+ int? arrayCount,
CancellationToken cancellationToken);
///
@@ -113,13 +117,19 @@ public interface ITwinCATNotificationHandle : IDisposable { }
/// path + detected + read-only flag.
///
/// Full dotted symbol path (e.g. MAIN.bStart, GVL.Counter).
-/// Mapped ; null when the symbol's type
-/// doesn't map onto our supported atomic surface (UDTs, pointers, function blocks).
+/// Mapped of the (element) type; null
+/// when the symbol's type doesn't map onto our supported atomic surface (UDTs, pointers,
+/// function blocks). For an array symbol this is the ELEMENT type, with
+/// carrying the dimension.
/// true when the symbol's AccessRights flag forbids writes.
+/// When non-null, the symbol is a 1-D array of this many
+/// elements. null = scalar. Multi-dimensional arrays are
+/// reported as null (treated as scalar/unsupported) — only 1-D is in scope for Phase 4c.
public sealed record TwinCATDiscoveredSymbol(
string InstancePath,
TwinCATDataType? DataType,
- bool ReadOnly);
+ bool ReadOnly,
+ int? ArrayLength = null);
/// Factory for s. One client per device.
public interface ITwinCATClientFactory
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
index 37c61c8d..4baa97b3 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
@@ -227,8 +227,13 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
+ // An array-typed tag (def.ArrayLength != null) drives a native 1-D ADS array read;
+ // the boxed result is an element-typed CLR array. Scalar tags pass null
+ // (scalar path) — bit-indexed BOOLs are inherently scalar so the client ignores
+ // arrayCount when a bitIndex is present (Phase 4c).
var (value, status) = await client.ReadValueAsync(
- symbolName, def.DataType, parsed?.BitIndex, cancellationToken).ConfigureAwait(false);
+ symbolName, def.DataType, parsed?.BitIndex, def.ArrayLength, cancellationToken)
+ .ConfigureAwait(false);
results[i] = new DataValueSnapshot(value, status, now, now);
if (status == TwinCATStatusMapper.Good)
@@ -340,8 +345,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
FullName: tag.Name,
DriverDataType: tag.DataType.ToDriverDataType(),
- IsArray: false,
- ArrayDim: null,
+ // A pre-declared tag with a positive ArrayLength is a 1-D array node; null
+ // (or non-positive) stays scalar (Phase 4c).
+ IsArray: tag.ArrayLength is > 0,
+ ArrayDim: tag.ArrayLength is > 0 ? (uint)tag.ArrayLength.Value : null,
SecurityClass: tag.Writable
? SecurityClassification.Operate
: SecurityClassification.ViewOnly,
@@ -367,8 +374,12 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
discoveredFolder.Variable(sym.InstancePath, sym.InstancePath, new DriverAttributeInfo(
FullName: sym.InstancePath,
DriverDataType: dt.ToDriverDataType(),
- IsArray: false,
- ArrayDim: null,
+ // A discovered 1-D array symbol carries its element count in
+ // sym.ArrayLength (the browser reports the ELEMENT type as dt);
+ // multi-dim/unsupported arrays arrive with null ArrayLength → scalar
+ // (Phase 4c).
+ IsArray: sym.ArrayLength is > 0,
+ ArrayDim: sym.ArrayLength is > 0 ? (uint)sym.ArrayLength.Value : null,
SecurityClass: sym.ReadOnly
? SecurityClassification.ViewOnly
: SecurityClassification.Operate,
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs
index 41495e23..46830b95 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs
@@ -27,6 +27,10 @@ internal class FakeTwinCATClient : ITwinCATClient
public Dictionary ReadStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
/// Gets the write statuses by symbol path.
public Dictionary WriteStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
+ /// Records the arrayCount argument of the most recent
+ /// call (null = scalar read) so Phase 4c array-read tests can assert the driver requested
+ /// the array with the authored length.
+ public int? LastReadArrayCount { get; private set; }
/// Gets the log of all write operations.
public List<(string symbol, TwinCATDataType type, int? bit, object? value)> WriteLog { get; } = new();
/// Gets or sets the result returned by ProbeAsync.
@@ -55,12 +59,14 @@ internal class FakeTwinCATClient : ITwinCATClient
/// The path to the symbol to read.
/// The data type of the symbol.
/// The optional bit index for bit-level reads.
+ /// The optional 1-D array element count (null = scalar read).
/// The cancellation token.
/// A task that returns the simulated value and status.
public virtual Task<(object? value, uint status)> ReadValueAsync(
- string symbolPath, TwinCATDataType type, int? bitIndex, CancellationToken ct)
+ string symbolPath, TwinCATDataType type, int? bitIndex, int? arrayCount, CancellationToken ct)
{
if (ThrowOnRead) throw Exception ?? new InvalidOperationException();
+ LastReadArrayCount = arrayCount;
var status = ReadStatuses.TryGetValue(symbolPath, out var s) ? s : TwinCATStatusMapper.Good;
var value = Values.TryGetValue(symbolPath, out var v) ? v : null;
return Task.FromResult((value, status));
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATArraySupportTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATArraySupportTests.cs
new file mode 100644
index 00000000..fd5ec364
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATArraySupportTests.cs
@@ -0,0 +1,215 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
+
+///
+/// Phase 4c — 1-D array support for the TwinCAT (ADS) driver. Covers the two discovery
+/// hard-wire flips (pre-declared + discovered symbols now report IsArray/ArrayDim from the
+/// ADS symbol's array dimension), the array READ path (the equipment tag's arrayLength drives
+/// a native array read boxed into a typed CLR array), and the equipment-tag parser threading
+/// arrayLength from the TagConfig JSON.
+///
+[Trait("Category", "Unit")]
+public sealed class TwinCATArraySupportTests
+{
+ private const string Host = "ads://5.23.91.23.1.1:851";
+
+ // ---- (1) discovery: pre-declared array tag flips IsArray/ArrayDim ----
+
+ /// A pre-declared tag carrying an ArrayLength surfaces as a 1-D array node.
+ [Fact]
+ public async Task Predeclared_array_tag_reports_IsArray_and_ArrayDim()
+ {
+ var builder = new RecordingBuilder();
+ var drv = new TwinCATDriver(new TwinCATDriverOptions
+ {
+ Devices = [new TwinCATDeviceOptions(Host)],
+ Tags =
+ [
+ new TwinCATTagDefinition("Speeds", Host, "MAIN.Speeds", TwinCATDataType.DInt,
+ Writable: true, WriteIdempotent: false, ArrayLength: 8),
+ ],
+ Probe = new TwinCATProbeOptions { Enabled = false },
+ EnableControllerBrowse = false,
+ }, "drv-1", new FakeTwinCATClientFactory());
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ await drv.DiscoverAsync(builder, CancellationToken.None);
+
+ var v = builder.Variables.Single(x => x.BrowseName == "Speeds").Info;
+ v.IsArray.ShouldBeTrue();
+ v.ArrayDim.ShouldBe(8u);
+ }
+
+ /// A scalar pre-declared tag still reports IsArray=false / ArrayDim=null.
+ [Fact]
+ public async Task Predeclared_scalar_tag_stays_scalar()
+ {
+ var builder = new RecordingBuilder();
+ var drv = new TwinCATDriver(new TwinCATDriverOptions
+ {
+ Devices = [new TwinCATDeviceOptions(Host)],
+ Tags = [new TwinCATTagDefinition("Speed", Host, "MAIN.Speed", TwinCATDataType.DInt)],
+ Probe = new TwinCATProbeOptions { Enabled = false },
+ }, "drv-1", new FakeTwinCATClientFactory());
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ await drv.DiscoverAsync(builder, CancellationToken.None);
+
+ var v = builder.Variables.Single(x => x.BrowseName == "Speed").Info;
+ v.IsArray.ShouldBeFalse();
+ v.ArrayDim.ShouldBeNull();
+ }
+
+ // ---- (1) discovery: discovered (browsed) array symbol flips IsArray/ArrayDim ----
+
+ /// A discovered (browsed) 1-D array symbol surfaces as a 1-D array node.
+ [Fact]
+ public async Task Discovered_array_symbol_reports_IsArray_and_ArrayDim()
+ {
+ var builder = new RecordingBuilder();
+ var factory = new FakeTwinCATClientFactory
+ {
+ Customise = () =>
+ {
+ var c = new FakeTwinCATClient();
+ c.BrowseResults.Add(new TwinCATDiscoveredSymbol(
+ "MAIN.Buffer", TwinCATDataType.Int, ReadOnly: false, ArrayLength: 16));
+ c.BrowseResults.Add(new TwinCATDiscoveredSymbol(
+ "GVL.Scalar", TwinCATDataType.Real, ReadOnly: false));
+ return c;
+ },
+ };
+ var drv = new TwinCATDriver(new TwinCATDriverOptions
+ {
+ Devices = [new TwinCATDeviceOptions(Host)],
+ Probe = new TwinCATProbeOptions { Enabled = false },
+ EnableControllerBrowse = true,
+ }, "drv-1", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ await drv.DiscoverAsync(builder, CancellationToken.None);
+
+ var arr = builder.Variables.Single(x => x.Info.FullName == "MAIN.Buffer").Info;
+ arr.IsArray.ShouldBeTrue();
+ arr.ArrayDim.ShouldBe(16u);
+
+ var scalar = builder.Variables.Single(x => x.Info.FullName == "GVL.Scalar").Info;
+ scalar.IsArray.ShouldBeFalse();
+ scalar.ArrayDim.ShouldBeNull();
+ }
+
+ // ---- (2) read path: array read returns a typed CLR array ----
+
+ /// An equipment array tag reads a typed CLR array (boxed as object) through the driver.
+ [Fact]
+ public async Task Driver_reads_an_array_equipment_tag_as_typed_clr_array()
+ {
+ var json =
+ $$"""{"deviceHostAddress":"{{Host}}","symbolPath":"MAIN.Speeds","dataType":"DInt","isArray":true,"arrayLength":4}""";
+ var expected = new[] { 10, 20, 30, 40 };
+ var factory = new FakeTwinCATClientFactory
+ {
+ Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Speeds"] = expected } },
+ };
+ var drv = new TwinCATDriver(new TwinCATDriverOptions
+ {
+ Devices = [new TwinCATDeviceOptions(Host)],
+ Tags = [],
+ Probe = new TwinCATProbeOptions { Enabled = false },
+ }, "twincat-arr", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var r = await drv.ReadAsync([json], CancellationToken.None);
+
+ r[0].StatusCode.ShouldBe(TwinCATStatusMapper.Good);
+ r[0].Value.ShouldBeOfType().ShouldBe(expected);
+ // The driver must request the array read with the authored length.
+ factory.Clients[0].LastReadArrayCount.ShouldBe(4);
+ }
+
+ /// A scalar equipment tag reads with a null array count (scalar path unchanged).
+ [Fact]
+ public async Task Driver_reads_a_scalar_equipment_tag_with_null_array_count()
+ {
+ var json = $$"""{"deviceHostAddress":"{{Host}}","symbolPath":"MAIN.Speed","dataType":"DInt"}""";
+ var factory = new FakeTwinCATClientFactory
+ {
+ Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Speed"] = 7 } },
+ };
+ var drv = new TwinCATDriver(new TwinCATDriverOptions
+ {
+ Devices = [new TwinCATDeviceOptions(Host)],
+ Tags = [],
+ Probe = new TwinCATProbeOptions { Enabled = false },
+ }, "twincat-scalar", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var r = await drv.ReadAsync([json], CancellationToken.None);
+
+ r[0].StatusCode.ShouldBe(TwinCATStatusMapper.Good);
+ r[0].Value.ShouldBe(7);
+ factory.Clients[0].LastReadArrayCount.ShouldBeNull();
+ }
+
+ // ---- (3) resolver: equipment-tag parser threads arrayLength ----
+
+ /// The equipment-tag parser threads arrayLength into the transient definition.
+ [Fact]
+ public void Parser_threads_arrayLength_into_the_definition()
+ {
+ var json =
+ """{"deviceHostAddress":"ads://5.23.91.23.1.1:851","symbolPath":"MAIN.Speeds","dataType":"DInt","isArray":true,"arrayLength":12}""";
+ TwinCATEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
+ def!.ArrayLength.ShouldBe(12);
+ }
+
+ /// A blob without isArray (or with isArray=false) leaves ArrayLength null.
+ [Fact]
+ public void Parser_leaves_ArrayLength_null_for_a_scalar_blob()
+ {
+ TwinCATEquipmentTagParser.TryParse(
+ """{"symbolPath":"MAIN.X","dataType":"DInt"}""", out var def).ShouldBeTrue();
+ def!.ArrayLength.ShouldBeNull();
+ }
+
+ /// arrayLength is ignored when isArray is absent/false (no orphan length).
+ [Fact]
+ public void Parser_ignores_arrayLength_when_isArray_is_false()
+ {
+ TwinCATEquipmentTagParser.TryParse(
+ """{"symbolPath":"MAIN.X","dataType":"DInt","isArray":false,"arrayLength":9}""",
+ out var def).ShouldBeTrue();
+ def!.ArrayLength.ShouldBeNull();
+ }
+
+ // ---- helpers ----
+
+ private sealed class RecordingBuilder : IAddressSpaceBuilder
+ {
+ public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
+ public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
+
+ public IAddressSpaceBuilder Folder(string browseName, string displayName)
+ { Folders.Add((browseName, displayName)); return this; }
+
+ public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
+ { Variables.Add((browseName, info)); return new Handle(info.FullName); }
+
+ public void AddProperty(string _, DriverDataType __, object? ___) { }
+
+ private sealed class Handle(string fullRef) : IVariableHandle
+ {
+ public string FullReference => fullRef;
+ public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
+ }
+
+ private sealed class NullSink : IAlarmConditionSink
+ {
+ public void OnTransition(AlarmEventArgs args) { }
+ }
+ }
+}