Merge hw2-twincat: hardware live-gate fixes (hw2-twincat)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 21:35:10 -04:00
8 changed files with 319 additions and 49 deletions
+6 -2
View File
@@ -1151,8 +1151,12 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa
(`CheckNotification`) rides a batch; value-converted pairs (DInt over INT, Real over LREAL/DINT) read singly. Only overall
ADS 1793/1794 latch the device to single reads; any other non-comms overall code (1792/1795/1797/1798...) reads that chunk singly for the poll without latching, with a once-per-device Warning; a comms failure neither.
Opt-in `diagnosticsEnabled` (default off) puts ADS state/app/online-change/cycle/exec/`HeadroomMs` on the `/hosts`
`IDriverDiagnostics` bag (up to 4 extra reads per device per probe tick). Unit-tested only; live gate pending a
user-approved route (the presses' routes name the Mac's old VPN IP). Sum-write not ported.
`IDriverDiagnostics` bag (≤ 4 extra reads per device per probe tick once settled; TC2 falls back to `.SYSTEMINFO` /
`.SYSTEMTASKINFOARR[1]`, costing 8 on the first tick and after each reconnect). Live-gated read-only 2026-10-08 on
Z35474 (TC3) + Z34834 (TC2, which answers sum commands) via a harness driving `TwinCATDriver` — `/hosts` end-to-end
not run (a stock host has no AMS router); the gate found ⚠️ Beckhoff's `SumRead` ctor throws for a `List<>` of entities (only an array survives
its internal cast) — `AdsTwinCATClient.CreateSumRead` owns it. Flat browse stays (VirtualTree is a strict subset
on TC3). Sum-write not ported.
- **AbLegacy (#636):** `AbLegacyAddress` (in `AbLegacy.Contracts`) is family-aware: PLC-5 `I:`/`O:` indices are OCTAL
and are emitted to libplctag as decimal (libplctag's `pccc.c` parses decimal only). MicroLogix function files parse
but read `BadNotSupported`. Indirect `N7:[N7:0]` (#646) is resolved by the driver at read/write time (index re-read
+30 -16
View File
@@ -189,8 +189,14 @@ to exercise Windows-1252 live — that rule is unit-tested only.
Two items ported from the stranded `auto/driver-gaps` branch (#310, #314), re-implemented against the
current raw-byte read path rather than cherry-picked (the branch predates the type guard, the string codec
and the array codec). Unit-tested against the fake client only; the live gate is read-only and pending a
route (see `docs/v3/twincat-backlog.md`).
and the array codec). Live-gated read-only on 2026-10-08 against Z35474 (TC3) and Z34834 (TC2 2.11) — see
`docs/v3/twincat-backlog.md` items 6–8 for the evidence.
⚠️ **Beckhoff's `SumRead` must be handed an ARRAY of entities.** Its `IList<IgIoSumReadEntity>` overload casts to
`IList<SumDataEntity>` internally, which only an array survives (covariance); a `List<>` throws
`InvalidCastException` before anything is sent. The shipped #645 code passed a `List<>`, so on real hardware every
sum chunk came back `BadCommunicationError` while all fake-client tests passed. `AdsTwinCATClient.CreateSumRead`
owns the construction; an unexpected chunk failure now logs one Warning per device.
### Sum-read (opt-in)
@@ -218,19 +224,28 @@ command is unverified, hence opt-in.
### `/hosts` diagnostics (`IDriverDiagnostics`, opt-in)
Behind `diagnosticsEnabled` (default **off** — not yet live-gated). The driver implements the optional
Behind `diagnosticsEnabled` (default **off**). Live-gated read-only 2026-10-08 on Z35474 (TC3) and Z34834 (TC2) by
driving `TwinCATDriver` from a scratch harness with an in-process AMS router; the `/hosts` page end-to-end was
**not** exercised (a stock host has no AMS router). The driver implements the optional
`IDriverDiagnostics` bag (the S7 `NegotiatedPduSize` precedent, #640), deliberately not synthetic raw tags.
When enabled, after each healthy probe tick **and after the host-state transition** it reads up to four
`TwinCAT_SystemInfoVarList` symbols per device — `_AppInfo.AppName`, `_AppInfo.OnlineChangeCnt`,
`_TaskInfo[1].CycleTime`, `_TaskInfo[1].LastExecTime` (100 ns ticks) — so up to **4 extra read requests per
device per probe interval** (default 5 s). The ADS state is the one `ProbeAsync` already read; no second
`_TaskInfo[1].CycleTime`, `_TaskInfo[1].LastExecTime` (100 ns ticks) — so at most **4 extra read requests per
device per probe interval** (default 5 s) once settled. On **TwinCAT 2** (fallback below) the first tick, and the
first tick after every reconnect, costs **8** (the four TC3 names answer "not found", then the four TC2 names);
after that ≤ 4. The ADS state is the one `ProbeAsync` already read; no second
ReadState. One device: flat keys `TwinCAT.AdsState`, `.AppName`, `.OnlineChangeCnt`, `.CycleTimeMs`,
`.LastExecTimeMs`, `.HeadroomMs` (cycle − last exec; negative = the task overran; this is spare time, not
jitter). Several devices: one line per device, `TwinCAT.<DeviceName or host>` =
`Run; Plc30 App; online-changes 7; cycle 10.0 ms; exec 2.5 ms; headroom 7.5 ms`, so the host's 16-entry cap
covers 16 devices. Times round to 0.1 ms (the bag's digest is in the publish fingerprint). Each symbol is
best-effort: a runtime that does not export one (older TC2) keeps the others, and a symbol answering "not
found" is not re-asked until a new client is installed (reconnect).
best-effort: a runtime that does not export one keeps the others, and a symbol answering "not
found" is not re-asked until a new client is installed (reconnect). **TwinCAT 2** has no
`TwinCAT_SystemInfoVarList`; once a TC3 name answers "not found" the driver reads the TC2 globals instead —
`.SYSTEMINFO.PROJECTNAME` (AppName), `.SYSTEMINFO.ONLINECHANGECOUNT` (UINT), `.SYSTEMTASKINFOARR[1].CYCLETIME` /
`.LASTEXECTIME` (same 100 ns units). The read counts — 8 on the first tick and after each reconnect, 4 after, and
none of the TC2 names on a TC3 target — are pinned by `TwinCATDiagnosticsTests`; the live run proved the values
(all six keys on TC2 2.11), not the counts.
## Capability surface
@@ -448,16 +463,15 @@ Discovered struct/UDT/FB member **writes** are **deferred** in this release. Sca
members can be written when pre-declared with `Writable: true`; the controller-browse expansion
path does not yet wire the write channel.
### Live caveat — Flat-mode vs VirtualTree-mode browse
### Flat-mode vs VirtualTree-mode browse — resolved, Flat stays (#612)
The ADS client's `BrowseSymbolsAsync` may return symbols in **Flat mode** (a flat list of all
instance paths, with no `SubSymbols` populated) on some TC3 runtime configurations, in which
case the recursive struct expansion produces no sub-symbols and the walk yields only top-level
symbols. If a live TC3 browse does not populate `SubSymbols` on struct-typed symbols, the
alternative is **VirtualTree mode** (which returns a hierarchical symbol tree with `SubSymbols`
populated). VirtualTree-mode browse is an unverified follow-up; if Flat-mode struct expansion
produces empty `Discovered/` sub-folders for struct symbols, switch to VirtualTree mode via the
driver configuration.
The driver loads symbols with `SymbolsLoadMode.Flat`. The worry was that Flat might leave `SubSymbols`
empty on some TC3 runtimes and drop every struct member. Live on the only TC3 press, Z35474 (TwinCAT
3.1.4024, 2026-10-08, read-only): Flat returned 3,384 roots, every one of the 573 struct roots carried its
`SubSymbols`, and the expander produced 10,770 leaves (9,921 of them struct members; `DiscoverAsync` 10,628
variables). VirtualTree returned 1,027 roots and 1,459 leaves — a strict subset (0 tree-only leaves) that
misses array-of-struct element members such as `.L_Axis[10].IN._AT.DriveStatusWord`. There is no load-mode
option and none is needed. TC2 Flat was already proven (45,083 / 42,666 variables, 2026-10-07).
## Further reading
+23 -16
View File
@@ -22,11 +22,13 @@ The Elma cleaner fleet (25 presses, 23 live) is the only real TwinCAT hardware i
Access facts: ADS is TCP 48898. The driver sends no AddRoute (routes are provisioned out of band) and has
no IP field, so a host needs a local AMS router — TwinCAT XAR, or `Beckhoff.TwinCAT.Ads.TcpRouter` hosted
in-process (the driver csproj does not reference TcpRouter; the 2026-10-07 run used a scratch harness).
The Mac reaches the presses directly over the VPN. **The PLC routes left by earlier tooling are STALE:**
they name the Mac's old VPN IP (10.181.198.189), and the Mac is now 10.181.207.140, so a source NetId of
`10.181.207.140.1.1` has no route on any press. We must not AddRoute on production, so the **live gate for
this backlog is deferred pending a user-approved route** (owner refreshes the route, or the user approves
reusing the old source NetId). Nothing below that says "live" has been run against TC3.
The Mac reaches the presses directly over the VPN. **Routes (2026-10-08, hw2 live gate):** with the
user's approval, an `oee-tagdump-mac` route for the Mac's current VPN IP (`10.181.201.108.1.1`) was added
to **Z35474** (TC3) and **Z34834** (TC2, `5.69.178.220.1.1`, 10.201.45.197) by UDP-48899 AddRoute with the
factory login. ⚠️ A same-name AddRoute does **not** replace on either runtime: both presses now carry TWO
`oee-tagdump-mac` entries — the stale `10.181.198.189.1.1` and the new one (route table read back over ADS,
port 10000 / IG 803). Removing the stale one needs an ADS write and was not done. Every other press still
has only the stale route, so it refuses the Mac; the VPN IP changes again ⇒ the routes go stale again.
## Status of the original items
@@ -47,19 +49,24 @@ reusing the old source NetId). Nothing below that says "live" has been run again
## Added by #612 / #645
6. **ADS sum-read (#310) — code DONE, live gate pending.** Opt-in `useSumRead` / `sumReadMaxItems`
(strict width+kind eligibility; latch only on ADS 1793/1794; see `docs/drivers/TwinCAT.md` § "Sum-read"). Unit-tested against the fake client only. Live gate
(read-only, valid on a press): enable on Z33856 / Z33733 (TC2) and Z35474 (TC3), compare a batched poll
to individual reads value-for-value, and record whether TC2 CE 2.11 answers or refuses the sum command
(either outcome is a result — a refusal must fall back cleanly). Needs the route above.
6. **ADS sum-read (#310) — DONE, live-gated 2026-10-08** on Z35474 (TC3) and Z34834 (TC2 2.11): 59 real
symbols (BOOL/INT/UINT/DINT/UDINT/USINT/REAL/LREAL/STRING/TIME + 7 value-converted pairs), sum vs single
bracketed by two single passes, `sumReadMaxItems` 8 (≥ 7 chunks) and 200: identical status on every tag,
identical value on every tag that did not move between passes; poll 59 tags in ~0.35–0.66 s vs ~2.7 s.
**TC2 CE 2.11 answers the sum command** (no refusal, no fallback). The gate found the shipped path broken —
every chunk `BadCommunicationError` because Beckhoff's `SumRead` constructor throws `InvalidCastException` for
a `List<IgIoSumReadEntity>` (see `AdsTwinCATClient.CreateSumRead`); fixed and pinned by
`TwinCATSumReadConstructionTests`.
7. **Driver diagnostics (#314) — code DONE (opt-in `diagnosticsEnabled`, default off), live gate pending.** ADS state, app name, online-change
count, task cycle time, last exec time and headroom on the `/hosts` diagnostics bag. Live gate: confirm
`TwinCAT_SystemInfoVarList._TaskInfo[1]` resolves on TC2 2.11 (the driver tolerates its absence).
7. **Driver diagnostics (#314) — DONE, live-gated 2026-10-08** (opt-in `diagnosticsEnabled`). TC3 Z35474: all six
keys from `TwinCAT_SystemInfoVarList` (`AppName=Port_851`, cycle 5.0 ms, exec 0.1 ms). TC2 Z34834 has **no**
`TwinCAT_SystemInfoVarList`; the driver now falls back to `.SYSTEMINFO.PROJECTNAME` / `.ONLINECHANGECOUNT`
and `.SYSTEMTASKINFOARR[1].CYCLETIME` / `.LASTEXECTIME` (all six keys live: cycle 20.0 ms, exec 0.6 ms).
8. **Flat vs VirtualTree browse on TC3 (#612)** — open. Only Z35474 is TC3; read-only symbol-table
upload in both modes, compare leaf sets. Add a `SymbolsLoadMode` option only if Flat drops struct
members. Needs the route. (Flat on TC2 already proved fine: 45,083 / 42,666 variables.)
8. **Flat vs VirtualTree browse on TC3 (#612) — DONE, keep Flat.** Z35474: Flat 3,384 roots → 10,770 leaves,
every struct root carried SubSymbols (0 without); VirtualTree 1,027 roots → 1,459 leaves, a strict subset
(it loses array-of-struct element members such as `.L_Axis[10].IN._AT.*`). The driver's `DiscoverAsync`
returned 10,628 variables. No `SymbolsLoadMode` option is needed.
9. **Discovered struct/FB member writes (#612)** — deferred: writes, never on a press; fixture-only.
Likewise **sum-write** was deliberately NOT ported from the stranded branch.
@@ -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
@@ -386,9 +386,13 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
continue;
}
// Set once the connect succeeded: a connect / backoff failure is the connect path's to report (it
// logs its own Warning) and must not consume the once-per-device sum-chunk-failure Warning below.
var connected = false;
try
{
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
connected = true;
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);
@@ -427,6 +431,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 (connected && !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 +951,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 +987,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 +1013,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 +1516,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; }
@@ -96,6 +96,10 @@ public sealed class TwinCATDiagnosticsTests
client.ReadStatuses[TwinCATDriver.SymCycleTime] = TwinCATStatusMapper.BadNodeIdUnknown;
client.ReadStatuses[TwinCATDriver.SymLastExecTime] = TwinCATStatusMapper.BadNodeIdUnknown;
client.ReadStatuses[TwinCATDriver.SymOnlineChangeCnt] = TwinCATStatusMapper.BadNodeIdUnknown;
// ...and the TwinCAT 2 equivalents are absent too.
client.ReadStatuses[TwinCATDriver.SymTc2CycleTime] = TwinCATStatusMapper.BadNodeIdUnknown;
client.ReadStatuses[TwinCATDriver.SymTc2LastExecTime] = TwinCATStatusMapper.BadNodeIdUnknown;
client.ReadStatuses[TwinCATDriver.SymTc2OnlineChangeCount] = TwinCATStatusMapper.BadNodeIdUnknown;
await Sample(drv, client, Host1);
var readsAfterFirst = client.SingleReadCount;
@@ -109,6 +113,49 @@ public sealed class TwinCATDiagnosticsTests
client.SingleReadCount.ShouldBe(readsAfterFirst + 1); // only AppName is read again
}
/// <summary>hw2 live gate (TC2 2.11 Elma Z34834): a TwinCAT 2 runtime has no TwinCAT_SystemInfoVarList but
/// serves the same facts as `.SYSTEMINFO` / `.SYSTEMTASKINFOARR[1]`; the bag must be filled from those, and
/// after the first tick only the TC2 names are read (4 reads, not 8).</summary>
[Fact]
public async Task TwinCAT2_system_globals_fill_the_bag_when_the_TC3_names_are_absent()
{
var drv = NewDriver(new FakeTwinCATClientFactory(), false, true, (Host1, null));
await drv.InitializeAsync("{}", CancellationToken.None);
var client = new FakeTwinCATClient { AdsStateText = "Run" };
foreach (var tc3 in new[] { TwinCATDriver.SymAppName, TwinCATDriver.SymOnlineChangeCnt, TwinCATDriver.SymCycleTime, TwinCATDriver.SymLastExecTime })
client.ReadStatuses[tc3] = TwinCATStatusMapper.BadNodeIdUnknown;
client.Values[TwinCATDriver.SymTc2ProjectName] = "ELMA_5STAGE";
client.Values[TwinCATDriver.SymTc2OnlineChangeCount] = (ushort)3;
client.Values[TwinCATDriver.SymTc2CycleTime] = 100_000u; // 10.0 ms
client.Values[TwinCATDriver.SymTc2LastExecTime] = 40_000u; // 4.0 ms
await Sample(drv, client, Host1);
var readsAfterFirst = client.SingleReadCount;
await Sample(drv, client, Host1);
var bag = ((IDriverDiagnostics)drv).GetDiagnostics();
bag["TwinCAT.AppName"].ShouldBe("ELMA_5STAGE");
bag["TwinCAT.OnlineChangeCnt"].ShouldBe("3");
bag["TwinCAT.CycleTimeMs"].ShouldBe("10.0");
bag["TwinCAT.LastExecTimeMs"].ShouldBe("4.0");
bag["TwinCAT.HeadroomMs"].ShouldBe("6.0");
readsAfterFirst.ShouldBe(8);
client.SingleReadCount.ShouldBe(readsAfterFirst + 4);
}
/// <summary>A TC3 target that serves TwinCAT_SystemInfoVarList is never asked for the TC2 names.</summary>
[Fact]
public async Task A_TC3_target_never_reads_the_TwinCAT2_names()
{
var drv = NewDriver(new FakeTwinCATClientFactory(), false, true, (Host1, null));
await drv.InitializeAsync("{}", CancellationToken.None);
var client = PlcClient();
await Sample(drv, client, Host1);
client.SingleReadCount.ShouldBe(4);
}
/// <summary>Verifies a failed sample keeps the previous snapshot rather than blanking the bag.</summary>
[Fact]
public async Task Failed_sample_keeps_the_last_values()
@@ -0,0 +1,99 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// hw2 review of the live-gate fix: the once-per-device "sum-read chunk failed" Warning exists to surface a
/// sum-read path that fails on every poll (the gate's silent failure). A device that is merely unreachable must
/// not consume it — the connect path reports that itself — or the later real defect is logged nowhere.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATSumChunkWarningTests
{
private const string Host = "ads://5.23.91.23.1.1:851";
private const string ChunkWarning = "an ADS sum-read chunk";
[Fact]
public async Task A_connect_failure_does_not_burn_the_warning_and_a_later_real_chunk_failure_warns_once()
{
var connectFails = true;
var factory = new FakeTwinCATClientFactory
{
Customise = () => new FakeTwinCATClient
{
ThrowOnConnect = connectFails,
Exception = new InvalidOperationException("device down"),
SumReadException = new InvalidOperationException("sum path broken"),
},
};
var logger = new CapturingLogger();
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
RawTags = TwinCATRawTags.Entries([
new TwinCATTagDefinition("A", Host, "MAIN.A", TwinCATDataType.DInt),
new TwinCATTagDefinition("B", Host, "MAIN.B", TwinCATDataType.DInt),
]),
Probe = new TwinCATProbeOptions { Enabled = false },
UseSumRead = true,
SumReadMaxItems = 10,
}, "drv-warn", factory, logger);
await drv.InitializeAsync("{}", CancellationToken.None);
// Unreachable: the connect attempt fails, then the backoff window rejects further attempts.
for (var i = 0; i < 3; i++)
(await drv.ReadAsync(["A", "B"], CancellationToken.None))
.ShouldAllBe(s => s.StatusCode == TwinCATStatusMapper.BadCommunicationError);
logger.Warnings.Count(w => w.Contains(ChunkWarning)).ShouldBe(0);
// Device comes back, but the sum path itself fails: wait out the 1 s connect backoff, then two polls.
connectFails = false;
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (!factory.Clients.Any(c => c.IsConnected) && DateTime.UtcNow < deadline)
{
await drv.ReadAsync(["A", "B"], CancellationToken.None);
await Task.Delay(50);
}
factory.Clients.Any(c => c.IsConnected).ShouldBeTrue("the device must reconnect for the second half to mean anything");
await drv.ReadAsync(["A", "B"], CancellationToken.None);
await drv.ReadAsync(["A", "B"], CancellationToken.None);
logger.Warnings.Count(w => w.Contains(ChunkWarning)).ShouldBe(1);
}
/// <summary>Source guard: the driver's only Beckhoff <c>SumRead</c> construction is inside
/// <c>CreateSumRead</c>, so re-inlining a <c>List</c>-based construction in <c>ReadGroupAsync</c> goes red.</summary>
[Fact]
public void ReadGroupAsync_builds_its_sum_command_through_CreateSumRead_only()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx"))) dir = dir.Parent;
dir.ShouldNotBeNull("repo root not found");
var src = File.ReadAllText(Path.Combine(dir!.FullName,
"src", "Drivers", "ZB.MOM.WW.OtOpcUa.Driver.TwinCAT", "AdsTwinCATClient.cs"));
Regex.Matches(src, @"new\s+SumRead\s*\(").Count.ShouldBe(1);
var create = src.IndexOf("internal static SumRead CreateSumRead(", StringComparison.Ordinal);
create.ShouldBeGreaterThan(0);
var ctor = Regex.Match(src, @"new\s+SumRead\s*\(").Index;
ctor.ShouldBeGreaterThan(create);
src.IndexOf("private const uint SymbolValueByHandle", StringComparison.Ordinal).ShouldBeGreaterThan(ctor);
Regex.IsMatch(src, @"await\s+CreateSumRead\(_client").ShouldBeTrue();
}
private sealed class CapturingLogger : ILogger<TwinCATDriver>
{
public List<string> Warnings { get; } = [];
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
if (logLevel == LogLevel.Warning) lock (Warnings) Warnings.Add(formatter(state, exception));
}
}
}
@@ -0,0 +1,41 @@
using Shouldly;
using TwinCAT.Ads;
using TwinCAT.Ads.SumCommand;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// hw2 live gate (2026-10-08, TC3 Z35474): every ADS sum-read chunk failed with
/// <see cref="InvalidCastException"/> thrown by Beckhoff's own <see cref="SumRead"/> constructor, because the
/// <c>IList&lt;IgIoSumReadEntity&gt;</c> overload casts its argument to <c>IList&lt;SumDataEntity&gt;</c> — which
/// works for an array (covariance) and not for a <c>List&lt;IgIoSumReadEntity&gt;</c>. The driver turned it into
/// <c>BadCommunicationError</c> for every batched tag on every poll. The fake client never builds a real
/// <see cref="SumRead"/>, so these tests construct one directly; the constructor does no I/O, so an unconnected
/// <see cref="AdsClient"/> is enough.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATSumReadConstructionTests
{
[Fact]
public void CreateSumRead_builds_a_real_Beckhoff_sum_command_without_a_cast_failure()
{
using var client = new AdsClient();
var sum = AdsTwinCATClient.CreateSumRead(client, [(0x4040u, 0x10u, 2), (0x4040u, 0x20u, 4), (0x4020u, 0x0u, 81)]);
sum.ShouldNotBeNull();
}
[Fact]
public void The_Beckhoff_list_overload_really_throws_for_a_List_which_is_why_CreateSumRead_passes_an_array()
{
// Falsifiability control: if a future Beckhoff release fixes the cast, this test goes red and the remark
// on CreateSumRead can be retired — but the array form keeps working either way.
using var client = new AdsClient();
var list = new List<IgIoSumReadEntity> { new(0x4040, 0x10, 2), new(0x4040, 0x20, 4) };
Should.Throw<InvalidCastException>(() => new SumRead(client, list, SumCommandErrorStrategy.Relaxed));
}
}