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
|
||||
{
|
||||
|
||||
@@ -17,6 +17,7 @@ internal class FakeTwinCATClient : ITwinCATClient
|
||||
public Dictionary<string, uint> ReadStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public Dictionary<string, uint> WriteStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public List<(string symbol, TwinCATDataType type, int? bit, object? value)> WriteLog { get; } = new();
|
||||
public List<(string symbol, TwinCATDataType type, int? bit, int[]? arrayDimensions)> ReadLog { get; } = new();
|
||||
public bool ProbeResult { get; set; } = true;
|
||||
|
||||
public virtual Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken ct)
|
||||
@@ -28,16 +29,17 @@ internal class FakeTwinCATClient : ITwinCATClient
|
||||
}
|
||||
|
||||
public virtual Task<(object? value, uint status)> ReadValueAsync(
|
||||
string symbolPath, TwinCATDataType type, int? bitIndex, CancellationToken ct)
|
||||
string symbolPath, TwinCATDataType type, int? bitIndex, int[]? arrayDimensions, CancellationToken ct)
|
||||
{
|
||||
if (ThrowOnRead) throw Exception ?? new InvalidOperationException();
|
||||
ReadLog.Add((symbolPath, type, bitIndex, arrayDimensions));
|
||||
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));
|
||||
}
|
||||
|
||||
public virtual Task<uint> WriteValueAsync(
|
||||
string symbolPath, TwinCATDataType type, int? bitIndex, object? value, CancellationToken ct)
|
||||
string symbolPath, TwinCATDataType type, int? bitIndex, int[]? arrayDimensions, object? value, CancellationToken ct)
|
||||
{
|
||||
if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
|
||||
WriteLog.Add((symbolPath, type, bitIndex, value));
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
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;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TwinCATArrayReadTests
|
||||
{
|
||||
private const string Host = "ads://5.23.91.23.1.1:851";
|
||||
|
||||
private static (TwinCATDriver drv, FakeTwinCATClientFactory factory) NewDriver(params TwinCATTagDefinition[] tags)
|
||||
{
|
||||
var factory = new FakeTwinCATClientFactory();
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions(Host)],
|
||||
Tags = tags,
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
// ---- Array shape mapping ----
|
||||
|
||||
[Fact]
|
||||
public void ResolveArrayShape_returns_scalar_for_null_dimensions()
|
||||
{
|
||||
var (isArray, dim) = TwinCATDriver.ResolveArrayShape(null);
|
||||
isArray.ShouldBeFalse();
|
||||
dim.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveArrayShape_returns_scalar_for_empty_dimensions()
|
||||
{
|
||||
var (isArray, dim) = TwinCATDriver.ResolveArrayShape([]);
|
||||
isArray.ShouldBeFalse();
|
||||
dim.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveArrayShape_returns_length_for_single_dim()
|
||||
{
|
||||
var (isArray, dim) = TwinCATDriver.ResolveArrayShape([10]);
|
||||
isArray.ShouldBeTrue();
|
||||
dim.ShouldBe(10u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveArrayShape_flattens_multi_dim_to_product()
|
||||
{
|
||||
var (isArray, dim) = TwinCATDriver.ResolveArrayShape([3, 4]);
|
||||
isArray.ShouldBeTrue();
|
||||
dim.ShouldBe(12u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveArrayShape_rejects_non_positive_dim_as_scalar()
|
||||
{
|
||||
// Defensive — bad config flattens to scalar so the read path still runs without
|
||||
// dragging a Make-Array-Type call into an empty allocation.
|
||||
var (isArray, dim) = TwinCATDriver.ResolveArrayShape([3, 0, 2]);
|
||||
isArray.ShouldBeFalse();
|
||||
dim.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ---- Discovery surfaces IsArray + ArrayDim ----
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_emits_IsArray_for_array_tags()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions(Host)],
|
||||
Tags =
|
||||
[
|
||||
new TwinCATTagDefinition("Vec", Host, "MAIN.Vec", TwinCATDataType.DInt, ArrayDimensions: [10]),
|
||||
new TwinCATTagDefinition("Mat", Host, "MAIN.Mat", TwinCATDataType.Real, ArrayDimensions: [3, 4]),
|
||||
new TwinCATTagDefinition("Scalar", Host, "MAIN.S", TwinCATDataType.DInt),
|
||||
],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
var vec = builder.Variables.Single(v => v.BrowseName == "Vec").Info;
|
||||
vec.IsArray.ShouldBeTrue();
|
||||
vec.ArrayDim.ShouldBe(10u);
|
||||
|
||||
var mat = builder.Variables.Single(v => v.BrowseName == "Mat").Info;
|
||||
mat.IsArray.ShouldBeTrue();
|
||||
mat.ArrayDim.ShouldBe(12u); // 3 * 4 flattened
|
||||
|
||||
var scalar = builder.Variables.Single(v => v.BrowseName == "Scalar").Info;
|
||||
scalar.IsArray.ShouldBeFalse();
|
||||
scalar.ArrayDim.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ---- Whole-array read fans through to the client with ArrayDimensions ----
|
||||
|
||||
[Fact]
|
||||
public async Task Whole_array_read_passes_dimensions_to_client_and_returns_array()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new TwinCATTagDefinition("Vec", Host, "MAIN.Vec", TwinCATDataType.DInt, ArrayDimensions: [4]));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Vec"] = new[] { 1, 2, 3, 4 } } };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Vec"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(TwinCATStatusMapper.Good);
|
||||
snapshots.Single().Value.ShouldBe(new[] { 1, 2, 3, 4 });
|
||||
|
||||
var read = factory.Clients[0].ReadLog.Single();
|
||||
read.symbol.ShouldBe("MAIN.Vec");
|
||||
read.arrayDimensions.ShouldBe(new[] { 4 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Multi_dim_array_read_flattens_to_product_on_wire()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new TwinCATTagDefinition("Mat", Host, "MAIN.Mat", TwinCATDataType.Real,
|
||||
ArrayDimensions: [2, 3]));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var flat = new[] { 1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f };
|
||||
factory.Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Mat"] = flat } };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Mat"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(TwinCATStatusMapper.Good);
|
||||
snapshots.Single().Value.ShouldBe(flat);
|
||||
var read = factory.Clients[0].ReadLog.Single();
|
||||
read.arrayDimensions.ShouldBe(new[] { 2, 3 });
|
||||
}
|
||||
|
||||
// ---- Whole-array writes are out of scope (read-only PR) ----
|
||||
|
||||
[Fact]
|
||||
public async Task Whole_array_write_returns_BadNotSupported_via_AdsTwinCATClient()
|
||||
{
|
||||
// Use the production AdsTwinCATClient gate directly — driver-level writes against
|
||||
// the fake client would succeed because the fake doesn't model the real ADS surface.
|
||||
var client = new AdsTwinCATClient();
|
||||
var status = await client.WriteValueAsync(
|
||||
"MAIN.Vec", TwinCATDataType.DInt, bitIndex: null,
|
||||
arrayDimensions: [4], value: new[] { 1, 2, 3, 4 }, CancellationToken.None);
|
||||
status.ShouldBe(TwinCATStatusMapper.BadNotSupported);
|
||||
client.Dispose();
|
||||
}
|
||||
|
||||
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