fix(twincat): review fixes for sum-read and diagnostics (#645)

Strict width+kind eligibility for sum-batched scalars (others read singly); latch only on ADS
1793/1794, split 1792/1797/1798 without latching; 32 KiB per-command budget and 500-item ceiling;
short sum response handled per item. Diagnostics: opt-in diagnosticsEnabled (default off), reuse the
probe's ReadState, sample after the host-state transition, JitterMs renamed HeadroomMs, compact
per-device line so many devices fit the 16-entry cap, unavailable-symbol latch cleared on reconnect.
AdminUI: min/max on sum-read max, diagnostics checkbox. Pure planner extracted and unit-tested.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 19:29:35 -04:00
parent af19db4ed1
commit fbcb521d1b
16 changed files with 470 additions and 138 deletions
+31 -23
View File
@@ -195,33 +195,41 @@ route (see `docs/v3/twincat-backlog.md`).
### Sum-read (opt-in)
`useSumRead: true` (default **off**) batches a poll's reads for one device into ADS sum-read commands
(`IndexGroup 0xF080`) of at most `sumReadMaxItems` symbols (default 200) instead of one round-trip per tag.
TC2 CE 2.11 support for the sum command is unverified, hence opt-in.
(`IndexGroup 0xF080`) of at most `sumReadMaxItems` symbols (default 200, ceiling 500) and at most 32 KiB of
payload (read lengths + 4 bytes each) instead of one round-trip per tag. TC2 CE 2.11 support for the sum
command is unverified, hence opt-in.
- The client plans each item exactly as a single read does — symbol from the Beckhoff symbol cache, declared
type checked by `TwinCATTypeGuard` — and then reads the raw bytes at the symbol's index group / offset in
one command, decoding with the same `TwinCATArrayCodec` (a scalar is a one-element array). A type-mismatched
item gets `BadTypeMismatch` without joining the batch; a bit-indexed BOOL and a handle-addressed symbol
(`0xF005`) are read individually inside the call.
- Per-item ADS error codes map to that tag only (`MapAdsError`); the overall code is the command's.
- **Fallback.** If the target refuses the command as a whole (overall ADS 1792–1798: service / group /
offset / size not supported) the client throws `TwinCATSumReadRefusedException`; the driver logs once,
marks the device `SumReadUnsupported`, and re-reads that batch with individual reads. The flag is sticky
until the driver is re-initialised. A comms-level failure is **not** a refusal: that chunk goes
`BadCommunicationError` and the next poll tries the sum command again.
- **Eligibility is strict.** The sum path reads raw bytes at the symbol's index group / offset and decodes them
as the DECLARED type (the notification rules), so a scalar rides the batch only when it passes
`TwinCATTypeGuard.CheckNotification` — same width, same float/integer kind. A tag the lenient single read
accepts by value conversion (`DInt` over a PLC `INT`, `Real` over `LREAL` or `DINT`) is read **individually**
inside the call, keeping the Beckhoff conversion; the batch never publishes reinterpreted bytes at Good.
Arrays use the same raw-bytes rule as on the single path. A type-mismatched item gets `BadTypeMismatch`
without joining the batch; a bit-indexed BOOL and a handle-addressed symbol (`0xF005`) are read singly.
- Per-item ADS error codes map to that tag only (`MapAdsError`); a response shorter than asked is a size error
for that item, not a failed chunk. The overall code is the command's.
- **Latch vs split.** Only overall ADS **1793 / 1794** (service / index group not supported) throws
`TwinCATSumReadRefusedException`; the driver logs once, marks the device `SumReadUnsupported` and reads
singly until re-initialise. **1792 / 1797 / 1798** (general error, bad size, bad data) mean *this request*
was rejected — typically too big — so that chunk is read singly for this poll and nothing is latched. A
comms-level failure is neither: that chunk goes `BadCommunicationError` and the next poll tries again.
- A one-item chunk is just a single read. Sum-**write** is not implemented (writes stay per-tag).
### `/hosts` diagnostics (`IDriverDiagnostics`)
### `/hosts` diagnostics (`IDriverDiagnostics`, opt-in)
The driver implements the optional `IDriverDiagnostics` bag (the S7 `NegotiatedPduSize` precedent, #640),
so these ride the health snapshot to `/hosts` — deliberately not synthetic raw tags. Sampled once per
healthy probe tick: ADS device state (`ReadState`), and `TwinCAT_SystemInfoVarList` `_AppInfo.AppName`,
`_AppInfo.OnlineChangeCnt`, `_TaskInfo[1].CycleTime`, `_TaskInfo[1].LastExecTime` (100 ns ticks).
Keys: `TwinCAT.AdsState`, `.AppName`, `.OnlineChangeCnt`, `.CycleTimeMs`, `.LastExecTimeMs`, `.JitterMs`
(last exec − cycle; positive = the task overran). Several devices prefix `TwinCAT.<DeviceName or host>.`;
the host caps the bag at 16 entries. Times are rounded to 0.1 ms because 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 the client is rebuilt.
Behind `diagnosticsEnabled` (default **off** — not yet live-gated). 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
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).
## Capability surface
+3 -3
View File
@@ -48,13 +48,13 @@ 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`
(see `docs/drivers/TwinCAT.md` § "Sum-read"). Unit-tested against the fake client only. Live gate
(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.
7. **Driver diagnostics (#314) — code DONE, live gate pending.** ADS state, app name, online-change
count, task cycle time, last exec time and jitter on the `/hosts` diagnostics bag. Live gate: confirm
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).
8. **Flat vs VirtualTree browse on TC3 (#612)** — open. Only Z35474 is TC3; read-only symbol-table