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,
|
||||
|
||||
Reference in New Issue
Block a user