Files
lmxopcua/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
Joseph Doherty 28e3470300 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>
2026-04-19 18:33:03 -04:00

220 lines
8.9 KiB
C#

using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// <summary>
/// TwinCAT ADS driver — talks to Beckhoff PLC runtimes (TC2 + TC3) via AMS / ADS. PR 1 ships
/// the <see cref="IDriver"/> skeleton; read / write / discover / subscribe / probe / host-
/// resolver land in PRs 2 and 3.
/// </summary>
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,
ITwinCATClientFactory? clientFactory = null)
{
ArgumentNullException.ThrowIfNull(options);
_options = options;
_driverInstanceId = driverInstanceId;
_clientFactory = clientFactory ?? new AdsTwinCATClientFactory();
}
public string DriverInstanceId => _driverInstanceId;
public string DriverType => "TwinCAT";
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
_health = new DriverHealth(DriverState.Initializing, null, null);
try
{
foreach (var device in _options.Devices)
{
var addr = TwinCATAmsAddress.TryParse(device.HostAddress)
?? throw new InvalidOperationException(
$"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)
{
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
throw;
}
return Task.CompletedTask;
}
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
}
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;
}
public DriverHealth GetHealth() => _health;
public long GetMemoryFootprint() => 0;
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
internal int DeviceCount => _devices.Count;
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);
internal sealed class DeviceState(TwinCATAmsAddress parsedAddress, TwinCATDeviceOptions options)
{
public TwinCATAmsAddress ParsedAddress { get; } = parsedAddress;
public TwinCATDeviceOptions Options { get; } = options;
public ITwinCATClient? Client { get; set; }
public void DisposeClient()
{
Client?.Dispose();
Client = null;
}
}
}