fix(twincat): #645 live gate — sum-read never worked on hardware; TC2 diagnostics fallback

Read-only live gate on Elma presses Z35474 (TC3 3.1.4024) and Z34834 (TC2 2.11):

- Sum-read: every chunk came back BadCommunicationError. Beckhoff's
  SumRead(IList<IgIoSumReadEntity>) ctor casts to IList<SumDataEntity>, which
  throws InvalidCastException for a List<> (only an array survives via
  covariance). The fake-client tests never built a real SumRead. New
  AdsTwinCATClient.CreateSumRead passes an array; TwinCATSumReadConstructionTests
  pins it (red on the List form) plus a falsifiability control on the library.
  After the fix: 59 tags, status identical everywhere, values identical on every
  non-moving tag, ~0.35-0.66 s vs ~2.7 s per poll; TC2 CE 2.11 answers sum reads.
- An unexpected sum chunk failure now logs one Warning per device (it was
  invisible: the health message was overwritten by the next good read).
- Diagnostics: TC2 has no TwinCAT_SystemInfoVarList; fall back to .SYSTEMINFO /
  .SYSTEMTASKINFOARR[1] once the TC3 name answers not-found (live: all six keys).
- #612 browse: Flat verified on TC3 (10,770 leaves; VirtualTree 1,459 strict
  subset) — comment and docs updated, no load-mode option needed.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 21:17:44 -04:00
parent e7dd742541
commit 664ff75ee2
7 changed files with 207 additions and 46 deletions
@@ -275,10 +275,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
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)
sum = await CreateSumRead(_client, items.Select(p => (p.IndexGroup, p.IndexOffset, p.Layout.ByteLength)))
.ReadRawAsync(cancellationToken).ConfigureAwait(false);
}
catch (AdsErrorException ex) when (TwinCATSumReadPlanner.IsRefusal((uint)ex.ErrorCode))
@@ -322,6 +319,29 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
}
}
/// <summary>
/// Builds the index-group/offset ADS sum-read command for <paramref name="entries"/>.
/// </summary>
/// <remarks>
/// The entities MUST be handed over as an ARRAY. Beckhoff's
/// <c>SumRead(IAdsConnection, IList&lt;IgIoSumReadEntity&gt;, ...)</c> overload casts its argument to
/// <c>IList&lt;SumDataEntity&gt;</c> internally; that cast succeeds for an array (array covariance) and
/// throws <see cref="InvalidCastException"/> for a <c>List&lt;IgIoSumReadEntity&gt;</c>. The live gate
/// (hw2, 2026-10-08, TC3 Z35474) found every sum-read chunk failing that way — the driver's catch-all
/// turned it into <c>BadCommunicationError</c> for every batched tag on every poll — while the fake-client
/// unit tests never constructed a real <see cref="SumRead"/>. The constructor does no I/O, so
/// <c>TwinCATSumReadConstructionTests</c> pins this without a PLC.
/// </remarks>
/// <param name="connection">The ADS connection the command will run on.</param>
/// <param name="entries">Index group, index offset and byte length per item, in request order.</param>
/// <returns>A ready-to-run sum-read command (relaxed error strategy).</returns>
internal static SumRead CreateSumRead(
IAdsConnection connection, IEnumerable<(uint IndexGroup, uint IndexOffset, int ByteLength)> entries)
{
IgIoSumReadEntity[] entities = [.. entries.Select(e => new IgIoSumReadEntity(e.IndexGroup, e.IndexOffset, e.ByteLength))];
return new SumRead(connection, entities, SumCommandErrorStrategy.Relaxed);
}
private const uint SymbolValueByHandle = 0xF005;
private readonly record struct PlannedSumRead(int Index, uint IndexGroup, uint IndexOffset, TwinCATArrayLayout Layout, bool IsArray);
@@ -638,13 +658,9 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
// 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.
//
// LIVE RISK (operator-gated, not yet verified against a real TC3 target): Flat mode
// may not populate ISymbol.SubSymbols on struct/UDT/FB symbols on some TC3 firmware
// versions — in that case TwinCATSymbolExpander sees no children and drops those
// symbols silently. If struct members don't surface on a real target, switch to
// SymbolsLoadMode.VirtualTree which is documented to always populate SubSymbols via
// the hierarchy. Do NOT change the mode here until confirmed against live ADS — Flat is
// required for Phase 4c array discovery to work correctly.
// Flat is VERIFIED on TC3 (hw2 live gate 2026-10-08, Z35474, TwinCAT 3.1.4024): every struct
// root carried SubSymbols, 10,770 leaves; VirtualTree gave 1,459, a strict subset that drops
// array-of-struct element members. TC2 Flat was proven 2026-10-07. Do not switch to VirtualTree.
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
@@ -427,6 +427,16 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
foreach (var e in chunk)
results[e.Index] = new DataValueSnapshot(null, TwinCATStatusMapper.BadCommunicationError, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
// Once per device: the health message is overwritten by the next good read in the same poll, so
// without this a sum-read path failing on every poll left no trace (hw2 live gate, 2026-10-08).
if (!device.SumChunkFailureWarned)
{
device.SumChunkFailureWarned = true;
_logger.LogWarning(ex,
"TwinCAT driver '{DriverInstanceId}' device '{Device}': an ADS sum-read chunk of {Count} tags " +
"failed and was reported BadCommunicationError; this warning is logged once per device",
_driverInstanceId, device.Options.HostAddress, chunk.Count);
}
}
}
}
@@ -937,6 +947,15 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
internal const string SymCycleTime = "TwinCAT_SystemInfoVarList._TaskInfo[1].CycleTime";
internal const string SymLastExecTime = "TwinCAT_SystemInfoVarList._TaskInfo[1].LastExecTime";
// TwinCAT 2 exports the same facts under different globals (leading dot required): FW_SYSTEMINFOTYPE
// `.SYSTEMINFO` and the `.SYSTEMTASKINFOARR` array (same 100 ns units). Live gate hw2 2026-10-08: a TC2 2.11
// Elma (Z34834) answers DeviceSymbolNotFound for every TwinCAT_SystemInfoVarList symbol and serves these.
// Asked only after the TC3 name answered "not found", so a TC3 target never pays for them.
internal const string SymTc2ProjectName = ".SYSTEMINFO.PROJECTNAME";
internal const string SymTc2OnlineChangeCount = ".SYSTEMINFO.ONLINECHANGECOUNT"; // UINT on TC2
internal const string SymTc2CycleTime = ".SYSTEMTASKINFOARR[1].CYCLETIME";
internal const string SymTc2LastExecTime = ".SYSTEMTASKINFOARR[1].LASTEXECTIME";
/// <summary>Converts a TwinCAT 100 ns tick count to milliseconds.</summary>
internal static double TicksToMilliseconds(uint ticks100ns) => ticks100ns / 10_000.0;
@@ -964,12 +983,16 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
// 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
var appName = await ReadDiagSymbolAsync(state, client, SymAppName, TwinCATDataType.String,
SymTc2ProjectName, TwinCATDataType.String, ct).ConfigureAwait(false) as string
?? prev?.AppName;
var onlineChange = ToUInt(await ReadDiagSymbolAsync(state, client, SymOnlineChangeCnt, TwinCATDataType.UDInt, ct).ConfigureAwait(false))
var onlineChange = ToUInt(await ReadDiagSymbolAsync(state, client, SymOnlineChangeCnt, TwinCATDataType.UDInt,
SymTc2OnlineChangeCount, TwinCATDataType.UInt, 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 cycleTicks = ToUInt(await ReadDiagSymbolAsync(state, client, SymCycleTime, TwinCATDataType.UDInt,
SymTc2CycleTime, TwinCATDataType.UDInt, ct).ConfigureAwait(false));
var execTicks = ToUInt(await ReadDiagSymbolAsync(state, client, SymLastExecTime, TwinCATDataType.UDInt,
SymTc2LastExecTime, TwinCATDataType.UDInt, ct).ConfigureAwait(false));
var cycleMs = cycleTicks is { } c ? TicksToMilliseconds(c) : prev?.CycleTimeMs;
var execMs = execTicks is { } x ? TicksToMilliseconds(x) : prev?.LastExecTimeMs;
@@ -986,6 +1009,18 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
_ => Convert.ToUInt32(v, System.Globalization.CultureInfo.InvariantCulture),
};
/// <summary>Reads the TC3 system symbol, falling back to its TwinCAT 2 equivalent once the TC3 name is
/// known to be absent on this target.</summary>
private async Task<object?> ReadDiagSymbolAsync(
DeviceState state, ITwinCATClient client, string symbol, TwinCATDataType type,
string tc2Symbol, TwinCATDataType tc2Type, CancellationToken ct)
{
var value = await ReadDiagSymbolAsync(state, client, symbol, type, ct).ConfigureAwait(false);
if (value is null && state.UnavailableDiagSymbols.ContainsKey(symbol))
value = await ReadDiagSymbolAsync(state, client, tc2Symbol, tc2Type, ct).ConfigureAwait(false);
return value;
}
private async Task<object?> ReadDiagSymbolAsync(
DeviceState state, ITwinCATClient client, string symbol, TwinCATDataType type, CancellationToken ct)
{
@@ -1477,6 +1512,9 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
/// <summary>True once the "sum request rejected, chunk read singly" warning was logged for this device.</summary>
public volatile bool SumFallbackWarned;
/// <summary>True once an unexpected sum-read chunk failure was logged for this device.</summary>
public volatile bool SumChunkFailureWarned;
/// <summary>Latest probe-sampled PLC diagnostics (#645 / #314); null before the first sample.</summary>
public TwinCATDeviceDiagnostics? LastDiagnostics { get; set; }