fix(twincat): read-singly fallback for any non-comms overall sum error, once-per-device warning, wording (#645 re-review)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 19:33:09 -04:00
parent fbcb521d1b
commit 802781b645
10 changed files with 90 additions and 15 deletions
@@ -289,6 +289,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
{
// 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;
}
@@ -301,6 +302,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
throw new TwinCATSumReadRefusedException(overall);
if (TwinCATSumReadPlanner.IsRequestRejected(overall))
{
NoteSumFallback(overall);
await FallBackAsync().ConfigureAwait(false); // too big / bad size for THIS request: no latch
return;
}
@@ -325,6 +327,20 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
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);
@@ -92,6 +92,16 @@ public interface ITwinCATClient : IDisposable
/// </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"/>).
@@ -393,7 +393,17 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
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);
var fallbacksBefore = client.SumFallbackCount;
var bulk = await client.ReadValuesAsync(items, cancellationToken).ConfigureAwait(false);
if (client.SumFallbackCount > fallbacksBefore && !device.SumFallbackWarned)
{
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];
@@ -994,7 +1004,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
/// <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 jitter (last exec − cycle). A single-device
/// 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
@@ -1464,6 +1474,9 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
/// <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; }
@@ -37,11 +37,21 @@ internal static class TwinCATSumReadPlanner
/// <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>The target rejected THIS request (general error / bad size / bad data), typically because it was
/// too big. Not a device property: read the chunk singly for this poll, do not latch.</summary>
/// <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 for 1792, 1797 and 1798.</returns>
public static bool IsRequestRejected(uint adsError) => adsError is 1792 or 1797 or 1798;
/// <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