Merge hw2-twincat: hardware live-gate fixes (hw2-twincat)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -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
@@ -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
@@ -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<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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
@@ -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<IgIoSumReadEntity></c> overload casts its argument to <c>IList<SumDataEntity></c> — which
|
||||
/// works for an array (covariance) and not for a <c>List<IgIoSumReadEntity></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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user