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
This commit is contained in:
Joseph Doherty
2026-10-08 19:18:42 -04:00
parent 8801631739
commit af19db4ed1
15 changed files with 1055 additions and 48 deletions
@@ -55,6 +55,45 @@ public interface ITwinCATClient : IDisposable
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"/>).
@@ -132,6 +171,18 @@ public interface ITwinCATClient : IDisposable
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 { }