TwinCAT PR 1 — Scaffolding + Core (TwinCATDriver + AMS address + symbolic path). New Driver.TwinCAT project referencing Beckhoff.TwinCAT.Ads 7.0.172 (the official Beckhoff .NET client — 1.6M+ downloads, actively maintained by Beckhoff + community). Package compiles without a local AMS router; wire calls need a running router (TwinCAT XAR on dev Windows, or the standalone Beckhoff.TwinCAT.Ads.TcpRouter embedded package for headless/CI). Same Core.Abstractions-only project shape as Modbus / AbCip / AbLegacy. TwinCATAmsAddress parses ads://{netId}:{port} canonical form — NetId is 6 dot-separated octets (NOT an IP; AMS router translates), port defaults to 851 (TC3 PLC runtime 1). Validates octet range 0-255 and port 1-65535. Case-insensitive scheme. Default-port stripping in canonical form for roundtrip stability. Rejects wrong scheme, missing //, 5-or-7-octet NetId, out-of-range octets/ports, non-numeric fragments. TwinCATSymbolPath handles IEC 61131-3 symbolic names — single-segment (Counter), POU.variable (MAIN.bStart), GVL.variable (GVL.Counter), structured member access (Motor1.Status.Running), array subscripts (Data[5]), multi-dim arrays (Matrix[1,2]), bit-access (Flags.3, GVL.Status.7), combined scope/member/subscript/bit (MAIN.Motors[0].Status.5). Roundtrip-safe ToAdsSymbolName produces the exact string AdsClient.ReadValue consumes. Rejects leading/trailing dots, space in idents, digit-prefix idents, empty/negative/non-numeric subscripts, unbalanced brackets. Underscore-prefix idents accepted per IEC. TwinCATDataType — BOOL / SINT / USINT / INT / UINT / DINT / UDINT / LINT / ULINT / REAL / LREAL / STRING / WSTRING (UTF-16) / TIME / DATE / DateTime (DT) / TimeOfDay (TOD) / Structure. Wider than Logix's surface — IEC adds WSTRING + TIME/DATE/DT/TOD variants. ToDriverDataType widens unsigned + 64-bit to Int32 matching the Modbus/AbCip/AbLegacy Int64-gap convention. TwinCATStatusMapper — Good / BadInternalError / BadNodeIdUnknown / BadNotWritable / BadOutOfRange / BadNotSupported / BadDeviceFailure / BadCommunicationError / BadTimeout / BadTypeMismatch. MapAdsError covers the ADS error codes a driver actually encounters — 6/7 port unreachable, 1792 service not supported, 1793/1794 invalid index group/offset, 1798 symbol not found (→ BadNodeIdUnknown), 1807 invalid state, 1808 access denied (→ BadNotWritable), 1811/1812 size mismatch (→ BadOutOfRange), 1861 sync timeout, unknown → BadCommunicationError. TwinCATDriverOptions + TwinCATDeviceOptions + TwinCATTagDefinition + TwinCATProbeOptions — one instance supports N AMS targets, Tags cross-key by HostAddress, Probe defaults to 5s interval (unlike AbLegacy there's no default probe address — ADS probe reads AmsRouterState not a user tag, so probe address is implicit). TwinCATDriver IDriver skeleton — InitializeAsync parses each device HostAddress + fails fast on malformed strings → Faulted. 61 new unit tests across 3 files — TwinCATAmsAddressTests (6 valid shapes + 12 invalid shapes + 2 ToString canonicalisation + roundtrip stability), TwinCATSymbolPathTests (9 valid shapes + 12 invalid shapes + underscore prefix + 8-case roundtrip), TwinCATDriverTests (DriverType + multi-device init + malformed-address fault + shutdown + reinit + data-type mapping theory + ADS error-code theory). Total project count 30 src + 19 tests; 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:
Joseph Doherty
2026-04-19 18:26:29 -04:00
parent 7fdf4e5618
commit cd2c0bcadd
12 changed files with 744 additions and 0 deletions

View File

@@ -0,0 +1,78 @@
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, IDisposable, IAsyncDisposable
{
private readonly TwinCATDriverOptions _options;
private readonly string _driverInstanceId;
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
private DriverHealth _health = new(DriverState.Unknown, null, null);
public TwinCATDriver(TwinCATDriverOptions options, string driverInstanceId)
{
ArgumentNullException.ThrowIfNull(options);
_options = options;
_driverInstanceId = driverInstanceId;
}
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);
}
_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)
{
_devices.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;
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;
}
}