From b69905232422edda53be64d40a177e20ca756cef Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 25 Apr 2026 17:36:15 -0400 Subject: [PATCH] =?UTF-8?q?Auto:=20twincat-1.4=20=E2=80=94=20whole-array?= =?UTF-8?q?=20reads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../AdsTwinCATClient.cs | 38 +++- .../ITwinCATClient.cs | 9 +- .../TwinCATDriver.cs | 31 ++- .../TwinCATDriverOptions.cs | 11 +- .../FakeTwinCATClient.cs | 6 +- .../TwinCATArrayReadTests.cs | 176 ++++++++++++++++++ 6 files changed, 260 insertions(+), 11 deletions(-) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATArrayReadTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs index b6a0e8d..5104cde 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs @@ -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); + + /// Apply per-element IEC TIME/DATE post-processing to a flat array result. + 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)(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 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); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs index 9ee748a..09ea4a9 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs @@ -22,22 +22,29 @@ public interface ITwinCATClient : IDisposable /// /// Read a symbolic value. Returns a boxed .NET value matching the requested /// , or null when the read produced no data; the - /// status tuple member carries the mapped OPC UA status (0 = Good). + /// status tuple member carries the mapped OPC UA status (0 = Good). When + /// is non-null + non-empty, the symbol is treated + /// as a whole-array read and the boxed value is a flat 1-D CLR + /// sized to product(arrayDimensions). /// Task<(object? value, uint status)> ReadValueAsync( string symbolPath, TwinCATDataType type, int? bitIndex, + int[]? arrayDimensions, CancellationToken cancellationToken); /// /// Write a symbolic value. Returns the mapped OPC UA status for the operation /// (0 = Good, non-zero = error mapped via ). + /// mirrors ; PR-1.4 + /// ships read-only whole-array support so writers may surface BadNotSupported. /// Task WriteValueAsync( string symbolPath, TwinCATDataType type, int? bitIndex, + int[]? arrayDimensions, object? value, CancellationToken cancellationToken); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs index c265b35..5fc8e4e 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs @@ -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; } + /// + /// Project a TwinCAT shape onto the + /// core 1-D surface. Multi-dim arrays flatten to the + /// product element count — the OPC UA address-space layer surfaces the rank via its own + /// ArrayDimensions metadata at variable build time. + /// + 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); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverOptions.cs index 173a3a0..dd4dd79 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverOptions.cs @@ -43,8 +43,12 @@ public sealed record TwinCATDeviceOptions( string? DeviceName = null); /// -/// One TwinCAT-backed OPC UA variable. is the full TwinCAT -/// symbolic name (e.g. MAIN.bStart, GVL.Counter, Motor1.Status.Running). +/// One TwinCAT-backed OPC UA variable. SymbolPath is the full TwinCAT symbolic name +/// (e.g. MAIN.bStart, GVL.Counter, Motor1.Status.Running). When +/// ArrayDimensions is non-null + non-empty the symbol is treated as a whole-array +/// read of product(dims) 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 ArrayDimensions metadata. /// 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 { diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs index 1594437..6cc180b 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs @@ -17,6 +17,7 @@ internal class FakeTwinCATClient : ITwinCATClient public Dictionary ReadStatuses { get; } = new(StringComparer.OrdinalIgnoreCase); public Dictionary 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 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)); diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATArrayReadTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATArrayReadTests.cs new file mode 100644 index 0000000..f5dce27 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATArrayReadTests.cs @@ -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) { } } + } +} -- 2.49.1