Auto: twincat-1.4 — whole-array reads
Surface int[]? ArrayDimensions on TwinCATTagDefinition + thread it through ITwinCATClient.ReadValueAsync / WriteValueAsync. When non-null + non-empty, AdsTwinCATClient issues a single ADS read against the symbol with clrType.MakeArrayType() and returns the flat 1-D CLR Array; for IEC TIME / DATE / DT / TOD element types we project per-element to the native TimeSpan / DateTime so consumers see consistent types regardless of rank. DiscoverAsync surfaces IsArray=true + ArrayDim=product(dims) onto DriverAttributeInfo via a new ResolveArrayShape helper. Multi-dim shapes flatten to the product on the wire — DriverAttributeInfo.ArrayDim is single-uint today and the OPC UA layer reflects rank via its own metadata. Native ADS notification subscriptions skip whole-array tags so the OPC UA layer falls through to a polled snapshot — the per-element AdsNotificationEx callback shape doesn't fit a flat array. Whole-array WRITES are out of scope for this PR — AdsTwinCATClient.WriteValueAsync returns BadNotSupported when ArrayDimensions is set. Tests: TwinCATArrayReadTests covers ResolveArrayShape (null / empty / single-dim / multi-dim flatten / non-positive defensive), DiscoverAsync emitting IsArray + ArrayDim for declared array tags, single-dim + multi-dim fake-client read fan-out, and the BadNotSupported gate on whole-array writes. Existing 137 unit tests still pass — total now 143. Closes #308
This commit is contained in:
@@ -49,18 +49,27 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
string symbolPath,
|
||||
TwinCATDataType type,
|
||||
int? bitIndex,
|
||||
int[]? arrayDimensions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var clrType = MapToClrType(type);
|
||||
var result = await _client.ReadValueAsync(symbolPath, clrType, cancellationToken)
|
||||
var readType = IsWholeArray(arrayDimensions) ? clrType.MakeArrayType() : clrType;
|
||||
|
||||
var result = await _client.ReadValueAsync(symbolPath, readType, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (result.ErrorCode != AdsErrorCode.NoError)
|
||||
return (null, TwinCATStatusMapper.MapAdsError((uint)result.ErrorCode));
|
||||
|
||||
var value = result.Value;
|
||||
if (IsWholeArray(arrayDimensions))
|
||||
{
|
||||
value = PostProcessArray(type, value);
|
||||
return (value, TwinCATStatusMapper.Good);
|
||||
}
|
||||
|
||||
if (bitIndex is int bit && type == TwinCATDataType.Bool && value is not bool)
|
||||
value = ExtractBit(value, bit);
|
||||
value = PostProcessIecTime(type, value);
|
||||
@@ -73,13 +82,40 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsWholeArray(int[]? arrayDimensions) =>
|
||||
arrayDimensions is { Length: > 0 } && arrayDimensions.All(d => d > 0);
|
||||
|
||||
/// <summary>Apply per-element IEC TIME/DATE post-processing to a flat array result.</summary>
|
||||
private static object? PostProcessArray(TwinCATDataType type, object? value)
|
||||
{
|
||||
if (value is not Array arr) return value;
|
||||
var elementProjector = type switch
|
||||
{
|
||||
TwinCATDataType.Time or TwinCATDataType.TimeOfDay
|
||||
or TwinCATDataType.Date or TwinCATDataType.DateTime
|
||||
=> (Func<object?, object?>)(v => PostProcessIecTime(type, v)),
|
||||
_ => null,
|
||||
};
|
||||
if (elementProjector is null) return arr;
|
||||
// IEC time post-processing changes the CLR element type (uint -> TimeSpan / DateTime).
|
||||
// Project into an object[] so the array element type matches the projected values.
|
||||
var projected = new object?[arr.Length];
|
||||
for (var i = 0; i < arr.Length; i++)
|
||||
projected[i] = elementProjector(arr.GetValue(i));
|
||||
return projected;
|
||||
}
|
||||
|
||||
public async Task<uint> WriteValueAsync(
|
||||
string symbolPath,
|
||||
TwinCATDataType type,
|
||||
int? bitIndex,
|
||||
int[]? arrayDimensions,
|
||||
object? value,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (IsWholeArray(arrayDimensions))
|
||||
return TwinCATStatusMapper.BadNotSupported; // PR-1.4 ships read-only whole-array
|
||||
|
||||
if (bitIndex is int bit && type == TwinCATDataType.Bool)
|
||||
return await WriteBitInWordAsync(symbolPath, bit, value, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
@@ -22,22 +22,29 @@ public interface ITwinCATClient : IDisposable
|
||||
/// <summary>
|
||||
/// Read a symbolic value. Returns a boxed .NET value matching the requested
|
||||
/// <paramref name="type"/>, or <c>null</c> when the read produced no data; the
|
||||
/// <c>status</c> tuple member carries the mapped OPC UA status (0 = Good).
|
||||
/// <c>status</c> tuple member carries the mapped OPC UA status (0 = Good). When
|
||||
/// <paramref name="arrayDimensions"/> is non-null + non-empty, the symbol is treated
|
||||
/// as a whole-array read and the boxed value is a flat 1-D CLR
|
||||
/// <see cref="Array"/> sized to <c>product(arrayDimensions)</c>.
|
||||
/// </summary>
|
||||
Task<(object? value, uint status)> ReadValueAsync(
|
||||
string symbolPath,
|
||||
TwinCATDataType type,
|
||||
int? bitIndex,
|
||||
int[]? arrayDimensions,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Write a symbolic value. Returns the mapped OPC UA status for the operation
|
||||
/// (0 = Good, non-zero = error mapped via <see cref="TwinCATStatusMapper"/>).
|
||||
/// <paramref name="arrayDimensions"/> mirrors <see cref="ReadValueAsync"/>; PR-1.4
|
||||
/// ships read-only whole-array support so writers may surface <c>BadNotSupported</c>.
|
||||
/// </summary>
|
||||
Task<uint> WriteValueAsync(
|
||||
string symbolPath,
|
||||
TwinCATDataType type,
|
||||
int? bitIndex,
|
||||
int[]? arrayDimensions,
|
||||
object? value,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
|
||||
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
|
||||
var (value, status) = await client.ReadValueAsync(
|
||||
symbolName, def.DataType, parsed?.BitIndex, cancellationToken).ConfigureAwait(false);
|
||||
symbolName, def.DataType, parsed?.BitIndex, def.ArrayDimensions, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
results[i] = new DataValueSnapshot(value, status, now, now);
|
||||
if (status == TwinCATStatusMapper.Good)
|
||||
@@ -188,7 +188,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
|
||||
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
|
||||
var status = await client.WriteValueAsync(
|
||||
symbolName, def.DataType, parsed?.BitIndex, w.Value, cancellationToken).ConfigureAwait(false);
|
||||
symbolName, def.DataType, parsed?.BitIndex, def.ArrayDimensions, w.Value, cancellationToken).ConfigureAwait(false);
|
||||
results[i] = new WriteResult(status);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
@@ -231,11 +231,12 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
|
||||
foreach (var tag in tagsForDevice)
|
||||
{
|
||||
var (isArray, arrayDim) = ResolveArrayShape(tag.ArrayDimensions);
|
||||
deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
|
||||
FullName: tag.Name,
|
||||
DriverDataType: tag.DataType.ToDriverDataType(),
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
IsArray: isArray,
|
||||
ArrayDim: arrayDim,
|
||||
SecurityClass: tag.Writable
|
||||
? SecurityClassification.Operate
|
||||
: SecurityClassification.ViewOnly,
|
||||
@@ -310,6 +311,9 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
{
|
||||
if (!_tagsByName.TryGetValue(reference, out var def)) continue;
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) continue;
|
||||
// Whole-array tags don't fit the per-element AdsNotificationEx callback shape —
|
||||
// skip the native path so the OPC UA layer falls through to a polled snapshot.
|
||||
if (def.ArrayDimensions is { Length: > 0 }) continue;
|
||||
|
||||
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
|
||||
@@ -428,6 +432,25 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
return device.Client;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Project a TwinCAT <see cref="TwinCATTagDefinition.ArrayDimensions"/> shape onto the
|
||||
/// core <see cref="DriverAttributeInfo"/> 1-D surface. Multi-dim arrays flatten to the
|
||||
/// product element count — the OPC UA address-space layer surfaces the rank via its own
|
||||
/// <c>ArrayDimensions</c> metadata at variable build time.
|
||||
/// </summary>
|
||||
internal static (bool isArray, uint? arrayDim) ResolveArrayShape(int[]? dimensions)
|
||||
{
|
||||
if (dimensions is null || dimensions.Length == 0) return (false, null);
|
||||
long product = 1;
|
||||
foreach (var d in dimensions)
|
||||
{
|
||||
if (d <= 0) return (false, null); // invalid shape; surface as scalar to fail safe
|
||||
product *= d;
|
||||
if (product > uint.MaxValue) return (true, uint.MaxValue);
|
||||
}
|
||||
return (true, (uint)product);
|
||||
}
|
||||
|
||||
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
|
||||
@@ -43,8 +43,12 @@ public sealed record TwinCATDeviceOptions(
|
||||
string? DeviceName = null);
|
||||
|
||||
/// <summary>
|
||||
/// 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>).
|
||||
/// One TwinCAT-backed OPC UA variable. <c>SymbolPath</c> is the full TwinCAT symbolic name
|
||||
/// (e.g. <c>MAIN.bStart</c>, <c>GVL.Counter</c>, <c>Motor1.Status.Running</c>). When
|
||||
/// <c>ArrayDimensions</c> is non-null + non-empty the symbol is treated as a whole-array
|
||||
/// read of <c>product(dims)</c> elements rather than a single scalar — PR-1.4 ships read-
|
||||
/// only whole-array support; multi-dim shapes flatten to the product on the wire and the
|
||||
/// OPC UA layer reflects the rank via its own <c>ArrayDimensions</c> metadata.
|
||||
/// </summary>
|
||||
public sealed record TwinCATTagDefinition(
|
||||
string Name,
|
||||
@@ -52,7 +56,8 @@ public sealed record TwinCATTagDefinition(
|
||||
string SymbolPath,
|
||||
TwinCATDataType DataType,
|
||||
bool Writable = true,
|
||||
bool WriteIdempotent = false);
|
||||
bool WriteIdempotent = false,
|
||||
int[]? ArrayDimensions = null);
|
||||
|
||||
public sealed class TwinCATProbeOptions
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user