[twincat] TwinCAT — Whole-array reads #344
@@ -49,18 +49,27 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
|||||||
string symbolPath,
|
string symbolPath,
|
||||||
TwinCATDataType type,
|
TwinCATDataType type,
|
||||||
int? bitIndex,
|
int? bitIndex,
|
||||||
|
int[]? arrayDimensions,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var clrType = MapToClrType(type);
|
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);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
if (result.ErrorCode != AdsErrorCode.NoError)
|
if (result.ErrorCode != AdsErrorCode.NoError)
|
||||||
return (null, TwinCATStatusMapper.MapAdsError((uint)result.ErrorCode));
|
return (null, TwinCATStatusMapper.MapAdsError((uint)result.ErrorCode));
|
||||||
|
|
||||||
var value = result.Value;
|
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)
|
if (bitIndex is int bit && type == TwinCATDataType.Bool && value is not bool)
|
||||||
value = ExtractBit(value, bit);
|
value = ExtractBit(value, bit);
|
||||||
value = PostProcessIecTime(type, value);
|
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(
|
public async Task<uint> WriteValueAsync(
|
||||||
string symbolPath,
|
string symbolPath,
|
||||||
TwinCATDataType type,
|
TwinCATDataType type,
|
||||||
int? bitIndex,
|
int? bitIndex,
|
||||||
|
int[]? arrayDimensions,
|
||||||
object? value,
|
object? value,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (IsWholeArray(arrayDimensions))
|
||||||
|
return TwinCATStatusMapper.BadNotSupported; // PR-1.4 ships read-only whole-array
|
||||||
|
|
||||||
if (bitIndex is int bit && type == TwinCATDataType.Bool)
|
if (bitIndex is int bit && type == TwinCATDataType.Bool)
|
||||||
return await WriteBitInWordAsync(symbolPath, bit, value, cancellationToken)
|
return await WriteBitInWordAsync(symbolPath, bit, value, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|||||||
@@ -22,22 +22,29 @@ public interface ITwinCATClient : IDisposable
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Read a symbolic value. Returns a boxed .NET value matching the requested
|
/// 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
|
/// <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>
|
/// </summary>
|
||||||
Task<(object? value, uint status)> ReadValueAsync(
|
Task<(object? value, uint status)> ReadValueAsync(
|
||||||
string symbolPath,
|
string symbolPath,
|
||||||
TwinCATDataType type,
|
TwinCATDataType type,
|
||||||
int? bitIndex,
|
int? bitIndex,
|
||||||
|
int[]? arrayDimensions,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Write a symbolic value. Returns the mapped OPC UA status for the operation
|
/// Write a symbolic value. Returns the mapped OPC UA status for the operation
|
||||||
/// (0 = Good, non-zero = error mapped via <see cref="TwinCATStatusMapper"/>).
|
/// (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>
|
/// </summary>
|
||||||
Task<uint> WriteValueAsync(
|
Task<uint> WriteValueAsync(
|
||||||
string symbolPath,
|
string symbolPath,
|
||||||
TwinCATDataType type,
|
TwinCATDataType type,
|
||||||
int? bitIndex,
|
int? bitIndex,
|
||||||
|
int[]? arrayDimensions,
|
||||||
object? value,
|
object? value,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
|||||||
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
|
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
|
||||||
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
|
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
|
||||||
var (value, status) = await client.ReadValueAsync(
|
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);
|
results[i] = new DataValueSnapshot(value, status, now, now);
|
||||||
if (status == TwinCATStatusMapper.Good)
|
if (status == TwinCATStatusMapper.Good)
|
||||||
@@ -188,7 +188,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
|||||||
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
|
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
|
||||||
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
|
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
|
||||||
var status = await client.WriteValueAsync(
|
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);
|
results[i] = new WriteResult(status);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) { throw; }
|
catch (OperationCanceledException) { throw; }
|
||||||
@@ -231,11 +231,12 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
|||||||
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
|
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
|
||||||
foreach (var tag in tagsForDevice)
|
foreach (var tag in tagsForDevice)
|
||||||
{
|
{
|
||||||
|
var (isArray, arrayDim) = ResolveArrayShape(tag.ArrayDimensions);
|
||||||
deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
|
deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
|
||||||
FullName: tag.Name,
|
FullName: tag.Name,
|
||||||
DriverDataType: tag.DataType.ToDriverDataType(),
|
DriverDataType: tag.DataType.ToDriverDataType(),
|
||||||
IsArray: false,
|
IsArray: isArray,
|
||||||
ArrayDim: null,
|
ArrayDim: arrayDim,
|
||||||
SecurityClass: tag.Writable
|
SecurityClass: tag.Writable
|
||||||
? SecurityClassification.Operate
|
? SecurityClassification.Operate
|
||||||
: SecurityClassification.ViewOnly,
|
: SecurityClassification.ViewOnly,
|
||||||
@@ -310,6 +311,9 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
|||||||
{
|
{
|
||||||
if (!_tagsByName.TryGetValue(reference, out var def)) continue;
|
if (!_tagsByName.TryGetValue(reference, out var def)) continue;
|
||||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) 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 client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||||
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
|
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
|
||||||
@@ -428,6 +432,25 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
|||||||
return device.Client;
|
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 void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||||
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
||||||
|
|
||||||
|
|||||||
@@ -43,8 +43,12 @@ public sealed record TwinCATDeviceOptions(
|
|||||||
string? DeviceName = null);
|
string? DeviceName = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One TwinCAT-backed OPC UA variable. <paramref name="SymbolPath"/> is the full TwinCAT
|
/// One TwinCAT-backed OPC UA variable. <c>SymbolPath</c> is the full TwinCAT symbolic name
|
||||||
/// symbolic name (e.g. <c>MAIN.bStart</c>, <c>GVL.Counter</c>, <c>Motor1.Status.Running</c>).
|
/// (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>
|
/// </summary>
|
||||||
public sealed record TwinCATTagDefinition(
|
public sealed record TwinCATTagDefinition(
|
||||||
string Name,
|
string Name,
|
||||||
@@ -52,7 +56,8 @@ public sealed record TwinCATTagDefinition(
|
|||||||
string SymbolPath,
|
string SymbolPath,
|
||||||
TwinCATDataType DataType,
|
TwinCATDataType DataType,
|
||||||
bool Writable = true,
|
bool Writable = true,
|
||||||
bool WriteIdempotent = false);
|
bool WriteIdempotent = false,
|
||||||
|
int[]? ArrayDimensions = null);
|
||||||
|
|
||||||
public sealed class TwinCATProbeOptions
|
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> ReadStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
public Dictionary<string, uint> WriteStatuses { 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, 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 bool ProbeResult { get; set; } = true;
|
||||||
|
|
||||||
public virtual Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken ct)
|
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(
|
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();
|
if (ThrowOnRead) throw Exception ?? new InvalidOperationException();
|
||||||
|
ReadLog.Add((symbolPath, type, bitIndex, arrayDimensions));
|
||||||
var status = ReadStatuses.TryGetValue(symbolPath, out var s) ? s : TwinCATStatusMapper.Good;
|
var status = ReadStatuses.TryGetValue(symbolPath, out var s) ? s : TwinCATStatusMapper.Good;
|
||||||
var value = Values.TryGetValue(symbolPath, out var v) ? v : null;
|
var value = Values.TryGetValue(symbolPath, out var v) ? v : null;
|
||||||
return Task.FromResult((value, status));
|
return Task.FromResult((value, status));
|
||||||
}
|
}
|
||||||
|
|
||||||
public virtual Task<uint> WriteValueAsync(
|
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();
|
if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
|
||||||
WriteLog.Add((symbolPath, type, bitIndex, value));
|
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