feat(twincat): 1-D array symbol read via ADS + IsArray discovery

This commit is contained in:
Joseph Doherty
2026-06-16 21:59:17 -04:00
parent 950069392c
commit 3e74239532
7 changed files with 341 additions and 14 deletions
@@ -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;