From 664ff75ee29f2f0d079ed73fb62896560a1cf6ac Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 8 Oct 2026 21:17:44 -0400 Subject: [PATCH] =?UTF-8?q?fix(twincat):=20#645=20live=20gate=20=E2=80=94?= =?UTF-8?q?=20sum-read=20never=20worked=20on=20hardware;=20TC2=20diagnosti?= =?UTF-8?q?cs=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) ctor casts to IList, 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 --- CLAUDE.md | 7 ++- docs/drivers/TwinCAT.md | 35 +++++++++----- docs/v3/twincat-backlog.md | 39 ++++++++------- .../AdsTwinCATClient.cs | 38 ++++++++++----- .../TwinCATDriver.cs | 46 ++++++++++++++++-- .../TwinCATDiagnosticsTests.cs | 47 +++++++++++++++++++ .../TwinCATSumReadConstructionTests.cs | 41 ++++++++++++++++ 7 files changed, 207 insertions(+), 46 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSumReadConstructionTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 5d0b185d..c92bf72e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1151,8 +1151,11 @@ 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 (up to 4 extra reads per device per probe tick; TC2 falls back to `.SYSTEMINFO` / + `.SYSTEMTASKINFOARR[1]`). Live-gated read-only 2026-10-08 on Z35474 (TC3) + Z34834 (TC2, which answers sum + commands); 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 diff --git a/docs/drivers/TwinCAT.md b/docs/drivers/TwinCAT.md index 24796bb0..ec85d5de 100644 --- a/docs/drivers/TwinCAT.md +++ b/docs/drivers/TwinCAT.md @@ -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` overload casts to +`IList` 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) @@ -229,8 +235,12 @@ ReadState. One device: flat keys `TwinCAT.AdsState`, `.AppName`, `.OnlineChangeC jitter). Several devices: one line per device, `TwinCAT.` = `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). Live on TC2 2.11: 8 reads on the first tick, 4 after; a TC3 target never +reads the TC2 names. ## Capability surface @@ -448,16 +458,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 diff --git a/docs/v3/twincat-backlog.md b/docs/v3/twincat-backlog.md index 1d0c6678..401075d6 100644 --- a/docs/v3/twincat-backlog.md +++ b/docs/v3/twincat-backlog.md @@ -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` (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. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs index d73ff860..b939b5b0 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs @@ -275,10 +275,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient ResultSumReadRaw sum; try { - var entities = new List(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 } } + /// + /// Builds the index-group/offset ADS sum-read command for . + /// + /// + /// The entities MUST be handed over as an ARRAY. Beckhoff's + /// SumRead(IAdsConnection, IList<IgIoSumReadEntity>, ...) overload casts its argument to + /// IList<SumDataEntity> internally; that cast succeeds for an array (array covariance) and + /// throws for a List<IgIoSumReadEntity>. 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 BadCommunicationError for every batched tag on every poll — while the fake-client + /// unit tests never constructed a real . The constructor does no I/O, so + /// TwinCATSumReadConstructionTests pins this without a PLC. + /// + /// The ADS connection the command will run on. + /// Index group, index offset and byte length per item, in request order. + /// A ready-to-run sum-read command (relaxed error strategy). + 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 diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs index 811cc6a1..649a6a76 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs @@ -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"; + /// Converts a TwinCAT 100 ns tick count to milliseconds. 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), }; + /// Reads the TC3 system symbol, falling back to its TwinCAT 2 equivalent once the TC3 name is + /// known to be absent on this target. + private async Task 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 ReadDiagSymbolAsync( DeviceState state, ITwinCATClient client, string symbol, TwinCATDataType type, CancellationToken ct) { @@ -1477,6 +1512,9 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery /// True once the "sum request rejected, chunk read singly" warning was logged for this device. public volatile bool SumFallbackWarned; + /// True once an unexpected sum-read chunk failure was logged for this device. + public volatile bool SumChunkFailureWarned; + /// Latest probe-sampled PLC diagnostics (#645 / #314); null before the first sample. public TwinCATDeviceDiagnostics? LastDiagnostics { get; set; } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATDiagnosticsTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATDiagnosticsTests.cs index d135373d..9fe134a6 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATDiagnosticsTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATDiagnosticsTests.cs @@ -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 } + /// 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). + [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); + } + + /// A TC3 target that serves TwinCAT_SystemInfoVarList is never asked for the TC2 names. + [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); + } + /// Verifies a failed sample keeps the previous snapshot rather than blanking the bag. [Fact] public async Task Failed_sample_keeps_the_last_values() diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSumReadConstructionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSumReadConstructionTests.cs new file mode 100644 index 00000000..42e9ded5 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSumReadConstructionTests.cs @@ -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; + +/// +/// hw2 live gate (2026-10-08, TC3 Z35474): every ADS sum-read chunk failed with +/// thrown by Beckhoff's own constructor, because the +/// IList<IgIoSumReadEntity> overload casts its argument to IList<SumDataEntity> — which +/// works for an array (covariance) and not for a List<IgIoSumReadEntity>. The driver turned it into +/// BadCommunicationError for every batched tag on every poll. The fake client never builds a real +/// , so these tests construct one directly; the constructor does no I/O, so an unconnected +/// is enough. +/// +[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 { new(0x4040, 0x10, 2), new(0x4040, 0x20, 4) }; + + Should.Throw(() => new SumRead(client, list, SumCommandErrorStrategy.Relaxed)); + } +}