Merge b647-twincat: ADS sum-read (opt-in) + TwinCAT diagnostics on /hosts (#645 #310/#314, #612 docs)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 19:33:22 -04:00
17 changed files with 1463 additions and 49 deletions
@@ -61,6 +61,32 @@ public sealed class TwinCATDriverOptions
/// </summary>
public int NotificationMaxDelayMs { get; init; }
/// <summary>
/// When <c>true</c>, a poll's reads for one device are batched into ADS sum-read commands
/// (IndexGroup <c>0xF080</c>) of up to <see cref="SumReadMaxItems"/> symbols each instead of one
/// round-trip per tag (Gitea #645 / #310). Default <c>false</c> (opt-in): a TwinCAT 2 CE runtime's
/// sum-command support is unverified. A target that refuses the sum command is detected at runtime
/// and that device falls back to individual reads for the rest of the driver's life.
/// </summary>
public bool UseSumRead { get; init; }
/// <summary>
/// Maximum symbols per ADS sum-read command; larger polls are split into chunks of this size.
/// Bounds the request/response size against the target's ADS buffer. Values below 1 are treated as 1.
/// Values above 500 (the ADS sub-command ceiling) are treated as 500. Only consulted when
/// <see cref="UseSumRead"/> is <c>true</c>.
/// </summary>
public int SumReadMaxItems { get; init; } = 200;
/// <summary>
/// When <c>true</c>, each healthy probe tick also reads up to four <c>TwinCAT_SystemInfoVarList</c>
/// symbols (app name, online-change count, task cycle and last-exec time) and the ADS state the probe
/// already fetched, and publishes them on the <c>/hosts</c> diagnostics bag (Gitea #645 / #314).
/// Default <c>false</c>: that is up to four extra read requests per device per probe interval against
/// the PLC, not yet live-gated. Pending the read-only live gate on a TC2 and a TC3 target.
/// </summary>
public bool DiagnosticsEnabled { get; init; }
/// <summary>
/// Timeout for the AdminUI Test Connect probe, in seconds. The AdminUI clamps to a
/// 60s server-side maximum; this default is what the form pre-fills for new instances.
@@ -3,6 +3,7 @@ using System.Runtime.CompilerServices;
using System.Threading.Channels;
using TwinCAT;
using TwinCAT.Ads;
using TwinCAT.Ads.SumCommand;
using TwinCAT.Ads.TypeSystem;
using TwinCAT.TypeSystem;
@@ -166,6 +167,184 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<(object? value, uint status)>> ReadValuesAsync(
IReadOnlyList<TwinCATBulkReadItem> reads, CancellationToken cancellationToken)
{
var results = new (object? value, uint status)[reads.Count];
var planned = new List<PlannedSumRead>(reads.Count);
Task<(object? value, uint status)> ReadSingly(int index) =>
ReadValueAsync(reads[index].SymbolPath, reads[index].Type, reads[index].BitIndex,
reads[index].ArrayCount, cancellationToken);
// Plan every item exactly as the single read would (symbol resolved from the Beckhoff symbol cache,
// declared type checked against it), but stop short of the wire read: eligible items become sum
// commands. Anything the sum command cannot carry exactly is read on its own right here.
for (var i = 0; i < reads.Count; i++)
{
var r = reads[i];
try
{
if (r.BitIndex is not null && r.Type == TwinCATDataType.Bool)
{
results[i] = await ReadSingly(i).ConfigureAwait(false);
continue;
}
var (shape, array, symbol, symbolStatus) = await DescribeSymbolAsync(r.SymbolPath, cancellationToken)
.ConfigureAwait(false);
if (symbolStatus != TwinCATStatusMapper.Good) { results[i] = (null, symbolStatus); continue; }
TwinCATArrayLayout layout;
if (r.ArrayCount is int count)
{
// Arrays are raw bytes on the single path too, with the same strict element rule.
if (TwinCATTypeGuard.CheckArray(r.Type, count, shape, array, out layout) is not null)
{
results[i] = (null, TwinCATStatusMapper.BadTypeMismatch);
continue;
}
}
else
{
if (TwinCATTypeGuard.CheckRead(r.Type, shape) is not null)
{
results[i] = (null, TwinCATStatusMapper.BadTypeMismatch);
continue;
}
// The single read value-converts a numeric pair (DInt over a PLC INT is fine there); raw
// bytes decoded as the declared type would not. Only an exact width + kind match rides the
// sum command — the rest keeps the Beckhoff conversion by reading singly.
if (!TwinCATSumReadPlanner.ScalarEligible(r.Type, shape))
{
results[i] = await ReadSingly(i).ConfigureAwait(false);
continue;
}
// A scalar is a one-element "array" at the type's own width (a STRING at the PLC's declared
// width), so scalar and array share one raw decode.
layout = new TwinCATArrayLayout(1, r.Type is TwinCATDataType.String or TwinCATDataType.WString
? shape.ByteSize
: TwinCATTypeGuard.DeclaredByteSize(r.Type));
}
// A handle-addressed symbol (0xF005) needs a fresh variable handle per read — not expressible
// as an index-group/offset sum entry. Read it individually.
if (symbol!.IndexGroup == SymbolValueByHandle)
{
results[i] = await ReadSingly(i).ConfigureAwait(false);
continue;
}
planned.Add(new PlannedSumRead(i, symbol.IndexGroup, symbol.IndexOffset, layout, r.ArrayCount is not null));
}
catch (AdsErrorException ex)
{
results[i] = (null, MapAndSignal((uint)ex.ErrorCode));
}
}
if (planned.Count == 0) return results;
// Split to the byte budget; a lone item (or an oversized one) is just a read.
var groups = TwinCATSumReadPlanner.Partition(
[.. planned.Select(p => p.Layout.ByteLength)], TwinCATSumReadPlanner.MaxBatchBytes);
foreach (var group in groups)
{
var items = group.Select(g => planned[g]).ToList();
if (items.Count == 1)
{
results[items[0].Index] = await ReadSingly(items[0].Index).ConfigureAwait(false);
continue;
}
await ReadGroupAsync(items, reads, results, ReadSingly, cancellationToken).ConfigureAwait(false);
}
return results;
}
private async Task ReadGroupAsync(
List<PlannedSumRead> items, IReadOnlyList<TwinCATBulkReadItem> reads,
(object? value, uint status)[] results,
Func<int, Task<(object? value, uint status)>> readSingly, CancellationToken cancellationToken)
{
async Task FallBackAsync()
{
foreach (var p in items) results[p.Index] = await readSingly(p.Index).ConfigureAwait(false);
}
ResultSumReadRaw sum;
try
{
var entities = new List<IgIoSumReadEntity>(items.Count);
foreach (var p in items)
entities.Add(new IgIoSumReadEntity(p.IndexGroup, p.IndexOffset, p.Layout.ByteLength));
sum = await new SumRead(_client, entities, SumCommandErrorStrategy.Relaxed)
.ReadRawAsync(cancellationToken).ConfigureAwait(false);
}
catch (AdsErrorException ex) when (TwinCATSumReadPlanner.IsRefusal((uint)ex.ErrorCode))
{
throw new TwinCATSumReadRefusedException((uint)ex.ErrorCode, ex);
}
catch (ArgumentException)
{
// The library slices the response by the REQUESTED lengths and throws when a target answers with
// less. That is this target mangling this response, not a comms fault: read the group singly.
NoteSumFallback(0);
await FallBackAsync().ConfigureAwait(false);
return;
}
// On an overall failure the library RETURNS the code (SubErrors all set, ReadBlocks null).
if (sum.ErrorCode != AdsErrorCode.NoError)
{
var overall = (uint)sum.ErrorCode;
if (TwinCATSumReadPlanner.IsRefusal(overall))
throw new TwinCATSumReadRefusedException(overall);
if (TwinCATSumReadPlanner.IsRequestRejected(overall))
{
NoteSumFallback(overall);
await FallBackAsync().ConfigureAwait(false); // too big / bad size for THIS request: no latch
return;
}
var mapped = MapAndSignal(overall);
foreach (var p in items) results[p.Index] = (null, mapped);
return;
}
var utf8 = StringsAreUtf8();
for (var k = 0; k < items.Count; k++)
{
var p = items[k];
var subError = sum.SubErrors is { } errs && k < errs.Length ? errs[k] : AdsErrorCode.NoError;
ReadOnlyMemory<byte>? block = sum.ReadBlocks is { } blocks && k < blocks.Count ? blocks[k] : null;
results[p.Index] = TwinCATSumReadPlanner.DecodeItem(
reads[p.Index].Type, subError, block, p.Layout, p.IsArray, utf8, MapAndSignal);
}
}
private const uint SymbolValueByHandle = 0xF005;
private readonly record struct PlannedSumRead(int Index, uint IndexGroup, uint IndexOffset, TwinCATArrayLayout Layout, bool IsArray);
private string? _lastAdsState;
private long _sumFallbackCount;
private long _lastSumFallbackCode;
private void NoteSumFallback(uint code)
{
Interlocked.Exchange(ref _lastSumFallbackCode, code);
Interlocked.Increment(ref _sumFallbackCount);
}
/// <inheritdoc />
public long SumFallbackCount => Interlocked.Read(ref _sumFallbackCount);
/// <inheritdoc />
public uint LastSumFallbackCode => (uint)Interlocked.Read(ref _lastSumFallbackCode);
/// <inheritdoc />
public string? LastAdsState => Volatile.Read(ref _lastAdsState);
/// <summary>
/// Resolves the PLC symbol behind <paramref name="symbolPath"/> into the facts
/// <see cref="TwinCATTypeGuard"/> compares a declared type against. Uses the Beckhoff symbol
@@ -289,7 +468,9 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
try
{
var state = await _client.ReadStateAsync(cancellationToken).ConfigureAwait(false);
return state.ErrorCode == AdsErrorCode.NoError;
var ok = state.ErrorCode == AdsErrorCode.NoError;
Volatile.Write(ref _lastAdsState, ok ? state.State.AdsState.ToString() : null);
return ok;
}
catch
{
@@ -55,6 +55,53 @@ 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 ADS device state name (<c>Run</c>, <c>Stop</c>, <c>Config</c>, …) the most recent
/// <see cref="ProbeAsync"/> read, or <c>null</c> when unknown. Reuses the probe's own ReadState so
/// diagnostics add no request (Gitea #645 / #314). Default <c>null</c>.
/// </summary>
string? LastAdsState => null;
/// <summary>
/// How many sum-read chunks this client has read singly because the target rejected the sum request
/// without refusing the service (non-latching). The driver warns once per device when it grows, so the
/// live gate can see sum-read is not working on that target. Default 0.
/// </summary>
long SumFallbackCount => 0;
/// <summary>The overall ADS code of the most recent such fallback (0 = a malformed response).</summary>
uint LastSumFallbackCode => 0;
/// <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 +179,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 { }
@@ -0,0 +1,26 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// <summary>
/// One device's probe-sampled PLC facts (Gitea #645 / #314), surfaced through the driver's
/// <see cref="Core.Abstractions.IDriverDiagnostics"/> bag to the <c>/hosts</c> page. Every field is
/// best-effort: a runtime that does not export a system symbol leaves that field at its last known value
/// (null if never seen).
/// </summary>
/// <param name="AdsState">The ADS device state name (<c>Run</c>, <c>Stop</c>, <c>Config</c>, …).</param>
/// <param name="AppName">The running PLC application's name (<c>_AppInfo.AppName</c>).</param>
/// <param name="OnlineChangeCnt">How many online changes the application has taken (<c>_AppInfo.OnlineChangeCnt</c>).</param>
/// <param name="CycleTimeMs">The first PLC task's configured cycle time in milliseconds.</param>
/// <param name="LastExecTimeMs">How long the task's last cycle took to execute, in milliseconds.</param>
/// <param name="UpdatedUtc">When the snapshot was taken.</param>
internal sealed record TwinCATDeviceDiagnostics(
string? AdsState,
string? AppName,
uint? OnlineChangeCnt,
double? CycleTimeMs,
double? LastExecTimeMs,
DateTime UpdatedUtc)
{
/// <summary>Gets cycle time minus last execution time: how much of the cycle was spare. Negative means the
/// task overran its cycle. (Not jitter — that needs the variation across cycles.)</summary>
public double? HeadroomMs => CycleTimeMs is { } c && LastExecTimeMs is { } x ? c - x : null;
}
@@ -12,7 +12,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// resolver land in PRs 2 and 3.
/// </summary>
public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
IHostConnectivityProbe, IPerCallHostResolver, IRediscoverable, ISkippedTagReporter, IDisposable, IAsyncDisposable
IHostConnectivityProbe, IPerCallHostResolver, IRediscoverable, ISkippedTagReporter, IDriverDiagnostics,
IDisposable, IAsyncDisposable
{
/// <summary>Authored tags this driver could not build into its poll table (Gitea #541).</summary>
private readonly SkippedTagLedger _skippedTags = new();
@@ -271,6 +272,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
var now = DateTime.UtcNow;
var results = new DataValueSnapshot[fullReferences.Count];
// #645/#310: with UseSumRead the reads for one device are gathered and issued as ADS sum-reads
// after this pass; everything else (and a device that refused the sum command) reads one by one.
Dictionary<DeviceState, List<SumReadEntry>>? buckets = null;
for (var i = 0; i < fullReferences.Count; i++)
{
var reference = fullReferences[i];
@@ -285,47 +290,145 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
continue;
}
if (_options.UseSumRead && !device.SumReadUnsupported)
{
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
buckets ??= [];
if (!buckets.TryGetValue(device, out var bucket))
buckets[device] = bucket = [];
bucket.Add(new SumReadEntry(i, reference, def, symbolName, parsed?.BitIndex));
continue;
}
results[i] = await ReadOneAsync(reference, def, device, now, cancellationToken).ConfigureAwait(false);
}
if (buckets is not null)
foreach (var (device, bucket) in buckets)
await ReadBucketAsync(device, bucket, results, now, cancellationToken).ConfigureAwait(false);
return results;
}
private readonly record struct SumReadEntry(
int Index, string Reference, TwinCATTagDefinition Def, string SymbolName, int? BitIndex);
/// <summary>One tag read on its own — the pre-sum-read path, also the fallback for a refused sum command.</summary>
private async Task<DataValueSnapshot> ReadOneAsync(
string reference, TwinCATTagDefinition def, DeviceState device, DateTime now, CancellationToken cancellationToken)
{
try
{
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
// An array-typed tag (def.ArrayLength != null) reads the array as raw bytes and decodes
// it with TwinCATArrayCodec — the same decode a native array notification runs — into an
// element-typed CLR array. Scalar tags pass null (scalar path) — bit-indexed BOOLs are
// inherently scalar so the client ignores arrayCount when a bitIndex is present.
var (value, status) = await client.ReadValueAsync(
symbolName, def.DataType, parsed?.BitIndex, def.ArrayLength, cancellationToken)
.ConfigureAwait(false);
return CompleteRead(reference, def, symbolName, value, status, now);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
return new DataValueSnapshot(null, TwinCATStatusMapper.BadCommunicationError, null, now);
}
}
/// <summary>Maps one read outcome to a snapshot and applies the health / log side effects.</summary>
private DataValueSnapshot CompleteRead(
string reference, TwinCATTagDefinition def, string symbolName, object? value, uint status, DateTime now)
{
if (status == TwinCATStatusMapper.Good)
{
_health = new DriverHealth(DriverState.Healthy, now, null);
return new DataValueSnapshot(value, status, now, now);
}
if (status == TwinCATStatusMapper.BadTypeMismatch)
_logger.LogWarning(
"TwinCAT driver '{DriverInstanceId}' refused '{Reference}': declared {DataType} " +
"cannot decode the PLC symbol '{Symbol}' (string/numeric kind mismatch, array element type " +
"or length mismatch, or a reference/container)",
_driverInstanceId, reference, def.DataType, symbolName);
else
_logger.LogWarning(
"TwinCAT driver '{DriverInstanceId}' ADS read error 0x{Status:X8} for '{Reference}'",
_driverInstanceId, status, reference);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"ADS status {status:X8} reading {reference}");
return new DataValueSnapshot(value, status, now, now);
}
/// <summary>
/// Reads one device's gathered tags as ADS sum-reads of at most <c>SumReadMaxItems</c> symbols each.
/// A target that refuses the sum command flips <see cref="DeviceState.SumReadUnsupported"/> and the
/// rest of the bucket is read individually; any other failure marks only the affected chunk Bad.
/// </summary>
private async Task ReadBucketAsync(
DeviceState device, List<SumReadEntry> bucket, DataValueSnapshot[] results, DateTime now,
CancellationToken cancellationToken)
{
var chunkSize = Math.Clamp(_options.SumReadMaxItems, 1, TwinCATSumReadPlanner.MaxItemsCeiling);
for (var start = 0; start < bucket.Count; start += chunkSize)
{
var chunk = bucket.GetRange(start, Math.Min(chunkSize, bucket.Count - start));
if (device.SumReadUnsupported || chunk.Count == 1)
{
foreach (var e in chunk)
results[e.Index] = await ReadOneAsync(e.Reference, e.Def, device, now, cancellationToken).ConfigureAwait(false);
continue;
}
try
{
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
// An array-typed tag (def.ArrayLength != null) reads the array as raw bytes and decodes
// it with TwinCATArrayCodec — the same decode a native array notification runs — into an
// element-typed CLR array. Scalar tags pass null (scalar path) — bit-indexed BOOLs are
// inherently scalar so the client ignores arrayCount when a bitIndex is present.
var (value, status) = await client.ReadValueAsync(
symbolName, def.DataType, parsed?.BitIndex, def.ArrayLength, cancellationToken)
.ConfigureAwait(false);
var items = new TwinCATBulkReadItem[chunk.Count];
for (var k = 0; k < chunk.Count; k++)
items[k] = new TwinCATBulkReadItem(chunk[k].SymbolName, chunk[k].Def.DataType, chunk[k].BitIndex, chunk[k].Def.ArrayLength);
results[i] = new DataValueSnapshot(value, status, now, now);
if (status == TwinCATStatusMapper.Good)
_health = new DriverHealth(DriverState.Healthy, now, null);
else
var fallbacksBefore = client.SumFallbackCount;
var bulk = await client.ReadValuesAsync(items, cancellationToken).ConfigureAwait(false);
if (client.SumFallbackCount > fallbacksBefore && !device.SumFallbackWarned)
{
if (status == TwinCATStatusMapper.BadTypeMismatch)
_logger.LogWarning(
"TwinCAT driver '{DriverInstanceId}' refused '{Reference}': declared {DataType} " +
"cannot decode the PLC symbol '{Symbol}' (string/numeric kind mismatch, array element type " +
"or length mismatch, or a reference/container)",
_driverInstanceId, reference, def.DataType, symbolName);
else
_logger.LogWarning(
"TwinCAT driver '{DriverInstanceId}' ADS read error 0x{Status:X8} for '{Reference}'",
_driverInstanceId, status, reference);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"ADS status {status:X8} reading {reference}");
device.SumFallbackWarned = true;
_logger.LogWarning(
"TwinCAT driver '{DriverInstanceId}' device '{Device}': the target rejected an ADS sum-read " +
"request (ADS error {AdsError}; 0 = malformed response) and the chunk was read singly. Sum-read " +
"is not working on this target; this warning is logged once per device",
_driverInstanceId, device.Options.HostAddress, client.LastSumFallbackCode);
}
for (var k = 0; k < chunk.Count; k++)
{
var e = chunk[k];
var (value, status) = k < bulk.Count ? bulk[k] : (null, TwinCATStatusMapper.BadCommunicationError);
results[e.Index] = CompleteRead(e.Reference, e.Def, e.SymbolName, value, status, now);
}
}
catch (OperationCanceledException) { throw; }
catch (TwinCATSumReadRefusedException refused)
{
device.SumReadUnsupported = true;
_logger.LogWarning(
"TwinCAT driver '{DriverInstanceId}' device '{Device}' refused the ADS sum-read command " +
"(ADS error {AdsError}); falling back to individual reads for this device",
_driverInstanceId, device.Options.HostAddress, refused.AdsErrorCode);
foreach (var e in chunk)
results[e.Index] = await ReadOneAsync(e.Reference, e.Def, device, now, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
results[i] = new DataValueSnapshot(null, TwinCATStatusMapper.BadCommunicationError, null, now);
foreach (var e in chunk)
results[e.Index] = new DataValueSnapshot(null, TwinCATStatusMapper.BadCommunicationError, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
}
}
return results;
}
// ---- IWritable ----
@@ -774,6 +877,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
while (!ct.IsCancellationRequested)
{
var success = false;
ITwinCATClient? probed = null;
try
{
// Probe-initiated connects honor TwinCATProbeOptions.Timeout — distinct from
@@ -791,9 +895,12 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
if (!success)
await RecycleClientAsync(state, ct).ConfigureAwait(false);
else
{
// 05/STAB-16: on a healthy tick, re-register any notifications whose replay
// failed (a transient AddNotification blip) so they recover without a client swap.
await RetryDeadRegistrationsAsync(state, ct).ConfigureAwait(false);
probed = client;
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
catch
@@ -808,11 +915,139 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped);
// #645/#314: opt-in PLC state / task-cycle sampling for /hosts. After the host-state transition so a
// slow target cannot delay it; never throws (except cancellation).
if (_options.DiagnosticsEnabled && probed is not null)
{
try { await SampleDeviceDiagnosticsAsync(state, probed, ct).ConfigureAwait(false); }
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
}
try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { break; }
}
}
// ---- IDriverDiagnostics (#645 / #314) ----
// Well-known system symbols, exported by TC3 (and most TC2) PLC runtimes under TwinCAT_SystemInfoVarList.
// _TaskInfo[1] is the first PLC task; CycleTime / LastExecTime are UDINT counts of 100 ns.
internal const string SymOnlineChangeCnt = "TwinCAT_SystemInfoVarList._AppInfo.OnlineChangeCnt";
internal const string SymAppName = "TwinCAT_SystemInfoVarList._AppInfo.AppName";
internal const string SymCycleTime = "TwinCAT_SystemInfoVarList._TaskInfo[1].CycleTime";
internal const string SymLastExecTime = "TwinCAT_SystemInfoVarList._TaskInfo[1].LastExecTime";
/// <summary>Converts a TwinCAT 100 ns tick count to milliseconds.</summary>
internal static double TicksToMilliseconds(uint ticks100ns) => ticks100ns / 10_000.0;
/// <summary>
/// Most recent <see cref="TwinCATDeviceDiagnostics"/> for a device, or null before its first
/// successful probe sample (or for an unknown host address).
/// </summary>
/// <param name="hostAddress">The device's ADS host address.</param>
/// <returns>The snapshot, or null.</returns>
internal TwinCATDeviceDiagnostics? GetDeviceDiagnostics(string hostAddress) =>
_devices.TryGetValue(hostAddress, out var s) ? s.LastDiagnostics : null;
/// <summary>
/// Samples the ADS device state and the four system symbols once per healthy probe tick and stores the
/// result on <see cref="DeviceState.LastDiagnostics"/>. Every read is individually best-effort: a
/// runtime that does not export a symbol (older TwinCAT 2 builds) keeps the previous value, and a
/// symbol that answers "not found" is not asked again until the client is rebuilt. Never throws
/// (except cancellation) — diagnostics must not disturb the probe.
/// </summary>
internal async Task SampleDeviceDiagnosticsAsync(DeviceState state, ITwinCATClient client, CancellationToken ct)
{
try
{
var prev = state.LastDiagnostics;
// The probe's own ReadState (ProbeAsync) already fetched this; no second request.
var adsState = client.LastAdsState ?? prev?.AdsState;
var appName = await ReadDiagSymbolAsync(state, client, SymAppName, TwinCATDataType.String, ct).ConfigureAwait(false) as string
?? prev?.AppName;
var onlineChange = ToUInt(await ReadDiagSymbolAsync(state, client, SymOnlineChangeCnt, TwinCATDataType.UDInt, ct).ConfigureAwait(false))
?? prev?.OnlineChangeCnt;
var cycleTicks = ToUInt(await ReadDiagSymbolAsync(state, client, SymCycleTime, TwinCATDataType.UDInt, ct).ConfigureAwait(false));
var execTicks = ToUInt(await ReadDiagSymbolAsync(state, client, SymLastExecTime, TwinCATDataType.UDInt, ct).ConfigureAwait(false));
var cycleMs = cycleTicks is { } c ? TicksToMilliseconds(c) : prev?.CycleTimeMs;
var execMs = execTicks is { } x ? TicksToMilliseconds(x) : prev?.LastExecTimeMs;
state.LastDiagnostics = new TwinCATDeviceDiagnostics(adsState, appName, onlineChange, cycleMs, execMs, DateTime.UtcNow);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch { /* best-effort */ }
}
private static uint? ToUInt(object? v) => v switch
{
uint u => u,
null => null,
_ => Convert.ToUInt32(v, System.Globalization.CultureInfo.InvariantCulture),
};
private async Task<object?> ReadDiagSymbolAsync(
DeviceState state, ITwinCATClient client, string symbol, TwinCATDataType type, CancellationToken ct)
{
if (state.UnavailableDiagSymbols.ContainsKey(symbol)) return null;
try
{
var (value, status) = await client.ReadValueAsync(symbol, type, null, null, ct).ConfigureAwait(false);
if (status == TwinCATStatusMapper.Good) return value;
if (status == TwinCATStatusMapper.BadNodeIdUnknown)
state.UnavailableDiagSymbols[symbol] = 0;
return null;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch { return null; }
}
/// <summary>
/// The <c>/hosts</c> diagnostics bag (Gitea #640 channel): per device, ADS state, PLC app name, online
/// change count, task cycle time, last execution time and headroom (cycle − last exec). A single-device
/// driver uses flat <c>TwinCAT.*</c> keys; several devices prefix the device name (or host address).
/// A pure in-memory read — the probe loop does the sampling. The host bounds the bag
/// (<see cref="DriverDiagnostics.MaxEntries"/>), so a many-device driver keeps the first keys in
/// ordinal order.
/// </summary>
/// <returns>The key/value pairs; empty before the first sample.</returns>
IReadOnlyDictionary<string, string> IDriverDiagnostics.GetDiagnostics()
{
var bag = new Dictionary<string, string>(StringComparer.Ordinal);
var multi = _devices.Count > 1;
foreach (var state in _devices.Values)
{
if (state.LastDiagnostics is not { } d) continue;
var name = state.Options.DeviceName ?? state.Options.HostAddress;
if (multi)
{
// One compact line per device so the host's 16-entry cap shows up to 16 devices, not 2.
var parts = new List<string>(6);
if (d.AdsState is not null) parts.Add(d.AdsState);
if (!string.IsNullOrEmpty(d.AppName)) parts.Add(d.AppName);
if (d.OnlineChangeCnt is { } n) parts.Add("online-changes " + n.ToString(System.Globalization.CultureInfo.InvariantCulture));
if (d.CycleTimeMs is { } cy) parts.Add("cycle " + FormatMs(cy) + " ms");
if (d.LastExecTimeMs is { } ex) parts.Add("exec " + FormatMs(ex) + " ms");
if (d.HeadroomMs is { } h) parts.Add("headroom " + FormatMs(h) + " ms");
bag[$"TwinCAT.{name}"] = string.Join("; ", parts);
continue;
}
const string prefix = "TwinCAT.";
if (d.AdsState is not null) bag[prefix + "AdsState"] = d.AdsState;
if (!string.IsNullOrEmpty(d.AppName)) bag[prefix + "AppName"] = d.AppName;
if (d.OnlineChangeCnt is { } oc) bag[prefix + "OnlineChangeCnt"] = oc.ToString(System.Globalization.CultureInfo.InvariantCulture);
if (d.CycleTimeMs is { } cy2) bag[prefix + "CycleTimeMs"] = FormatMs(cy2);
if (d.LastExecTimeMs is { } ex2) bag[prefix + "LastExecTimeMs"] = FormatMs(ex2);
if (d.HeadroomMs is { } h2) bag[prefix + "HeadroomMs"] = FormatMs(h2);
}
return bag;
}
// 0.1 ms resolution: the bag is published on a heartbeat and its digest is in the publish fingerprint,
// so sub-0.1 ms wobble should not read as a change.
private static string FormatMs(double ms) =>
Math.Round(ms, 1, MidpointRounding.AwayFromZero).ToString("0.0", System.Globalization.CultureInfo.InvariantCulture);
private void TransitionDeviceState(DeviceState state, HostState newState)
{
HostState old;
@@ -976,6 +1211,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
throw;
}
device.Client = client;
device.UnavailableDiagSymbols.Clear(); // a rebuilt client may face a PLC that now exports them
device.Backoff.RecordSuccess(); // 05/STAB-8: recovery resets the window immediately
// Re-register every native notification onto the fresh client. Native ADS is push, so
@@ -1233,11 +1469,27 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
/// so the loop cannot touch a disposed client.</summary>
public Task? ProbeTask { get; set; }
/// <summary>Set when the target answered the ADS sum-read command with "service / index group not
/// supported" (#645); the device reads one tag at a time from then on. Cleared only by a fresh
/// <see cref="DeviceState"/> (driver re-initialise).</summary>
public volatile bool SumReadUnsupported;
/// <summary>True once the "sum request rejected, chunk read singly" warning was logged for this device.</summary>
public volatile bool SumFallbackWarned;
/// <summary>Latest probe-sampled PLC diagnostics (#645 / #314); null before the first sample.</summary>
public TwinCATDeviceDiagnostics? LastDiagnostics { get; set; }
/// <summary>Diagnostic system symbols the target answered "not found" for — not re-asked until the client is rebuilt.</summary>
public ConcurrentDictionary<string, byte> UnavailableDiagSymbols { get; } = new(StringComparer.Ordinal);
/// <summary>Disposes the active ADS client if any.</summary>
public void DisposeClient()
{
Client?.Dispose();
Client = null;
// A rebuilt client may face a PLC that now exports the symbols (e.g. after a program download).
UnavailableDiagSymbols.Clear();
}
/// <summary>Disposes the connection gate semaphore.</summary>
@@ -78,6 +78,9 @@ public static class TwinCATDriverFactoryExtensions
UseNativeNotifications = dto.UseNativeNotifications ?? true,
EnableControllerBrowse = dto.EnableControllerBrowse ?? false,
NotificationMaxDelayMs = dto.NotificationMaxDelayMs ?? 0,
UseSumRead = dto.UseSumRead ?? false,
SumReadMaxItems = dto.SumReadMaxItems ?? 200,
DiagnosticsEnabled = dto.DiagnosticsEnabled ?? false,
};
}
@@ -114,6 +117,15 @@ public static class TwinCATDriverFactoryExtensions
/// <summary>Gets or sets the maximum notification delay in milliseconds.</summary>
public int? NotificationMaxDelayMs { get; init; }
/// <summary>Gets or sets a value indicating whether a poll's reads are batched into ADS sum-reads.</summary>
public bool? UseSumRead { get; init; }
/// <summary>Gets or sets a value indicating whether probe-tick PLC diagnostics are sampled.</summary>
public bool? DiagnosticsEnabled { get; init; }
/// <summary>Gets or sets the maximum symbols per sum-read command.</summary>
public int? SumReadMaxItems { get; init; }
/// <summary>Gets or sets the list of configured devices.</summary>
public List<TwinCATDeviceDto>? Devices { get; init; }
@@ -0,0 +1,113 @@
using Beckhoff = TwinCAT.Ads;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// <summary>
/// The pure decisions behind an ADS sum-read (Gitea #645 / #310), split out of
/// <see cref="AdsTwinCATClient"/> so they are unit-testable without an ADS connection: which items may
/// ride a sum command, how a poll is split to fit a request budget, which overall ADS codes mean what,
/// and how one item's raw block becomes a value.
/// </summary>
internal static class TwinCATSumReadPlanner
{
/// <summary>Hard ceiling on items per sum command (ADS documents roughly 500 sub-commands).</summary>
public const int MaxItemsCeiling = 500;
/// <summary>Per-command byte budget: the sum of the items' read lengths plus <see cref="PerItemOverhead"/> each.
/// Keeps a poll that includes large arrays inside a modest router / CE buffer.</summary>
public const int MaxBatchBytes = 32 * 1024;
/// <summary>Bytes of per-item ADS error code carried in a sum response.</summary>
public const int PerItemOverhead = 4;
/// <summary>
/// True when a SCALAR may be read as raw bytes in a sum command. The sum path decodes the bytes as the
/// DECLARED type, so it needs the strict width + float/integer-kind rule (the notification rule); the
/// lenient <see cref="TwinCATTypeGuard.CheckRead"/> relies on the Beckhoff value conversion a raw read
/// does not do. An item failing this but passing CheckRead must be read individually.
/// </summary>
/// <param name="declared">The tag's declared type.</param>
/// <param name="plc">The PLC symbol's shape.</param>
/// <returns>True when the raw-bytes decode is exact.</returns>
public static bool ScalarEligible(TwinCATDataType declared, TwinCATPlcSymbolShape plc) =>
TwinCATTypeGuard.CheckNotification(declared, plc) is null;
/// <summary>The service or index group itself is unsupported: the device never does sum commands. Latches.</summary>
/// <param name="adsError">The overall ADS code.</param>
/// <returns>True for 1793 (service not supported) and 1794 (invalid index group).</returns>
public static bool IsRefusal(uint adsError) => adsError is 1793 or 1794;
/// <summary>A transport-level failure (router / port / timeout), not an answer from the sum service.</summary>
/// <param name="adsError">The overall ADS code.</param>
/// <returns>True when the code maps to a communication error or timeout.</returns>
public static bool IsCommsFailure(uint adsError) =>
TwinCATStatusMapper.MapAdsError(adsError) is var s
&& (s == TwinCATStatusMapper.BadCommunicationError || s == TwinCATStatusMapper.BadTimeout);
/// <summary>
/// The sum service answered with an overall error that is neither "unsupported" nor a comms failure
/// (general error, bad size / data / offset, ...): it rejected THIS request. Read the chunk singly for this
/// poll, do not latch.
/// </summary>
/// <param name="adsError">The overall ADS code.</param>
/// <returns>True for any such code.</returns>
public static bool IsRequestRejected(uint adsError) => !IsRefusal(adsError) && !IsCommsFailure(adsError);
/// <summary>
/// Splits items (in order) into groups whose summed <c>length + PerItemOverhead</c> stays within
/// <paramref name="budget"/>. An item that alone exceeds the budget gets its own group (the caller reads a
/// one-item group individually).
/// </summary>
/// <param name="byteLengths">Each item's read length.</param>
/// <param name="budget">The byte budget per command.</param>
/// <returns>Groups of positions into <paramref name="byteLengths"/>.</returns>
public static List<List<int>> Partition(IReadOnlyList<int> byteLengths, int budget)
{
var groups = new List<List<int>>();
List<int>? current = null;
var used = 0;
for (var i = 0; i < byteLengths.Count; i++)
{
var cost = byteLengths[i] + PerItemOverhead;
if (current is null || used + cost > budget)
{
current = [];
groups.Add(current);
used = 0;
}
current.Add(i);
used += cost;
}
return groups;
}
/// <summary>
/// Turns one item's sum-response slice into a value. A per-item ADS error maps to its status; a missing or
/// short block (a target answering with less than was asked for) is a size error for that item only.
/// </summary>
/// <param name="type">The declared type.</param>
/// <param name="subError">The item's ADS error code (0 = none).</param>
/// <param name="block">The item's bytes, or null when the response had none.</param>
/// <param name="layout">The planned layout (a scalar is one element).</param>
/// <param name="isArray">True to return the whole array, false for element 0.</param>
/// <param name="utf8">True when the target's STRINGs are UTF-8.</param>
/// <param name="mapError">Maps an ADS code to an OPC UA status (also raises symbol-version signals).</param>
/// <returns>The value and status.</returns>
public static (object? value, uint status) DecodeItem(
TwinCATDataType type, Beckhoff.AdsErrorCode subError, ReadOnlyMemory<byte>? block,
TwinCATArrayLayout layout, bool isArray, bool utf8, Func<uint, uint> mapError)
{
if (subError != Beckhoff.AdsErrorCode.NoError) return (null, mapError((uint)subError));
if (block is not { } b || b.Length < layout.ByteLength)
return (null, TwinCATStatusMapper.MapAdsError(1797));
try
{
var decoded = TwinCATArrayCodec.Decode(type, b.Span, layout, utf8);
return (isArray ? decoded : decoded.GetValue(0), TwinCATStatusMapper.Good);
}
catch (ArgumentException)
{
return (null, TwinCATStatusMapper.MapAdsError(1797));
}
}
}
@@ -0,0 +1,21 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// <summary>
/// Thrown by <see cref="ITwinCATClient.ReadValuesAsync"/> when the TARGET refuses the ADS sum-read
/// command as a whole (service or index group not supported). It is not a comms fault: the driver marks
/// the device "no sum-read" and re-reads the batch with individual reads, so the poll still completes.
/// </summary>
internal sealed class TwinCATSumReadRefusedException : Exception
{
/// <summary>Initializes a new instance of the <see cref="TwinCATSumReadRefusedException"/> class.</summary>
/// <param name="adsErrorCode">The overall ADS error code the target answered with.</param>
/// <param name="inner">The underlying exception, if any.</param>
public TwinCATSumReadRefusedException(uint adsErrorCode, Exception? inner = null)
: base($"ADS sum-read refused by the target (ADS error {adsErrorCode})", inner)
{
AdsErrorCode = adsErrorCode;
}
/// <summary>Gets the overall ADS error code.</summary>
public uint AdsErrorCode { get; }
}