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);
+ }
+}