feat(twincat): 1-D array symbol read via ADS + IsArray discovery
This commit is contained in:
@@ -70,13 +70,19 @@ public sealed record TwinCATDeviceOptions(
|
||||
/// One TwinCAT-backed OPC UA variable. <paramref name="SymbolPath"/> is the full TwinCAT
|
||||
/// symbolic name (e.g. <c>MAIN.bStart</c>, <c>GVL.Counter</c>, <c>Motor1.Status.Running</c>).
|
||||
/// </summary>
|
||||
/// <param name="ArrayLength">
|
||||
/// When non-null, this tag is a 1-D array of <paramref name="ArrayLength"/> elements of
|
||||
/// <paramref name="DataType"/>. Drives <c>IsArray</c>/<c>ArrayDim</c> at discovery and a
|
||||
/// native ADS array read at runtime (Phase 4c). <c>null</c> = scalar (the default).
|
||||
/// </param>
|
||||
public sealed record TwinCATTagDefinition(
|
||||
string Name,
|
||||
string DeviceHostAddress,
|
||||
string SymbolPath,
|
||||
TwinCATDataType DataType,
|
||||
bool Writable = true,
|
||||
bool WriteIdempotent = false);
|
||||
bool WriteIdempotent = false,
|
||||
int? ArrayLength = null);
|
||||
|
||||
/// <summary>Probe options for TwinCAT connection monitoring.</summary>
|
||||
public sealed class TwinCATProbeOptions
|
||||
|
||||
+20
-1
@@ -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() ?? "" : "";
|
||||
|
||||
/// <summary>
|
||||
/// Reads the optional 1-D array length: <c>arrayLength</c> (a positive uint) honoured ONLY
|
||||
/// when <c>isArray</c> is the JSON literal <c>true</c>. Returns <c>null</c> (scalar) when
|
||||
/// isArray is absent/false, when arrayLength is absent / non-numeric / zero / negative.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,12 +97,15 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
/// <param name="symbolPath">The ADS symbol path to read from.</param>
|
||||
/// <param name="type">The TwinCAT data type.</param>
|
||||
/// <param name="bitIndex">Optional bit index for BOOL values within larger containers.</param>
|
||||
/// <param name="arrayCount">When non-null, read a 1-D array of this many <paramref name="type"/>
|
||||
/// elements; the boxed value is the element-typed CLR array (e.g. <c>int[]</c>). Phase 4c.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A tuple containing the value and OPC UA status code.</returns>
|
||||
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<T[]> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a symbol's <see cref="IDataType"/> to the driver's atomic
|
||||
/// <see cref="TwinCATDataType"/> plus an optional 1-D array length.
|
||||
/// <para>
|
||||
/// A 1-D array symbol (<c>Category == Array</c>, <see cref="IArrayType.Dimensions"/> count 1)
|
||||
/// surfaces as its ELEMENT type with the dimension's <see cref="IDimension.ElementCount"/> as
|
||||
/// the array length. ASSUMPTION (no live ADS here): the array <see cref="IDataType"/> exposes
|
||||
/// <see cref="IArrayType"/> with <see cref="IArrayType.ElementType"/> + a
|
||||
/// <see cref="IArrayType.Dimensions"/> collection whose single <see cref="IDimension"/> carries
|
||||
/// <see cref="IDimension.ElementCount"/> — matches the TwinCAT TypeSystem surface
|
||||
/// (<c>TwinCAT.TypeSystem.IArrayType</c> / <c>IDimensionCollection.GetDimensionLengths()</c>).
|
||||
/// Verified by unit test against the fake client only.
|
||||
/// </para>
|
||||
/// Multi-dimensional arrays (Dimensions.Count > 1, including jagged) are NOT in scope for
|
||||
/// Phase 4c — they fall through as a scalar/unsupported <c>null</c> (which DiscoverAsync drops),
|
||||
/// never silently mis-reported as a 1-D array.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
@@ -40,11 +40,15 @@ public interface ITwinCATClient : IDisposable
|
||||
/// <param name="symbolPath">The ADS symbol path.</param>
|
||||
/// <param name="type">The target data type.</param>
|
||||
/// <param name="bitIndex">Optional bit index for bit extraction within a word.</param>
|
||||
/// <param name="arrayCount">When non-null, read a 1-D array of this many <paramref name="type"/>
|
||||
/// elements; the boxed value is the element-typed CLR array (<c>int[]</c> / <c>float[]</c> /
|
||||
/// <c>bool[]</c> / <c>string[]</c> / …). When null, read a scalar (Phase 4c).</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||||
Task<(object? value, uint status)> ReadValueAsync(
|
||||
string symbolPath,
|
||||
TwinCATDataType type,
|
||||
int? bitIndex,
|
||||
int? arrayCount,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
@@ -113,13 +117,19 @@ public interface ITwinCATNotificationHandle : IDisposable { }
|
||||
/// path + detected <see cref="TwinCATDataType"/> + read-only flag.
|
||||
/// </summary>
|
||||
/// <param name="InstancePath">Full dotted symbol path (e.g. <c>MAIN.bStart</c>, <c>GVL.Counter</c>).</param>
|
||||
/// <param name="DataType">Mapped <see cref="TwinCATDataType"/>; <c>null</c> when the symbol's type
|
||||
/// doesn't map onto our supported atomic surface (UDTs, pointers, function blocks).</param>
|
||||
/// <param name="DataType">Mapped <see cref="TwinCATDataType"/> of the (element) type; <c>null</c>
|
||||
/// 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 <paramref name="ArrayLength"/>
|
||||
/// carrying the dimension.</param>
|
||||
/// <param name="ReadOnly"><c>true</c> when the symbol's AccessRights flag forbids writes.</param>
|
||||
/// <param name="ArrayLength">When non-null, the symbol is a 1-D array of this many
|
||||
/// <paramref name="DataType"/> elements. <c>null</c> = scalar. Multi-dimensional arrays are
|
||||
/// reported as <c>null</c> (treated as scalar/unsupported) — only 1-D is in scope for Phase 4c.</param>
|
||||
public sealed record TwinCATDiscoveredSymbol(
|
||||
string InstancePath,
|
||||
TwinCATDataType? DataType,
|
||||
bool ReadOnly);
|
||||
bool ReadOnly,
|
||||
int? ArrayLength = null);
|
||||
|
||||
/// <summary>Factory for <see cref="ITwinCATClient"/>s. One client per device.</summary>
|
||||
public interface ITwinCATClientFactory
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -27,6 +27,10 @@ internal class FakeTwinCATClient : ITwinCATClient
|
||||
public Dictionary<string, uint> ReadStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
/// <summary>Gets the write statuses by symbol path.</summary>
|
||||
public Dictionary<string, uint> WriteStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
/// <summary>Records the <c>arrayCount</c> argument of the most recent <see cref="ReadValueAsync"/>
|
||||
/// call (null = scalar read) so Phase 4c array-read tests can assert the driver requested
|
||||
/// the array with the authored length.</summary>
|
||||
public int? LastReadArrayCount { get; private set; }
|
||||
/// <summary>Gets the log of all write operations.</summary>
|
||||
public List<(string symbol, TwinCATDataType type, int? bit, object? value)> WriteLog { get; } = new();
|
||||
/// <summary>Gets or sets the result returned by ProbeAsync.</summary>
|
||||
@@ -55,12 +59,14 @@ internal class FakeTwinCATClient : ITwinCATClient
|
||||
/// <param name="symbolPath">The path to the symbol to read.</param>
|
||||
/// <param name="type">The data type of the symbol.</param>
|
||||
/// <param name="bitIndex">The optional bit index for bit-level reads.</param>
|
||||
/// <param name="arrayCount">The optional 1-D array element count (null = scalar read).</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task that returns the simulated value and status.</returns>
|
||||
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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <c>arrayLength</c> from the TagConfig JSON.
|
||||
/// </summary>
|
||||
[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 ----
|
||||
|
||||
/// <summary>A pre-declared tag carrying an ArrayLength surfaces as a 1-D array node.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>A scalar pre-declared tag still reports IsArray=false / ArrayDim=null.</summary>
|
||||
[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 ----
|
||||
|
||||
/// <summary>A discovered (browsed) 1-D array symbol surfaces as a 1-D array node.</summary>
|
||||
[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 ----
|
||||
|
||||
/// <summary>An equipment array tag reads a typed CLR array (boxed as object) through the driver.</summary>
|
||||
[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<int[]>().ShouldBe(expected);
|
||||
// The driver must request the array read with the authored length.
|
||||
factory.Clients[0].LastReadArrayCount.ShouldBe(4);
|
||||
}
|
||||
|
||||
/// <summary>A scalar equipment tag reads with a null array count (scalar path unchanged).</summary>
|
||||
[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 ----
|
||||
|
||||
/// <summary>The equipment-tag parser threads <c>arrayLength</c> into the transient definition.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>A blob without isArray (or with isArray=false) leaves ArrayLength null.</summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>arrayLength is ignored when isArray is absent/false (no orphan length).</summary>
|
||||
[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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user