diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs new file mode 100644 index 0000000..8336d20 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs @@ -0,0 +1,154 @@ +using TwinCAT.Ads; + +namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; + +/// +/// Default backed by Beckhoff's . +/// One instance per AMS target; reused across reads / writes / probes. +/// +/// +/// Wire behavior depends on a reachable AMS router — on Windows the router comes +/// from TwinCAT XAR; elsewhere from the Beckhoff.TwinCAT.Ads.TcpRouter package +/// hosted by the server process. Neither is built-in here; deployment wires one in. +/// +/// Error mapping — ADS error codes surface through +/// and get translated to OPC UA status codes via . +/// +internal sealed class AdsTwinCATClient : ITwinCATClient +{ + private readonly AdsClient _client = new(); + + public bool IsConnected => _client.IsConnected; + + public Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken cancellationToken) + { + if (_client.IsConnected) return Task.CompletedTask; + _client.Timeout = (int)Math.Max(1_000, timeout.TotalMilliseconds); + var netId = AmsNetId.Parse(address.NetId); + _client.Connect(netId, address.Port); + return Task.CompletedTask; + } + + public async Task<(object? value, uint status)> ReadValueAsync( + string symbolPath, + TwinCATDataType type, + int? bitIndex, + CancellationToken cancellationToken) + { + try + { + var clrType = MapToClrType(type); + var result = await _client.ReadValueAsync(symbolPath, clrType, cancellationToken) + .ConfigureAwait(false); + + if (result.ErrorCode != AdsErrorCode.NoError) + return (null, TwinCATStatusMapper.MapAdsError((uint)result.ErrorCode)); + + var value = result.Value; + if (bitIndex is int bit && type == TwinCATDataType.Bool && value is not bool) + value = ExtractBit(value, bit); + + return (value, TwinCATStatusMapper.Good); + } + catch (AdsErrorException ex) + { + return (null, TwinCATStatusMapper.MapAdsError((uint)ex.ErrorCode)); + } + } + + public async Task WriteValueAsync( + string symbolPath, + TwinCATDataType type, + int? bitIndex, + object? value, + CancellationToken cancellationToken) + { + if (bitIndex is int && type == TwinCATDataType.Bool) + throw new NotSupportedException( + "BOOL-within-word writes require read-modify-write; tracked in task #181."); + + try + { + var converted = ConvertForWrite(type, value); + var result = await _client.WriteValueAsync(symbolPath, converted, cancellationToken) + .ConfigureAwait(false); + return result.ErrorCode == AdsErrorCode.NoError + ? TwinCATStatusMapper.Good + : TwinCATStatusMapper.MapAdsError((uint)result.ErrorCode); + } + catch (AdsErrorException ex) + { + return TwinCATStatusMapper.MapAdsError((uint)ex.ErrorCode); + } + } + + public async Task ProbeAsync(CancellationToken cancellationToken) + { + try + { + var state = await _client.ReadStateAsync(cancellationToken).ConfigureAwait(false); + return state.ErrorCode == AdsErrorCode.NoError; + } + catch + { + return false; + } + } + + public void Dispose() => _client.Dispose(); + + private static Type MapToClrType(TwinCATDataType type) => type switch + { + TwinCATDataType.Bool => typeof(bool), + TwinCATDataType.SInt => typeof(sbyte), + TwinCATDataType.USInt => typeof(byte), + TwinCATDataType.Int => typeof(short), + TwinCATDataType.UInt => typeof(ushort), + TwinCATDataType.DInt => typeof(int), + TwinCATDataType.UDInt => typeof(uint), + TwinCATDataType.LInt => typeof(long), + TwinCATDataType.ULInt => typeof(ulong), + TwinCATDataType.Real => typeof(float), + TwinCATDataType.LReal => typeof(double), + TwinCATDataType.String or TwinCATDataType.WString => typeof(string), + TwinCATDataType.Time or TwinCATDataType.Date + or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => typeof(uint), + _ => typeof(int), + }; + + private static object ConvertForWrite(TwinCATDataType type, object? value) => type switch + { + TwinCATDataType.Bool => Convert.ToBoolean(value), + TwinCATDataType.SInt => Convert.ToSByte(value), + TwinCATDataType.USInt => Convert.ToByte(value), + TwinCATDataType.Int => Convert.ToInt16(value), + TwinCATDataType.UInt => Convert.ToUInt16(value), + TwinCATDataType.DInt => Convert.ToInt32(value), + TwinCATDataType.UDInt => Convert.ToUInt32(value), + TwinCATDataType.LInt => Convert.ToInt64(value), + TwinCATDataType.ULInt => Convert.ToUInt64(value), + TwinCATDataType.Real => Convert.ToSingle(value), + TwinCATDataType.LReal => Convert.ToDouble(value), + TwinCATDataType.String or TwinCATDataType.WString => Convert.ToString(value) ?? string.Empty, + TwinCATDataType.Time or TwinCATDataType.Date + or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => Convert.ToUInt32(value), + _ => throw new NotSupportedException($"TwinCATDataType {type} not writable."), + }; + + private static bool ExtractBit(object? rawWord, int bit) => rawWord switch + { + short s => (s & (1 << bit)) != 0, + ushort us => (us & (1 << bit)) != 0, + int i => (i & (1 << bit)) != 0, + uint ui => (ui & (1u << bit)) != 0, + long l => (l & (1L << bit)) != 0, + ulong ul => (ul & (1UL << bit)) != 0, + _ => false, + }; +} + +/// Default — one per call. +internal sealed class AdsTwinCATClientFactory : ITwinCATClientFactory +{ + public ITwinCATClient Create() => new AdsTwinCATClient(); +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs new file mode 100644 index 0000000..cc6a086 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs @@ -0,0 +1,55 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; + +/// +/// Wire-layer abstraction over one connection to a TwinCAT AMS target. One instance per +/// ; reused across reads / writes / probes for the device. +/// Tests swap in a fake via . +/// +/// +/// Unlike libplctag-backed drivers where one native handle exists per tag, TwinCAT's +/// AdsClient is one connection per target with symbolic reads / writes issued against it. +/// The abstraction reflects that — single , many +/// / calls. +/// +public interface ITwinCATClient : IDisposable +{ + /// Establish the AMS connection. Idempotent — subsequent calls are no-ops when already connected. + Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken cancellationToken); + + /// True when the AMS router + target both accept commands. + bool IsConnected { get; } + + /// + /// 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). + /// + Task<(object? value, uint status)> ReadValueAsync( + string symbolPath, + TwinCATDataType type, + int? bitIndex, + CancellationToken cancellationToken); + + /// + /// Write a symbolic value. Returns the mapped OPC UA status for the operation + /// (0 = Good, non-zero = error mapped via ). + /// + Task WriteValueAsync( + string symbolPath, + TwinCATDataType type, + int? bitIndex, + object? value, + CancellationToken cancellationToken); + + /// + /// Cheap health probe — returns true when the target's AMS state is reachable. + /// Used by 's probe loop. + /// + Task ProbeAsync(CancellationToken cancellationToken); +} + +/// Factory for s. One client per device. +public interface ITwinCATClientFactory +{ + ITwinCATClient Create(); +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs index 23dd232..7018649 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs @@ -7,18 +7,22 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; /// the skeleton; read / write / discover / subscribe / probe / host- /// resolver land in PRs 2 and 3. /// -public sealed class TwinCATDriver : IDriver, IDisposable, IAsyncDisposable +public sealed class TwinCATDriver : IDriver, IReadable, IWritable, IDisposable, IAsyncDisposable { private readonly TwinCATDriverOptions _options; private readonly string _driverInstanceId; + private readonly ITwinCATClientFactory _clientFactory; private readonly Dictionary _devices = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _tagsByName = new(StringComparer.OrdinalIgnoreCase); private DriverHealth _health = new(DriverState.Unknown, null, null); - public TwinCATDriver(TwinCATDriverOptions options, string driverInstanceId) + public TwinCATDriver(TwinCATDriverOptions options, string driverInstanceId, + ITwinCATClientFactory? clientFactory = null) { ArgumentNullException.ThrowIfNull(options); _options = options; _driverInstanceId = driverInstanceId; + _clientFactory = clientFactory ?? new AdsTwinCATClientFactory(); } public string DriverInstanceId => _driverInstanceId; @@ -36,6 +40,7 @@ public sealed class TwinCATDriver : IDriver, IDisposable, IAsyncDisposable $"TwinCAT device has invalid HostAddress '{device.HostAddress}' — expected 'ads://{{netId}}:{{port}}'."); _devices[device.HostAddress] = new DeviceState(addr, device); } + foreach (var tag in _options.Tags) _tagsByName[tag.Name] = tag; _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null); } catch (Exception ex) @@ -54,7 +59,9 @@ public sealed class TwinCATDriver : IDriver, IDisposable, IAsyncDisposable public Task ShutdownAsync(CancellationToken cancellationToken) { + foreach (var state in _devices.Values) state.DisposeClient(); _devices.Clear(); + _tagsByName.Clear(); _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); return Task.CompletedTask; } @@ -67,6 +74,133 @@ public sealed class TwinCATDriver : IDriver, IDisposable, IAsyncDisposable internal DeviceState? GetDeviceState(string hostAddress) => _devices.TryGetValue(hostAddress, out var s) ? s : null; + // ---- IReadable ---- + + public async Task> ReadAsync( + IReadOnlyList fullReferences, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(fullReferences); + var now = DateTime.UtcNow; + var results = new DataValueSnapshot[fullReferences.Count]; + + for (var i = 0; i < fullReferences.Count; i++) + { + var reference = fullReferences[i]; + if (!_tagsByName.TryGetValue(reference, out var def)) + { + results[i] = new DataValueSnapshot(null, TwinCATStatusMapper.BadNodeIdUnknown, null, now); + continue; + } + if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) + { + results[i] = new DataValueSnapshot(null, TwinCATStatusMapper.BadNodeIdUnknown, null, now); + continue; + } + + try + { + var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false); + 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); + + results[i] = new DataValueSnapshot(value, status, now, now); + if (status == TwinCATStatusMapper.Good) + _health = new DriverHealth(DriverState.Healthy, now, null); + else + _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, + $"ADS status {status:X8} reading {reference}"); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + results[i] = new DataValueSnapshot(null, TwinCATStatusMapper.BadCommunicationError, null, now); + _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); + } + } + + return results; + } + + // ---- IWritable ---- + + public async Task> WriteAsync( + IReadOnlyList writes, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(writes); + var results = new WriteResult[writes.Count]; + + for (var i = 0; i < writes.Count; i++) + { + var w = writes[i]; + if (!_tagsByName.TryGetValue(w.FullReference, out var def)) + { + results[i] = new WriteResult(TwinCATStatusMapper.BadNodeIdUnknown); + continue; + } + if (!def.Writable) + { + results[i] = new WriteResult(TwinCATStatusMapper.BadNotWritable); + continue; + } + if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) + { + results[i] = new WriteResult(TwinCATStatusMapper.BadNodeIdUnknown); + continue; + } + + try + { + var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false); + 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); + results[i] = new WriteResult(status); + } + catch (OperationCanceledException) { throw; } + catch (NotSupportedException nse) + { + results[i] = new WriteResult(TwinCATStatusMapper.BadNotSupported); + _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message); + } + catch (Exception ex) when (ex is FormatException or InvalidCastException) + { + results[i] = new WriteResult(TwinCATStatusMapper.BadTypeMismatch); + } + catch (OverflowException) + { + results[i] = new WriteResult(TwinCATStatusMapper.BadOutOfRange); + } + catch (Exception ex) + { + results[i] = new WriteResult(TwinCATStatusMapper.BadCommunicationError); + _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); + } + } + + return results; + } + + private async Task EnsureConnectedAsync(DeviceState device, CancellationToken ct) + { + if (device.Client is { IsConnected: true } c) return c; + device.Client ??= _clientFactory.Create(); + try + { + await device.Client.ConnectAsync(device.ParsedAddress, _options.Timeout, ct) + .ConfigureAwait(false); + } + catch + { + device.Client.Dispose(); + device.Client = null; + throw; + } + return device.Client; + } + public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult(); public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false); @@ -74,5 +208,12 @@ public sealed class TwinCATDriver : IDriver, IDisposable, IAsyncDisposable { public TwinCATAmsAddress ParsedAddress { get; } = parsedAddress; public TwinCATDeviceOptions Options { get; } = options; + public ITwinCATClient? Client { get; set; } + + public void DisposeClient() + { + Client?.Dispose(); + Client = null; + } } } diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs new file mode 100644 index 0000000..51598ef --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs @@ -0,0 +1,72 @@ +using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; + +namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests; + +internal class FakeTwinCATClient : ITwinCATClient +{ + public bool IsConnected { get; private set; } + public int ConnectCount { get; private set; } + public int DisposeCount { get; private set; } + public bool ThrowOnConnect { get; set; } + public bool ThrowOnRead { get; set; } + public bool ThrowOnWrite { get; set; } + public bool ThrowOnProbe { get; set; } + public Exception? Exception { get; set; } + public Dictionary Values { get; } = new(StringComparer.OrdinalIgnoreCase); + 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 bool ProbeResult { get; set; } = true; + + public virtual Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken ct) + { + ConnectCount++; + if (ThrowOnConnect) throw Exception ?? new InvalidOperationException(); + IsConnected = true; + return Task.CompletedTask; + } + + public virtual Task<(object? value, uint status)> ReadValueAsync( + string symbolPath, TwinCATDataType type, int? bitIndex, CancellationToken ct) + { + if (ThrowOnRead) throw Exception ?? new InvalidOperationException(); + 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) + { + if (ThrowOnWrite) throw Exception ?? new InvalidOperationException(); + WriteLog.Add((symbolPath, type, bitIndex, value)); + Values[symbolPath] = value; + var status = WriteStatuses.TryGetValue(symbolPath, out var s) ? s : TwinCATStatusMapper.Good; + return Task.FromResult(status); + } + + public virtual Task ProbeAsync(CancellationToken ct) + { + if (ThrowOnProbe) return Task.FromResult(false); + return Task.FromResult(ProbeResult); + } + + public virtual void Dispose() + { + DisposeCount++; + IsConnected = false; + } +} + +internal sealed class FakeTwinCATClientFactory : ITwinCATClientFactory +{ + public List Clients { get; } = new(); + public Func? Customise { get; set; } + + public ITwinCATClient Create() + { + var client = Customise?.Invoke() ?? new FakeTwinCATClient(); + Clients.Add(client); + return client; + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReadWriteTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReadWriteTests.cs new file mode 100644 index 0000000..eb1587f --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReadWriteTests.cs @@ -0,0 +1,255 @@ +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 TwinCATReadWriteTests +{ + private static (TwinCATDriver drv, FakeTwinCATClientFactory factory) NewDriver(params TwinCATTagDefinition[] tags) + { + var factory = new FakeTwinCATClientFactory(); + var drv = new TwinCATDriver(new TwinCATDriverOptions + { + Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")], + Tags = tags, + Probe = new TwinCATProbeOptions { Enabled = false }, + }, "drv-1", factory); + return (drv, factory); + } + + // ---- Read ---- + + [Fact] + public async Task Unknown_reference_maps_to_BadNodeIdUnknown() + { + var (drv, _) = NewDriver(); + await drv.InitializeAsync("{}", CancellationToken.None); + + var snapshots = await drv.ReadAsync(["missing"], CancellationToken.None); + snapshots.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown); + } + + [Fact] + public async Task Successful_DInt_read_returns_Good_value() + { + var (drv, factory) = NewDriver( + new TwinCATTagDefinition("Speed", "ads://5.23.91.23.1.1:851", "MAIN.Speed", TwinCATDataType.DInt)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Speed"] = 4200 } }; + + var snapshots = await drv.ReadAsync(["Speed"], CancellationToken.None); + + snapshots.Single().StatusCode.ShouldBe(TwinCATStatusMapper.Good); + snapshots.Single().Value.ShouldBe(4200); + factory.Clients[0].ConnectCount.ShouldBe(1); + factory.Clients[0].IsConnected.ShouldBeTrue(); + } + + [Fact] + public async Task Repeat_read_reuses_connection() + { + var (drv, factory) = NewDriver( + new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "GVL.X", TwinCATDataType.DInt)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = () => new FakeTwinCATClient { Values = { ["GVL.X"] = 1 } }; + + await drv.ReadAsync(["X"], CancellationToken.None); + await drv.ReadAsync(["X"], CancellationToken.None); + await drv.ReadAsync(["X"], CancellationToken.None); + + // One client, one connect — subsequent calls reuse the connected client. + factory.Clients.Count.ShouldBe(1); + factory.Clients[0].ConnectCount.ShouldBe(1); + } + + [Fact] + public async Task Read_with_ADS_error_maps_via_status_mapper() + { + var (drv, factory) = NewDriver( + new TwinCATTagDefinition("Ghost", "ads://5.23.91.23.1.1:851", "MAIN.Missing", TwinCATDataType.DInt)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = () => + { + var c = new FakeTwinCATClient(); + c.ReadStatuses["MAIN.Missing"] = TwinCATStatusMapper.BadNodeIdUnknown; + return c; + }; + + var snapshots = await drv.ReadAsync(["Ghost"], CancellationToken.None); + snapshots.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown); + } + + [Fact] + public async Task Read_exception_surfaces_BadCommunicationError() + { + var (drv, factory) = NewDriver( + new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = () => new FakeTwinCATClient { ThrowOnRead = true }; + + var snapshots = await drv.ReadAsync(["X"], CancellationToken.None); + snapshots.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError); + drv.GetHealth().State.ShouldBe(DriverState.Degraded); + } + + [Fact] + public async Task Connect_failure_surfaces_BadCommunicationError_and_disposes_client() + { + var (drv, factory) = NewDriver( + new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = () => new FakeTwinCATClient { ThrowOnConnect = true }; + + var snapshots = await drv.ReadAsync(["X"], CancellationToken.None); + snapshots.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError); + factory.Clients[0].DisposeCount.ShouldBe(1); + } + + [Fact] + public async Task Batched_reads_preserve_order() + { + var (drv, factory) = NewDriver( + new TwinCATTagDefinition("A", "ads://5.23.91.23.1.1:851", "MAIN.A", TwinCATDataType.DInt), + new TwinCATTagDefinition("B", "ads://5.23.91.23.1.1:851", "MAIN.B", TwinCATDataType.Real), + new TwinCATTagDefinition("C", "ads://5.23.91.23.1.1:851", "MAIN.C", TwinCATDataType.String)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = () => new FakeTwinCATClient + { + Values = + { + ["MAIN.A"] = 1, + ["MAIN.B"] = 3.14f, + ["MAIN.C"] = "hello", + }, + }; + + var snapshots = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None); + snapshots[0].Value.ShouldBe(1); + snapshots[1].Value.ShouldBe(3.14f); + snapshots[2].Value.ShouldBe("hello"); + } + + // ---- Write ---- + + [Fact] + public async Task Non_writable_tag_rejected_with_BadNotWritable() + { + var (drv, _) = NewDriver( + new TwinCATTagDefinition("RO", "ads://5.23.91.23.1.1:851", "MAIN.RO", TwinCATDataType.DInt, Writable: false)); + await drv.InitializeAsync("{}", CancellationToken.None); + + var results = await drv.WriteAsync( + [new WriteRequest("RO", 1)], CancellationToken.None); + results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNotWritable); + } + + [Fact] + public async Task Successful_write_logs_symbol_type_value() + { + var (drv, factory) = NewDriver( + new TwinCATTagDefinition("Speed", "ads://5.23.91.23.1.1:851", "MAIN.Speed", TwinCATDataType.DInt)); + await drv.InitializeAsync("{}", CancellationToken.None); + + var results = await drv.WriteAsync( + [new WriteRequest("Speed", 4200)], CancellationToken.None); + + results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.Good); + var write = factory.Clients[0].WriteLog.Single(); + write.symbol.ShouldBe("MAIN.Speed"); + write.type.ShouldBe(TwinCATDataType.DInt); + write.value.ShouldBe(4200); + } + + [Fact] + public async Task Write_with_ADS_error_surfaces_mapped_status() + { + var (drv, factory) = NewDriver( + new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = () => + { + var c = new FakeTwinCATClient(); + c.WriteStatuses["MAIN.X"] = TwinCATStatusMapper.BadNotWritable; + return c; + }; + + var results = await drv.WriteAsync( + [new WriteRequest("X", 1)], CancellationToken.None); + results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNotWritable); + } + + [Fact] + public async Task Write_exception_surfaces_BadCommunicationError() + { + var (drv, factory) = NewDriver( + new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = () => new FakeTwinCATClient { ThrowOnWrite = true }; + + var results = await drv.WriteAsync( + [new WriteRequest("X", 1)], CancellationToken.None); + results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError); + } + + [Fact] + public async Task Batch_write_preserves_order_across_outcomes() + { + var factory = new FakeTwinCATClientFactory(); + var drv = new TwinCATDriver(new TwinCATDriverOptions + { + Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")], + Tags = + [ + new TwinCATTagDefinition("A", "ads://5.23.91.23.1.1:851", "MAIN.A", TwinCATDataType.DInt), + new TwinCATTagDefinition("B", "ads://5.23.91.23.1.1:851", "MAIN.B", TwinCATDataType.DInt, Writable: false), + ], + Probe = new TwinCATProbeOptions { Enabled = false }, + }, "drv-1", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var results = await drv.WriteAsync( + [ + new WriteRequest("A", 1), + new WriteRequest("B", 2), + new WriteRequest("Unknown", 3), + ], CancellationToken.None); + + results.Count.ShouldBe(3); + results[0].StatusCode.ShouldBe(TwinCATStatusMapper.Good); + results[1].StatusCode.ShouldBe(TwinCATStatusMapper.BadNotWritable); + results[2].StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown); + } + + [Fact] + public async Task Cancellation_propagates() + { + var (drv, factory) = NewDriver( + new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = () => new FakeTwinCATClient + { + ThrowOnRead = true, + Exception = new OperationCanceledException(), + }; + + await Should.ThrowAsync( + () => drv.ReadAsync(["X"], CancellationToken.None)); + } + + [Fact] + public async Task ShutdownAsync_disposes_client() + { + var (drv, factory) = NewDriver( + new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = () => new FakeTwinCATClient { Values = { ["MAIN.X"] = 1 } }; + + await drv.ReadAsync(["X"], CancellationToken.None); + await drv.ShutdownAsync(CancellationToken.None); + + factory.Clients[0].DisposeCount.ShouldBe(1); + } +}