TwinCAT PR 2 — IReadable + IWritable. ITwinCATClient + ITwinCATClientFactory abstraction — one client per AMS target, reused across reads/writes/probes. Shape differs from AbCip/AbLegacy where libplctag handles are per-tag — TwinCAT's AdsClient is a single connection with symbolic reads/writes issued against it, so the abstraction is coarser. AdsTwinCATClient is the default implementation wrapping Beckhoff.TwinCAT.Ads's AdsClient — ConnectAsync calls AdsClient.Connect(AmsNetId.Parse(netId), port) after setting Timeout in ms; ReadValueAsync dispatches TwinCATDataType to the CLR Type via MapToClrType (bool/sbyte/byte/short/ushort/int/uint/long/ulong/float/double/string/uint for time types) and calls AdsClient.ReadValueAsync(symbol, type, ct) which returns ResultAnyValue; unwraps .Value + .ErrorCode and maps non-NoError codes via TwinCATStatusMapper.MapAdsError. BOOL-within-word reads extract the bit after the underlying word read using ExtractBit over short/ushort/int/uint/long/ulong. WriteValueAsync converts the boxed value via ConvertForWrite (Convert.ToXxx per type) then calls AdsClient.WriteValueAsync returning ResultWrite; checks .ErrorCode for status mapping. BOOL-within-word writes throw NotSupportedException with a pointer to task #181 — same RMW gap as Modbus BitInRegister / AbCip BOOL-in-DINT / AbLegacy bit-within-N-file. ProbeAsync calls AdsClient.ReadStateAsync + checks AdsErrorCode.NoError. TwinCATDriver implements IReadable + IWritable — per-device ITwinCATClient cached in DeviceState.Client, lazy-connected on first read/write via EnsureConnectedAsync, connect-failure path disposes + clears the client so next call re-attempts cleanly. ReadAsync ordered-snapshot pattern matching AbCip/AbLegacy: unknown ref → BadNodeIdUnknown, unknown device → BadNodeIdUnknown, OperationCanceledException rethrow, any other exception → BadCommunicationError + Degraded health. WriteAsync similar — non-Writable tag → BadNotWritable upfront, NotSupportedException → BadNotSupported, FormatException/InvalidCastException (guard pattern) → BadTypeMismatch, OverflowException → BadOutOfRange, generic → BadCommunicationError. Symbol name resolution goes through TwinCATSymbolPath.TryParse(def.SymbolPath) with fallback to the raw def.SymbolPath if the path doesn't parse — the Beckhoff AdsClient handles the final validation at wire time. ShutdownAsync disposes each device's client. 14 new unit tests in TwinCATReadWriteTests using FakeTwinCATClient + FakeTwinCATClientFactory — unknown ref → BadNodeIdUnknown, successful DInt read with Good status + captured value + IsConnected=true after EnsureConnectedAsync, repeat reads reuse the connection (one Connect + multiple reads), ADS error code mapping via FakeTwinCATClient.ReadStatuses, read exception → BadCommunicationError + Degraded health, connect exception disposes the client, batched reads preserve order across DInt/Real/String types, non-Writable rejection, successful write logs symbol+type+value+bit for test inspection, write status-code mapping, write exception → BadCommunicationError, batch preserves order across success/non-writable/unknown, cancellation propagation, ShutdownAsync disposes the client. Total TwinCAT unit tests now 75/75 passing (+14 from PR 1's 61); full solution builds 0 errors; Modbus / AbCip / AbLegacy / other drivers untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
154
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs
Normal file
154
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs
Normal file
@@ -0,0 +1,154 @@
|
||||
using TwinCAT.Ads;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="ITwinCATClient"/> backed by Beckhoff's <see cref="AdsClient"/>.
|
||||
/// One instance per AMS target; reused across reads / writes / probes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Wire behavior depends on a reachable AMS router — on Windows the router comes
|
||||
/// from TwinCAT XAR; elsewhere from the <c>Beckhoff.TwinCAT.Ads.TcpRouter</c> package
|
||||
/// hosted by the server process. Neither is built-in here; deployment wires one in.</para>
|
||||
///
|
||||
/// <para>Error mapping — ADS error codes surface through <see cref="AdsErrorException"/>
|
||||
/// and get translated to OPC UA status codes via <see cref="TwinCATStatusMapper.MapAdsError"/>.</para>
|
||||
/// </remarks>
|
||||
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<uint> 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<bool> 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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Default <see cref="ITwinCATClientFactory"/> — one <see cref="AdsTwinCATClient"/> per call.</summary>
|
||||
internal sealed class AdsTwinCATClientFactory : ITwinCATClientFactory
|
||||
{
|
||||
public ITwinCATClient Create() => new AdsTwinCATClient();
|
||||
}
|
||||
55
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs
Normal file
55
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-layer abstraction over one connection to a TwinCAT AMS target. One instance per
|
||||
/// <see cref="TwinCATAmsAddress"/>; reused across reads / writes / probes for the device.
|
||||
/// Tests swap in a fake via <see cref="ITwinCATClientFactory"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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 <see cref="ConnectAsync"/>, many
|
||||
/// <see cref="ReadValueAsync"/> / <see cref="WriteValueAsync"/> calls.
|
||||
/// </remarks>
|
||||
public interface ITwinCATClient : IDisposable
|
||||
{
|
||||
/// <summary>Establish the AMS connection. Idempotent — subsequent calls are no-ops when already connected.</summary>
|
||||
Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>True when the AMS router + target both accept commands.</summary>
|
||||
bool IsConnected { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Read a symbolic value. Returns a boxed .NET value matching the requested
|
||||
/// <paramref name="type"/>, or <c>null</c> when the read produced no data; the
|
||||
/// <c>status</c> tuple member carries the mapped OPC UA status (0 = Good).
|
||||
/// </summary>
|
||||
Task<(object? value, uint status)> ReadValueAsync(
|
||||
string symbolPath,
|
||||
TwinCATDataType type,
|
||||
int? bitIndex,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Write a symbolic value. Returns the mapped OPC UA status for the operation
|
||||
/// (0 = Good, non-zero = error mapped via <see cref="TwinCATStatusMapper"/>).
|
||||
/// </summary>
|
||||
Task<uint> WriteValueAsync(
|
||||
string symbolPath,
|
||||
TwinCATDataType type,
|
||||
int? bitIndex,
|
||||
object? value,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Cheap health probe — returns <c>true</c> when the target's AMS state is reachable.
|
||||
/// Used by <see cref="Core.Abstractions.IHostConnectivityProbe"/>'s probe loop.
|
||||
/// </summary>
|
||||
Task<bool> ProbeAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Factory for <see cref="ITwinCATClient"/>s. One client per device.</summary>
|
||||
public interface ITwinCATClientFactory
|
||||
{
|
||||
ITwinCATClient Create();
|
||||
}
|
||||
@@ -7,18 +7,22 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
/// the <see cref="IDriver"/> skeleton; read / write / discover / subscribe / probe / host-
|
||||
/// resolver land in PRs 2 and 3.
|
||||
/// </summary>
|
||||
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<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, TwinCATTagDefinition> _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<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> 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<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
IReadOnlyList<WriteRequest> 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<ITwinCATClient> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user