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();
|
||||
}
|
||||
Reference in New Issue
Block a user