Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs
T
Joseph Doherty 64e3fbe035
v2-ci / build (push) Failing after 1m43s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
docs: backfill XML documentation across 756 files
Adds <summary>, <param>, <typeparam>, and <inheritdoc/> tags to public
members surfaced by commentchecker — resolves 5,847 of 5,869 issues
(99.6%) across three /fixdocs passes.
2026-05-28 08:10:17 -04:00

478 lines
21 KiB
C#

using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using System.Threading.Channels;
using TwinCAT;
using TwinCAT.Ads;
using TwinCAT.Ads.TypeSystem;
using TwinCAT.TypeSystem;
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
{
// Bounded so a slow downstream consumer cannot back the AMS router thread up — the
// router thread enqueues and returns immediately (Driver.TwinCAT-008). 50k matches the
// Galaxy EventPump default; ~500 notifications/connection is the ADS ceiling so this is
// generous headroom against bursty change storms.
private const int NotificationQueueCapacity = 50_000;
private readonly AdsClient _client = new();
private readonly ConcurrentDictionary<uint, NotificationRegistration> _notifications = new();
// Marshals native ADS notifications off the AMS router thread onto a dedicated managed
// task. The router-thread callback (OnAdsNotificationEx) only enqueues; DispatchLoopAsync
// drains and invokes the per-tag OnChange delegates. Blocking the router thread would
// stall every ADS notification across the whole process (docs/v2/driver-specs.md §6).
private readonly Channel<PendingNotification> _notificationQueue =
Channel.CreateBounded<PendingNotification>(new BoundedChannelOptions(NotificationQueueCapacity)
{
FullMode = BoundedChannelFullMode.DropWrite,
SingleReader = true,
SingleWriter = false,
});
private readonly CancellationTokenSource _dispatchCts = new();
private readonly Task _dispatchLoop;
private int _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="AdsTwinCATClient"/> class.
/// </summary>
public AdsTwinCATClient()
{
_client.AdsNotificationEx += OnAdsNotificationEx;
_dispatchLoop = Task.Run(() => DispatchLoopAsync(_dispatchCts.Token));
}
private readonly record struct PendingNotification(NotificationRegistration Registration, object? Value);
/// <summary>
/// Gets a value indicating whether the client is connected.
/// </summary>
public bool IsConnected => _client.IsConnected;
/// <summary>
/// Occurs when the symbol version changes on the connected ADS target.
/// </summary>
public event EventHandler? OnSymbolVersionChanged;
/// <summary>Raise <see cref="OnSymbolVersionChanged"/> when <paramref name="adsError"/> is <c>DeviceSymbolVersionInvalid</c> (1809 / 0x0711).</summary>
private uint MapAndSignal(uint adsError)
{
if (TwinCATStatusMapper.IsSymbolVersionChanged(adsError))
OnSymbolVersionChanged?.Invoke(this, EventArgs.Empty);
return TwinCATStatusMapper.MapAdsError(adsError);
}
/// <summary>
/// Connects to the specified ADS target asynchronously.
/// </summary>
/// <param name="address">The AMS address to connect to.</param>
/// <param name="timeout">The connection timeout.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task representing the asynchronous connection operation.</returns>
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;
}
/// <summary>
/// Reads a value from the specified symbol path asynchronously.
/// </summary>
/// <param name="symbolPath">The ADS symbol path to read from.</param>
/// <param name="type">The TwinCAT data type.</param>
/// <param name="bitIndex">Optional bit index for BOOL values within larger containers.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A tuple containing the value and OPC UA status code.</returns>
public async Task<(object? value, uint status)> ReadValueAsync(
string symbolPath,
TwinCATDataType type,
int? bitIndex,
CancellationToken cancellationToken)
{
try
{
// Bit-indexed BOOL — TwinCAT's symbol table doesn't expose "WordVar.N" as its
// own symbolic entry (ADS returns DeviceSymbolNotFound), so we read the parent
// container as its widest unsigned primitive and extract the bit locally. The
// .N suffix added by TwinCATSymbolPath.ToAdsSymbolName needs to come back off
// first. uint covers WORD / DWORD containers; BYTE-sized bit containers are
// rare in real code and promoting to uint is harmless for them.
if (bitIndex is int bit && type == TwinCATDataType.Bool)
{
var parent = StripBitSuffix(symbolPath);
var parentResult = await _client.ReadValueAsync(parent, typeof(uint), cancellationToken)
.ConfigureAwait(false);
if (parentResult.ErrorCode != AdsErrorCode.NoError)
return (null, MapAndSignal((uint)parentResult.ErrorCode));
return (ExtractBit(parentResult.Value, bit), TwinCATStatusMapper.Good);
}
var clrType = MapToClrType(type);
var result = await _client.ReadValueAsync(symbolPath, clrType, cancellationToken)
.ConfigureAwait(false);
if (result.ErrorCode != AdsErrorCode.NoError)
return (null, MapAndSignal((uint)result.ErrorCode));
return (result.Value, TwinCATStatusMapper.Good);
}
catch (AdsErrorException ex)
{
return (null, MapAndSignal((uint)ex.ErrorCode));
}
}
private static string StripBitSuffix(string symbolPath)
{
var lastDot = symbolPath.LastIndexOf('.');
if (lastDot < 0) return symbolPath;
return int.TryParse(symbolPath.AsSpan(lastDot + 1), out _)
? symbolPath[..lastDot]
: symbolPath;
}
/// <summary>
/// Writes a value to the specified symbol path asynchronously.
/// </summary>
/// <param name="symbolPath">The ADS symbol path to write to.</param>
/// <param name="type">The TwinCAT data type.</param>
/// <param name="bitIndex">Optional bit index for BOOL values (not supported for writes).</param>
/// <param name="value">The value to write.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The OPC UA status code of the write operation.</returns>
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
: MapAndSignal((uint)result.ErrorCode);
}
catch (AdsErrorException ex)
{
return MapAndSignal((uint)ex.ErrorCode);
}
}
/// <summary>
/// Probes the connection to verify the ADS target is reachable.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>True if the probe succeeds; otherwise false.</returns>
public async Task<bool> ProbeAsync(CancellationToken cancellationToken)
{
try
{
var state = await _client.ReadStateAsync(cancellationToken).ConfigureAwait(false);
return state.ErrorCode == AdsErrorCode.NoError;
}
catch
{
return false;
}
}
/// <summary>
/// Adds a notification (subscription) for changes to the specified symbol.
/// </summary>
/// <param name="symbolPath">The ADS symbol path to monitor.</param>
/// <param name="type">The TwinCAT data type.</param>
/// <param name="bitIndex">Optional bit index for BOOL values.</param>
/// <param name="cycleTime">The minimum cycle time between notifications.</param>
/// <param name="maxDelayMs">The maximum delay before delivering a batched notification.</param>
/// <param name="onChange">The callback to invoke when the value changes.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A handle to manage the notification subscription.</returns>
public async Task<ITwinCATNotificationHandle> AddNotificationAsync(
string symbolPath,
TwinCATDataType type,
int? bitIndex,
TimeSpan cycleTime,
int maxDelayMs,
Action<string, object?> onChange,
CancellationToken cancellationToken)
{
var clrType = MapToClrType(type);
// NotificationSettings takes cycle + max-delay in milliseconds (Beckhoff InfoSys
// tcadsnetref/7313319051 — "The unit is 1ms"). AdsTransMode.OnChange fires when
// the value differs; OnCycle fires every cycle. OnChange is the right default for
// OPC UA data-change semantics — the PLC already has the best view of "has this
// changed" so we let it decide. maxDelayMs > 0 lets TwinCAT batch notifications up
// to that delay before pushing them — exposed via TwinCATDriverOptions
// (Driver.TwinCAT-014).
var cycleMs = (int)Math.Max(1, cycleTime.TotalMilliseconds);
var settings = new NotificationSettings(AdsTransMode.OnChange, cycleMs, Math.Max(0, maxDelayMs));
// AddDeviceNotificationExAsync returns Task<ResultHandle>; AdsNotificationEx fires
// with the handle as part of the event args so we use the handle as the correlation
// key into _notifications.
var result = await _client.AddDeviceNotificationExAsync(
symbolPath, settings, userData: null, clrType, args: null, cancellationToken)
.ConfigureAwait(false);
if (result.ErrorCode != AdsErrorCode.NoError)
throw new InvalidOperationException(
$"AddDeviceNotificationExAsync failed with ADS error {result.ErrorCode} for {symbolPath}");
var reg = new NotificationRegistration(symbolPath, type, bitIndex, onChange, this, result.Handle);
_notifications[result.Handle] = reg;
return reg;
}
/// <summary>
/// Runs on the <see cref="AdsClient"/> AMS router thread. Does the cheap bit-extraction
/// decode then enqueues — no driver logic, no consumer callbacks — so a slow consumer
/// can never stall ADS notification delivery for the rest of the process
/// (Driver.TwinCAT-008). Drops the notification (DropWrite) if the queue is saturated.
/// </summary>
private void OnAdsNotificationEx(object? sender, AdsNotificationExEventArgs args)
{
if (!_notifications.TryGetValue(args.Handle, out var reg)) return;
var value = args.Value;
if (reg.BitIndex is int bit && reg.Type == TwinCATDataType.Bool && value is not bool)
value = ExtractBit(value, bit);
_notificationQueue.Writer.TryWrite(new PendingNotification(reg, value));
}
private async Task DispatchLoopAsync(CancellationToken ct)
{
try
{
await foreach (var pending in _notificationQueue.Reader.ReadAllAsync(ct).ConfigureAwait(false))
{
try { pending.Registration.OnChange(pending.Registration.SymbolPath, pending.Value); }
catch { /* consumer-side errors stay on this managed task, not the ADS thread */ }
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Clean shutdown.
}
}
/// <summary>
/// Deletes a notification subscription by handle.
/// </summary>
/// <param name="handle">The notification handle to delete.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task representing the asynchronous delete operation.</returns>
internal async Task DeleteNotificationAsync(uint handle, CancellationToken cancellationToken)
{
_notifications.TryRemove(handle, out _);
try { await _client.DeleteDeviceNotificationAsync(handle, cancellationToken).ConfigureAwait(false); }
catch { /* best-effort tear-down; target may already be gone */ }
}
/// <summary>
/// Browses all available symbols on the connected ADS target asynchronously.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>An async enumerable of discovered symbols.</returns>
public async IAsyncEnumerable<TwinCATDiscoveredSymbol> BrowseSymbolsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
// SymbolLoaderFactory downloads the symbol-info blob once then iterates locally — the
// async surface on this interface is for our callers, not for the underlying call which
// is effectively sync on top of the already-open AdsClient.
var settings = new SymbolLoaderSettings(SymbolsLoadMode.Flat);
var loader = SymbolLoaderFactory.Create(_client, settings);
await Task.Yield(); // honors the async surface; pragmatic given the loader itself is sync
foreach (ISymbol symbol in loader.Symbols)
{
// ThrowIfCancellationRequested — not yield break — so a cancelled browse propagates
// as OperationCanceledException rather than a silent clean completion. DiscoverAsync
// has an explicit catch(OperationCanceledException){ throw; } to surface this
// distinctly from a genuine browse failure; a yield break would let a partial
// symbol set appear as a fully successful discovery (Driver.TwinCAT-010).
cancellationToken.ThrowIfCancellationRequested();
var mapped = MapSymbolTypeName(symbol.DataType?.Name);
var readOnly = !IsSymbolWritable(symbol);
yield return new TwinCATDiscoveredSymbol(symbol.InstancePath, mapped, readOnly);
}
}
private static TwinCATDataType? MapSymbolTypeName(string? typeName)
{
if (typeName is null) return null;
// SymbolLoader emits STRING(80) / WSTRING(80) with the declared bound baked into
// the type name — strip the "(...)" suffix so sized strings map onto the bare
// String/WString atom the driver speaks.
var paren = typeName.IndexOf('(');
var bare = paren > 0 ? typeName[..paren] : typeName;
return bare switch
{
"BOOL" or "BIT" => TwinCATDataType.Bool,
"SINT" or "BYTE" => TwinCATDataType.SInt,
"USINT" => TwinCATDataType.USInt,
"INT" or "WORD" => TwinCATDataType.Int,
"UINT" => TwinCATDataType.UInt,
"DINT" or "DWORD" => TwinCATDataType.DInt,
"UDINT" => TwinCATDataType.UDInt,
"LINT" or "LWORD" => TwinCATDataType.LInt,
"ULINT" => TwinCATDataType.ULInt,
"REAL" => TwinCATDataType.Real,
"LREAL" => TwinCATDataType.LReal,
"STRING" => TwinCATDataType.String,
"WSTRING" => TwinCATDataType.WString,
"TIME" => TwinCATDataType.Time,
"DATE" => TwinCATDataType.Date,
"DT" or "DATE_AND_TIME" => TwinCATDataType.DateTime,
"TOD" or "TIME_OF_DAY" => TwinCATDataType.TimeOfDay,
_ => null, // UDTs / FB instances / arrays / pointers — out of atomic scope
};
}
private static bool IsSymbolWritable(ISymbol symbol)
{
// SymbolAccessRights is a flags enum — the Write bit indicates a writable symbol.
// When the symbol implementation doesn't surface it, assume writable + let the PLC
// return AccessDenied at write time.
if (symbol is Symbol s) return (s.AccessRights & SymbolAccessRights.Write) != 0;
return true;
}
/// <summary>
/// Disposes the client and releases all resources.
/// </summary>
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
_client.AdsNotificationEx -= OnAdsNotificationEx;
_notifications.Clear();
// Stop the dispatch loop: complete the queue so the reader drains + exits, then
// cancel as a backstop. Best-effort wait so a wedged consumer can't hang teardown.
_notificationQueue.Writer.TryComplete();
_dispatchCts.Cancel();
try { _dispatchLoop.Wait(TimeSpan.FromSeconds(2)); } catch { /* shutdown */ }
_dispatchCts.Dispose();
_client.Dispose();
}
private sealed class NotificationRegistration(
string symbolPath,
TwinCATDataType type,
int? bitIndex,
Action<string, object?> onChange,
AdsTwinCATClient owner,
uint handle) : ITwinCATNotificationHandle
{
/// <summary>
/// Gets the symbol path being monitored.
/// </summary>
public string SymbolPath { get; } = symbolPath;
/// <summary>
/// Gets the TwinCAT data type of the symbol.
/// </summary>
public TwinCATDataType Type { get; } = type;
/// <summary>
/// Gets the optional bit index for BOOL values.
/// </summary>
public int? BitIndex { get; } = bitIndex;
/// <summary>
/// Gets the callback to invoke when the value changes.
/// </summary>
public Action<string, object?> OnChange { get; } = onChange;
/// <summary>
/// Disposes the notification subscription.
/// </summary>
public void Dispose()
{
// Fire-and-forget AMS call — caller has already committed to the tear-down.
_ = owner.DeleteNotificationAsync(handle, CancellationToken.None);
}
}
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
{
/// <summary>
/// Creates a new <see cref="AdsTwinCATClient"/> instance.
/// </summary>
/// <returns>A new <see cref="AdsTwinCATClient"/> instance.</returns>
public ITwinCATClient Create() => new AdsTwinCATClient();
}