Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs
T
Joseph Doherty af19db4ed1 feat(twincat): opt-in ADS sum-read and /hosts diagnostics bag; refresh TwinCAT backlog (#645, #612)
Sum-read (#310): batches a poll's reads per device into sum commands capped at sumReadMaxItems,
per-item ADS errors mapped per tag, falls back to individual reads when the target refuses the
command. Diagnostics (#314): ADS state, app name, online-change count, task cycle/exec/jitter via
IDriverDiagnostics. Re-implemented against the current raw-byte read path (stranded auto/driver-gaps
commits predate it). Sum-write not ported. Unit tests only; live gate pending a route.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 19:18:42 -04:00

215 lines
13 KiB
C#

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>
/// <param name="address">The target AMS address.</param>
/// <param name="timeout">The connection timeout.</param>
/// <param name="cancellationToken">Cancellation token for the connection attempt.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken cancellationToken);
/// <summary>True when the AMS router + target both accept commands.</summary>
bool IsConnected { get; }
/// <summary>
/// Raised when the client observes the ADS symbol-version-changed code
/// (<see cref="TwinCATStatusMapper.AdsSymbolVersionChanged"/> — <c>DeviceSymbolVersionInvalid</c>
/// 1809 / 0x0711) on any read / write / notification — the signal that a PLC program
/// re-download has invalidated every symbol + notification handle. The driver forwards
/// this to <see cref="Core.Abstractions.IRediscoverable.OnRediscoveryNeeded"/> so Core
/// rebuilds the address space subtree (docs/v2/driver-specs.md §6).
/// </summary>
event EventHandler? OnSymbolVersionChanged;
/// <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>
/// <param name="symbolPath">The ADS symbol path.</param>
/// <param name="type">The target data type.</param>
/// <param name="bitIndex">Optional bit index for bit extraction within a word.</param>
/// <param name="arrayCount">When non-null, read an array of this many <paramref name="type"/>
/// elements (a multi-dimensional PLC array flattened in memory order; a count shorter than the PLC
/// array reads its leading sub-range); the boxed value is the element-typed CLR array
/// (<c>int[]</c> / <c>float[]</c> / <c>bool[]</c> / <c>string[]</c> / …) decoded by
/// <see cref="TwinCATArrayCodec"/>. When null, read a scalar.</param>
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
/// <returns>The boxed value read (or <c>null</c>) together with the mapped OPC UA status.</returns>
Task<(object? value, uint status)> ReadValueAsync(
string symbolPath,
TwinCATDataType type,
int? bitIndex,
int? arrayCount,
CancellationToken cancellationToken);
/// <summary>
/// Reads several symbols at once — the ADS sum-read path (Gitea #645 / #310). The result is a parallel
/// list: element <c>i</c> is the outcome of <paramref name="reads"/>[<c>i</c>], each decoded and
/// status-mapped exactly as <see cref="ReadValueAsync"/> would (an item the sum command cannot carry —
/// a bit-indexed BOOL, a handle-addressed symbol — is read individually inside the call).
/// </summary>
/// <remarks>
/// The default implementation reads one item at a time, so a client without a sum path (or a test
/// fake) is correct if not faster. An implementation that issues a real sum command throws
/// <see cref="TwinCATSumReadRefusedException"/> when the TARGET refuses the command as a whole
/// (service / index group not supported); the caller then falls back to individual reads. A
/// comms-level failure propagates as the ordinary exception.
/// </remarks>
/// <param name="reads">The symbols to read, at most the caller's chunk size.</param>
/// <param name="cancellationToken">Cancellation token for the read.</param>
/// <returns>One <c>(value, status)</c> per item, in request order.</returns>
/// <exception cref="TwinCATSumReadRefusedException">The target refused the sum command itself.</exception>
async Task<IReadOnlyList<(object? value, uint status)>> ReadValuesAsync(
IReadOnlyList<TwinCATBulkReadItem> reads, CancellationToken cancellationToken)
{
var results = new (object? value, uint status)[reads.Count];
for (var i = 0; i < reads.Count; i++)
{
var r = reads[i];
results[i] = await ReadValueAsync(r.SymbolPath, r.Type, r.BitIndex, r.ArrayCount, cancellationToken)
.ConfigureAwait(false);
}
return results;
}
/// <summary>
/// The target's ADS device state (<c>Run</c>, <c>Stop</c>, <c>Config</c>, …) as text, or <c>null</c>
/// when it cannot be read. Feeds the driver's <c>/hosts</c> diagnostics (Gitea #645 / #314).
/// Default <c>null</c> — a client without a state read simply reports none.
/// </summary>
/// <param name="cancellationToken">Cancellation token for the read.</param>
/// <returns>The state name, or <c>null</c>.</returns>
Task<string?> ReadAdsStateAsync(CancellationToken cancellationToken) => Task.FromResult<string?>(null);
/// <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>
/// <param name="symbolPath">The ADS symbol path.</param>
/// <param name="type">The data type.</param>
/// <param name="bitIndex">Optional bit index for bit manipulation within a word.</param>
/// <param name="value">The value to write.</param>
/// <param name="cancellationToken">Cancellation token for the write operation.</param>
/// <returns>The mapped OPC UA status for the write (0 = Good).</returns>
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>
/// <param name="cancellationToken">Cancellation token for the probe operation.</param>
/// <returns><c>true</c> when the target is reachable; otherwise <c>false</c>.</returns>
Task<bool> ProbeAsync(CancellationToken cancellationToken);
/// <summary>
/// Register a cyclic / on-change ADS notification for a symbol. Returns a handle whose
/// <see cref="IDisposable.Dispose"/> tears the notification down. Callback fires on the
/// thread libplctag / AdsClient uses for notifications — consumers should marshal to
/// their own scheduler before doing work of any size.
/// </summary>
/// <param name="symbolPath">ADS symbol path (e.g. <c>MAIN.bStart</c>).</param>
/// <param name="type">Declared type; drives the native layout + callback value boxing.</param>
/// <param name="bitIndex">For BOOL-within-word tags — the bit to extract from the parent word.</param>
/// <param name="arrayCount">When non-null (and no <paramref name="bitIndex"/>), register the whole
/// array: <paramref name="arrayCount"/> elements of <paramref name="type"/> as raw bytes, decoded per
/// notification into the same element-typed CLR array <see cref="ReadValueAsync"/> returns
/// (<see cref="TwinCATArrayCodec"/>). Null = a scalar.</param>
/// <param name="cycleTime">Minimum interval between change notifications (native-floor depends on target).</param>
/// <param name="maxDelayMs">Maximum batching delay in milliseconds — TwinCAT may coalesce
/// notifications up to this delay before pushing them. <c>0</c> = no batching, push
/// immediately.</param>
/// <param name="onChange">Invoked with <c>(symbolPath, boxedValue)</c> per notification.</param>
/// <param name="cancellationToken">Cancels the initial registration; does not tear down an established notification.</param>
/// <returns>A handle whose disposal tears the registered notification down.</returns>
/// <exception cref="TwinCATNotificationRefusedException">The notification is refused for this one tag
/// — the declared <paramref name="type"/> / <paramref name="arrayCount"/> cannot decode the PLC
/// symbol (<see cref="TwinCATTypeMismatchException"/>: width, float/integer or string kind differ,
/// not an array, too long), or the array exceeds
/// <see cref="TwinCATTypeGuard.MaxNotificationArrayBytes"/>. The caller isolates that one tag to the
/// exception's status rather than registering a notification that would publish wrong bytes at
/// Good.</exception>
Task<ITwinCATNotificationHandle> AddNotificationAsync(
string symbolPath,
TwinCATDataType type,
int? bitIndex,
int? arrayCount,
TimeSpan cycleTime,
int maxDelayMs,
Action<string, object?> onChange,
CancellationToken cancellationToken);
/// <summary>
/// Walk the target's symbol table via the TwinCAT <c>SymbolLoaderFactory</c> (flat mode)
/// and expand all struct / UDT / function-block symbols to their atomic member leaves.
/// The implementation uses <see cref="TwinCATSymbolExpander.ExpandLeaves"/> to recursively
/// descend each struct/UDT/FB-instance symbol into its addressable atomic members,
/// yielding only atomic leaves (scalars or 1-D arrays with a non-null
/// <see cref="TwinCATDiscoveredSymbol.DataType"/>) and dropping struct/UDT/FB containers.
/// Callers receive fully-qualified <c>InstancePath</c>s (e.g. <c>MAIN.Motor1.Speed</c>)
/// and never see struct-typed entries — expansion is performed here, not in the caller.
/// </summary>
/// <param name="cancellationToken">Cancellation token for the enumeration operation.</param>
/// <returns>An asynchronous stream of the target's atomic-leaf symbols.</returns>
IAsyncEnumerable<TwinCATDiscoveredSymbol> BrowseSymbolsAsync(CancellationToken cancellationToken);
}
/// <summary>One element of an <see cref="ITwinCATClient.ReadValuesAsync"/> request — the same four facts
/// <see cref="ITwinCATClient.ReadValueAsync"/> takes.</summary>
/// <param name="SymbolPath">The ADS symbol path (a bit-indexed BOOL keeps its <c>.N</c> suffix, as for a single read).</param>
/// <param name="Type">The declared data type.</param>
/// <param name="BitIndex">The bit to extract for a BOOL-within-word tag; null otherwise.</param>
/// <param name="ArrayCount">The authored array length for an array tag; null for a scalar.</param>
public sealed record TwinCATBulkReadItem(
string SymbolPath,
TwinCATDataType Type,
int? BitIndex = null,
int? ArrayCount = null);
/// <summary>Opaque handle for a registered ADS notification. <see cref="IDisposable.Dispose"/> tears it down.</summary>
public interface ITwinCATNotificationHandle : IDisposable { }
/// <summary>
/// One symbol yielded by <see cref="ITwinCATClient.BrowseSymbolsAsync"/> — full instance
/// path + detected <see cref="TwinCATDataType"/> + read-only flag.
/// </summary>
/// <param name="InstancePath">Full dotted symbol path (e.g. <c>MAIN.bStart</c>, <c>GVL.Counter</c>).</param>
/// <param name="DataType">Mapped <see cref="TwinCATDataType"/> of the (element) type; <c>null</c>
/// when the symbol's type doesn't map onto our supported atomic surface (UDTs, pointers,
/// function blocks). For an array symbol this is the ELEMENT type, with <paramref name="ArrayLength"/>
/// carrying the dimension.</param>
/// <param name="ReadOnly"><c>true</c> when the symbol's AccessRights flag forbids writes.</param>
/// <param name="ArrayLength">When non-null, the symbol is a 1-D array of this many
/// <paramref name="DataType"/> elements. <c>null</c> = scalar. Multi-dimensional arrays are
/// reported as <c>null</c> (treated as scalar/unsupported) — only 1-D is in scope for Phase 4c.</param>
public sealed record TwinCATDiscoveredSymbol(
string InstancePath,
TwinCATDataType? DataType,
bool ReadOnly,
int? ArrayLength = null);
/// <summary>Factory for <see cref="ITwinCATClient"/>s. One client per device.</summary>
public interface ITwinCATClientFactory
{
/// <summary>Creates a new TwinCAT client instance.</summary>
/// <returns>A new, not-yet-connected <see cref="ITwinCATClient"/>.</returns>
ITwinCATClient Create();
}