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:
@@ -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<IgIoSumReadEntity>, ...)</c> overload casts its argument to
|
||||
/// <c>IList<SumDataEntity></c> internally; that cast succeeds for an array (array covariance) and
|
||||
/// throws <see cref="InvalidCastException"/> for a <c>List<IgIoSumReadEntity></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; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user