[twincat] TwinCAT — Whole-array reads #344

Merged
dohertj2 merged 1 commits from auto/twincat/1.4 into auto/driver-gaps 2026-04-25 17:38:40 -04:00
6 changed files with 260 additions and 11 deletions

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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
{

View File

@@ -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));

View File

@@ -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) { } }
}
}