feat(twincat): opt-in ADS sum-read and /hosts diagnostics bag; refresh TwinCAT backlog (#645, #612)

Sum-read (#310): batches a poll's reads per device into sum commands capped at sumReadMaxItems,
per-item ADS errors mapped per tag, falls back to individual reads when the target refuses the
command. Diagnostics (#314): ADS state, app name, online-change count, task cycle/exec/jitter via
IDriverDiagnostics. Re-implemented against the current raw-byte read path (stranded auto/driver-gaps
commits predate it). Sum-write not ported. Unit tests only; live gate pending a route.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 19:18:42 -04:00
parent 8801631739
commit af19db4ed1
15 changed files with 1055 additions and 48 deletions
+5
View File
@@ -1130,6 +1130,11 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa
`DriverHealthChanged.Diagnostics` / proto `has_diagnostics`+`diagnostics`; its digest is in the publish fingerprint). `DriverHealthChanged.Diagnostics` / proto `has_diagnostics`+`diagnostics`; its digest is in the publish fingerprint).
- **TwinCAT (#637):** TIME/TOD publish Float64 ms, DATE/DT a UTC DateTime; a tag authored `UDInt` keeps the raw counter - **TwinCAT (#637):** TIME/TOD publish Float64 ms, DATE/DT a UTC DateTime; a tag authored `UDInt` keeps the raw counter
(and keeps historizing — DateTime is not historized). (and keeps historizing — DateTime is not historized).
- **TwinCAT (#645, #310/#314):** opt-in ADS sum-read (`useSumRead`, `sumReadMaxItems` default 200; reads raw bytes by index
group/offset and decodes with the same codecs as a single read; a target refusing the sum command (overall ADS 1792-1798)
flips that device to individual reads, a comms failure does not). `/hosts` diagnostics via `IDriverDiagnostics`
(`TwinCAT.AdsState`/`AppName`/`OnlineChangeCnt`/`CycleTimeMs`/`LastExecTimeMs`/`JitterMs`, probe-sampled). Unit-tested only; live
gate pending a user-approved route (the presses' routes name the Mac's old VPN IP). Sum-write not ported.
- **AbLegacy (#636):** `AbLegacyAddress` (in `AbLegacy.Contracts`) is family-aware: PLC-5 `I:`/`O:` indices are OCTAL - **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 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 but read `BadNotSupported`. Indirect `N7:[N7:0]` (#646) is resolved by the driver at read/write time (index re-read
+39 -1
View File
@@ -185,9 +185,47 @@ notifications; `DInt` over a `STRING(80)` → `BadTypeMismatch` in both paths wi
the rest of the subscription Good. No PLC value held a genuine non-ASCII string the rest of the subscription Good. No PLC value held a genuine non-ASCII string
to exercise Windows-1252 live — that rule is unit-tested only. to exercise Windows-1252 live — that rule is unit-tested only.
## Sum-read and diagnostics (Gitea #645)
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`).
### 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.
- 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.
- A one-item chunk is just a single read. Sum-**write** is not implemented (writes stay per-tag).
### `/hosts` diagnostics (`IDriverDiagnostics`)
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.
## Capability surface ## Capability surface
`TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IRediscoverable` `TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IRediscoverable, ISkippedTagReporter, IDriverDiagnostics`
(`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs`). (`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs`).
| Capability | Path | Notes | | Capability | Path | Notes |
+52 -18
View File
@@ -5,27 +5,61 @@ The v2 TwinCAT driver is considered solid: 28 integration tests (14 `[TwinCATFac
three latent driver bugs shaken out (notification cycle units, `STRING(N)` mapper, three latent driver bugs shaken out (notification cycle units, `STRING(N)` mapper,
bit-indexed BOOL path). Further work is deferred. bit-indexed BOOL path). Further work is deferred.
Archived from `docs/drivers/TwinCAT-Test-Fixture.md` § Follow-up candidates. Archived from `docs/drivers/TwinCAT-Test-Fixture.md` § Follow-up candidates. Refreshed 2026-10-08
(Gitea #612 / #645); live-validation results live in `docs/drivers/TwinCAT.md` § "Live validation".
## Deferred items ## Hardware available (as of 2026-10-08)
1. **TC2 coverage** — spin up a TC2 runtime (Windows CE IPC or legacy XAR) The Elma cleaner fleet (25 presses, 23 live) is the only real TwinCAT hardware in reach, and it is
and run the same suite; any delta surfaces. Blocked on hardware. **production — strictly read-only**:
2. **Notification coalescing under load** — run the subscribe test while | Press | Runtime | AMS | Notes |
the PLC cycle is saturated (bump `lineSim` complexity, watch for |---|---|---|---|
dropped notifications). Doable on current rig; deferred as lower | 22 presses (e.g. Z33856 `10.201.45.236.1.1`, Z33733 `5.25.53.46.1.1`) | **TC2 2.11 on WinCE 6.0** | port **801** | read-validated 2026-10-07 |
priority than v3 feature work. | **Z35474** (`5.118.56.93.1.1`, 10.201.23.227) | **TC3 3.1.4024** (Win NT 10) | port **851** | the **only TC3 press**; "Plc30 App" |
| TCBSD fixture `10.100.0.128` (`41.169.163.43.1.1:851`) | TC3 | 851 | not a press; unreachable from the Mac |
3. **Multi-hop AMS route** — add a test behind an IPC gateway with a Access facts: ADS is TCP 48898. The driver sends no AddRoute (routes are provisioned out of band) and has
chained route entry. Blocked on hardware (gateway IPC). 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.
4. **License-rotation automation** — XAR's 7-day trial expires on ## Status of the original items
schedule. Either automate `TcActivate.exe /reactivate` via a scheduled
task on the VM (not officially supported; reportedly works for some
TC3 builds), or buy a paid runtime license (~$1k one-time per runtime
per CPU) to kill the rotation. Ops item, not code.
5. **Lab rig** — cheapest IPC (CX7000 / CX9020) on a dedicated network; 1. **TC2 coverage — DONE (read side).** No longer "blocked on hardware": the 2026-10-07 Elma run
the only route that covers TC2 + real EtherCAT I/O timing + cycle (TwinCAT 2.11) verified browse, scalar/array/string reads and native notifications on TC2. What is
jitter under CPU load. Blocked on hardware + budget. still unverified there: sum-read support (see item 6) and anything that writes.
2. **Notification coalescing under load** — run the subscribe test while the PLC cycle is saturated.
Needs the TCBSD fixture (loading a production press is out of the question) and the `GVL_Perf` /
`FB_PerfChurn` fixture project from the stranded `auto/driver-gaps` branch. Fixture-gated.
3. **Multi-hop AMS route** — a test behind an IPC gateway with a chained route entry. No hardware.
4. **License-rotation automation** — XAR's 7-day trial expires on schedule. Ops item, not code.
5. **Lab rig** — cheapest IPC on a dedicated network; the only route that covers real EtherCAT I/O
timing and cycle jitter under CPU load. Blocked on hardware + budget.
## 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
(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
`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
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.)
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.
@@ -61,6 +61,22 @@ public sealed class TwinCATDriverOptions
/// </summary> /// </summary>
public int NotificationMaxDelayMs { get; init; } public int NotificationMaxDelayMs { get; init; }
/// <summary>
/// When <c>true</c>, a poll's reads for one device are batched into ADS sum-read commands
/// (IndexGroup <c>0xF080</c>) of up to <see cref="SumReadMaxItems"/> symbols each instead of one
/// round-trip per tag (Gitea #645 / #310). Default <c>false</c> (opt-in): a TwinCAT 2 CE runtime's
/// sum-command support is unverified. A target that refuses the sum command is detected at runtime
/// and that device falls back to individual reads for the rest of the driver's life.
/// </summary>
public bool UseSumRead { get; init; }
/// <summary>
/// Maximum symbols per ADS sum-read command; larger polls are split into chunks of this size.
/// Bounds the request/response size against the target's ADS buffer. Values below 1 are treated as 1.
/// Only consulted when <see cref="UseSumRead"/> is <c>true</c>.
/// </summary>
public int SumReadMaxItems { get; init; } = 200;
/// <summary> /// <summary>
/// Timeout for the AdminUI Test Connect probe, in seconds. The AdminUI clamps to a /// Timeout for the AdminUI Test Connect probe, in seconds. The AdminUI clamps to a
/// 60s server-side maximum; this default is what the form pre-fills for new instances. /// 60s server-side maximum; this default is what the form pre-fills for new instances.
@@ -3,6 +3,7 @@ using System.Runtime.CompilerServices;
using System.Threading.Channels; using System.Threading.Channels;
using TwinCAT; using TwinCAT;
using TwinCAT.Ads; using TwinCAT.Ads;
using TwinCAT.Ads.SumCommand;
using TwinCAT.Ads.TypeSystem; using TwinCAT.Ads.TypeSystem;
using TwinCAT.TypeSystem; using TwinCAT.TypeSystem;
@@ -166,6 +167,151 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
} }
} }
/// <inheritdoc />
public async Task<IReadOnlyList<(object? value, uint status)>> ReadValuesAsync(
IReadOnlyList<TwinCATBulkReadItem> reads, CancellationToken cancellationToken)
{
var results = new (object? value, uint status)[reads.Count];
var planned = new List<PlannedSumRead>(reads.Count);
// Plan every item exactly as the single read would (symbol resolved from the Beckhoff symbol cache,
// declared type checked against it), but stop short of the wire read: those become ONE sum command.
// Anything the sum command cannot carry is read on its own right here.
for (var i = 0; i < reads.Count; i++)
{
var r = reads[i];
try
{
if (r.BitIndex is not null && r.Type == TwinCATDataType.Bool)
{
results[i] = await ReadValueAsync(r.SymbolPath, r.Type, r.BitIndex, r.ArrayCount, cancellationToken)
.ConfigureAwait(false);
continue;
}
var (shape, array, symbol, symbolStatus) = await DescribeSymbolAsync(r.SymbolPath, cancellationToken)
.ConfigureAwait(false);
if (symbolStatus != TwinCATStatusMapper.Good) { results[i] = (null, symbolStatus); continue; }
TwinCATArrayLayout layout;
if (r.ArrayCount is int count)
{
if (TwinCATTypeGuard.CheckArray(r.Type, count, shape, array, out layout) is not null)
{
results[i] = (null, TwinCATStatusMapper.BadTypeMismatch);
continue;
}
}
else
{
if (TwinCATTypeGuard.CheckRead(r.Type, shape) is not null)
{
results[i] = (null, TwinCATStatusMapper.BadTypeMismatch);
continue;
}
// A scalar is a one-element "array" at the type's own width (a STRING at the PLC's declared
// width), so scalar and array share one raw decode.
layout = new TwinCATArrayLayout(1, r.Type is TwinCATDataType.String or TwinCATDataType.WString
? shape.ByteSize
: TwinCATTypeGuard.DeclaredByteSize(r.Type));
}
// A handle-addressed symbol (0xF005) needs a fresh variable handle per read — not expressible
// as an index-group/offset sum entry. Read it individually.
if (symbol!.IndexGroup == SymbolValueByHandle)
{
results[i] = await ReadValueAsync(r.SymbolPath, r.Type, r.BitIndex, r.ArrayCount, cancellationToken)
.ConfigureAwait(false);
continue;
}
planned.Add(new PlannedSumRead(i, symbol.IndexGroup, symbol.IndexOffset, layout, r.ArrayCount is not null));
}
catch (AdsErrorException ex)
{
results[i] = (null, MapAndSignal((uint)ex.ErrorCode));
}
}
if (planned.Count == 0) return results;
ResultSumReadRaw sum;
try
{
var entities = new List<IgIoSumReadEntity>(planned.Count);
foreach (var p in planned)
entities.Add(new IgIoSumReadEntity(p.IndexGroup, p.IndexOffset, p.Layout.ByteLength));
sum = await new SumRead(_client, entities, SumCommandErrorStrategy.Relaxed)
.ReadRawAsync(cancellationToken).ConfigureAwait(false);
}
catch (AdsErrorException ex) when (IsSumRefusal((uint)ex.ErrorCode))
{
throw new TwinCATSumReadRefusedException((uint)ex.ErrorCode, ex);
}
catch (AdsSumCommandException)
{
// The library gave up on the batch without an overall ADS code (a sub-item failure under its
// strict handling). Not a refusal of the service: read these items one by one this time.
foreach (var p in planned)
results[p.Index] = await ReadValueAsync(reads[p.Index].SymbolPath, reads[p.Index].Type,
reads[p.Index].BitIndex, reads[p.Index].ArrayCount, cancellationToken).ConfigureAwait(false);
return results;
}
// The overall code is the sum command itself; per-item codes are in SubErrors.
if (sum.ErrorCode != AdsErrorCode.NoError)
{
var overall = (uint)sum.ErrorCode;
if (IsSumRefusal(overall))
throw new TwinCATSumReadRefusedException(overall);
var mapped = MapAndSignal(overall);
foreach (var p in planned) results[p.Index] = (null, mapped);
return results;
}
for (var k = 0; k < planned.Count; k++)
{
var p = planned[k];
var subError = sum.SubErrors is { } errs && k < errs.Length ? errs[k] : AdsErrorCode.NoError;
if (subError != AdsErrorCode.NoError)
{
results[p.Index] = (null, MapAndSignal((uint)subError));
continue;
}
if (sum.ReadBlocks is not { } blocks || k >= blocks.Count || blocks[k].Length < p.Layout.ByteLength)
{
// A short block would decode garbage; treat it as a size mismatch, not a value.
results[p.Index] = (null, TwinCATStatusMapper.MapAdsError(1797));
continue;
}
var decoded = TwinCATArrayCodec.Decode(reads[p.Index].Type, sum.ReadBlocks![k].Span, p.Layout, StringsAreUtf8());
results[p.Index] = (p.IsArray ? decoded : decoded.GetValue(0), TwinCATStatusMapper.Good);
}
return results;
}
private const uint SymbolValueByHandle = 0xF005;
private readonly record struct PlannedSumRead(int Index, uint IndexGroup, uint IndexOffset, TwinCATArrayLayout Layout, bool IsArray);
/// <summary>
/// True for an overall ADS code that means "this target does not do sum commands" (service / index
/// group / offset / size not supported) rather than a comms fault.
/// </summary>
internal static bool IsSumRefusal(uint adsError) => adsError is 1792 or 1793 or 1794 or 1795 or 1797 or 1798;
/// <inheritdoc />
public async Task<string?> ReadAdsStateAsync(CancellationToken cancellationToken)
{
try
{
var state = await _client.ReadStateAsync(cancellationToken).ConfigureAwait(false);
return state.ErrorCode == AdsErrorCode.NoError ? state.State.AdsState.ToString() : null;
}
catch (OperationCanceledException) { throw; }
catch { return null; }
}
/// <summary> /// <summary>
/// Resolves the PLC symbol behind <paramref name="symbolPath"/> into the facts /// Resolves the PLC symbol behind <paramref name="symbolPath"/> into the facts
/// <see cref="TwinCATTypeGuard"/> compares a declared type against. Uses the Beckhoff symbol /// <see cref="TwinCATTypeGuard"/> compares a declared type against. Uses the Beckhoff symbol
@@ -55,6 +55,45 @@ public interface ITwinCATClient : IDisposable
int? arrayCount, int? arrayCount,
CancellationToken cancellationToken); CancellationToken cancellationToken);
/// <summary>
/// Reads several symbols at once — the ADS sum-read path (Gitea #645 / #310). The result is a parallel
/// list: element <c>i</c> is the outcome of <paramref name="reads"/>[<c>i</c>], each decoded and
/// status-mapped exactly as <see cref="ReadValueAsync"/> would (an item the sum command cannot carry —
/// a bit-indexed BOOL, a handle-addressed symbol — is read individually inside the call).
/// </summary>
/// <remarks>
/// The default implementation reads one item at a time, so a client without a sum path (or a test
/// fake) is correct if not faster. An implementation that issues a real sum command throws
/// <see cref="TwinCATSumReadRefusedException"/> when the TARGET refuses the command as a whole
/// (service / index group not supported); the caller then falls back to individual reads. A
/// comms-level failure propagates as the ordinary exception.
/// </remarks>
/// <param name="reads">The symbols to read, at most the caller's chunk size.</param>
/// <param name="cancellationToken">Cancellation token for the read.</param>
/// <returns>One <c>(value, status)</c> per item, in request order.</returns>
/// <exception cref="TwinCATSumReadRefusedException">The target refused the sum command itself.</exception>
async Task<IReadOnlyList<(object? value, uint status)>> ReadValuesAsync(
IReadOnlyList<TwinCATBulkReadItem> reads, CancellationToken cancellationToken)
{
var results = new (object? value, uint status)[reads.Count];
for (var i = 0; i < reads.Count; i++)
{
var r = reads[i];
results[i] = await ReadValueAsync(r.SymbolPath, r.Type, r.BitIndex, r.ArrayCount, cancellationToken)
.ConfigureAwait(false);
}
return results;
}
/// <summary>
/// The target's ADS device state (<c>Run</c>, <c>Stop</c>, <c>Config</c>, …) as text, or <c>null</c>
/// when it cannot be read. Feeds the driver's <c>/hosts</c> diagnostics (Gitea #645 / #314).
/// Default <c>null</c> — a client without a state read simply reports none.
/// </summary>
/// <param name="cancellationToken">Cancellation token for the read.</param>
/// <returns>The state name, or <c>null</c>.</returns>
Task<string?> ReadAdsStateAsync(CancellationToken cancellationToken) => Task.FromResult<string?>(null);
/// <summary> /// <summary>
/// Write a symbolic value. Returns the mapped OPC UA status for the operation /// Write a symbolic value. Returns the mapped OPC UA status for the operation
/// (0 = Good, non-zero = error mapped via <see cref="TwinCATStatusMapper"/>). /// (0 = Good, non-zero = error mapped via <see cref="TwinCATStatusMapper"/>).
@@ -132,6 +171,18 @@ public interface ITwinCATClient : IDisposable
IAsyncEnumerable<TwinCATDiscoveredSymbol> BrowseSymbolsAsync(CancellationToken cancellationToken); IAsyncEnumerable<TwinCATDiscoveredSymbol> BrowseSymbolsAsync(CancellationToken cancellationToken);
} }
/// <summary>One element of an <see cref="ITwinCATClient.ReadValuesAsync"/> request — the same four facts
/// <see cref="ITwinCATClient.ReadValueAsync"/> takes.</summary>
/// <param name="SymbolPath">The ADS symbol path (a bit-indexed BOOL keeps its <c>.N</c> suffix, as for a single read).</param>
/// <param name="Type">The declared data type.</param>
/// <param name="BitIndex">The bit to extract for a BOOL-within-word tag; null otherwise.</param>
/// <param name="ArrayCount">The authored array length for an array tag; null for a scalar.</param>
public sealed record TwinCATBulkReadItem(
string SymbolPath,
TwinCATDataType Type,
int? BitIndex = null,
int? ArrayCount = null);
/// <summary>Opaque handle for a registered ADS notification. <see cref="IDisposable.Dispose"/> tears it down.</summary> /// <summary>Opaque handle for a registered ADS notification. <see cref="IDisposable.Dispose"/> tears it down.</summary>
public interface ITwinCATNotificationHandle : IDisposable { } public interface ITwinCATNotificationHandle : IDisposable { }
@@ -0,0 +1,25 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// <summary>
/// One device's probe-sampled PLC facts (Gitea #645 / #314), surfaced through the driver's
/// <see cref="Core.Abstractions.IDriverDiagnostics"/> bag to the <c>/hosts</c> page. Every field is
/// best-effort: a runtime that does not export a system symbol leaves that field at its last known value
/// (null if never seen).
/// </summary>
/// <param name="AdsState">The ADS device state name (<c>Run</c>, <c>Stop</c>, <c>Config</c>, …).</param>
/// <param name="AppName">The running PLC application's name (<c>_AppInfo.AppName</c>).</param>
/// <param name="OnlineChangeCnt">How many online changes the application has taken (<c>_AppInfo.OnlineChangeCnt</c>).</param>
/// <param name="CycleTimeMs">The first PLC task's configured cycle time in milliseconds.</param>
/// <param name="LastExecTimeMs">How long the task's last cycle took to execute, in milliseconds.</param>
/// <param name="UpdatedUtc">When the snapshot was taken.</param>
internal sealed record TwinCATDeviceDiagnostics(
string? AdsState,
string? AppName,
uint? OnlineChangeCnt,
double? CycleTimeMs,
double? LastExecTimeMs,
DateTime UpdatedUtc)
{
/// <summary>Gets last execution time minus cycle time: positive means the task overran its cycle.</summary>
public double? JitterMs => CycleTimeMs is { } c && LastExecTimeMs is { } x ? x - c : null;
}
@@ -12,7 +12,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// resolver land in PRs 2 and 3. /// resolver land in PRs 2 and 3.
/// </summary> /// </summary>
public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable, public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
IHostConnectivityProbe, IPerCallHostResolver, IRediscoverable, ISkippedTagReporter, IDisposable, IAsyncDisposable IHostConnectivityProbe, IPerCallHostResolver, IRediscoverable, ISkippedTagReporter, IDriverDiagnostics,
IDisposable, IAsyncDisposable
{ {
/// <summary>Authored tags this driver could not build into its poll table (Gitea #541).</summary> /// <summary>Authored tags this driver could not build into its poll table (Gitea #541).</summary>
private readonly SkippedTagLedger _skippedTags = new(); private readonly SkippedTagLedger _skippedTags = new();
@@ -271,6 +272,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var results = new DataValueSnapshot[fullReferences.Count]; var results = new DataValueSnapshot[fullReferences.Count];
// #645/#310: with UseSumRead the reads for one device are gathered and issued as ADS sum-reads
// after this pass; everything else (and a device that refused the sum command) reads one by one.
Dictionary<DeviceState, List<SumReadEntry>>? buckets = null;
for (var i = 0; i < fullReferences.Count; i++) for (var i = 0; i < fullReferences.Count; i++)
{ {
var reference = fullReferences[i]; var reference = fullReferences[i];
@@ -285,6 +290,34 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
continue; continue;
} }
if (_options.UseSumRead && !device.SumReadUnsupported)
{
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
buckets ??= [];
if (!buckets.TryGetValue(device, out var bucket))
buckets[device] = bucket = [];
bucket.Add(new SumReadEntry(i, reference, def, symbolName, parsed?.BitIndex));
continue;
}
results[i] = await ReadOneAsync(reference, def, device, now, cancellationToken).ConfigureAwait(false);
}
if (buckets is not null)
foreach (var (device, bucket) in buckets)
await ReadBucketAsync(device, bucket, results, now, cancellationToken).ConfigureAwait(false);
return results;
}
private readonly record struct SumReadEntry(
int Index, string Reference, TwinCATTagDefinition Def, string SymbolName, int? BitIndex);
/// <summary>One tag read on its own — the pre-sum-read path, also the fallback for a refused sum command.</summary>
private async Task<DataValueSnapshot> ReadOneAsync(
string reference, TwinCATTagDefinition def, DeviceState device, DateTime now, CancellationToken cancellationToken)
{
try try
{ {
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false); var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
@@ -297,12 +330,26 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
var (value, status) = await client.ReadValueAsync( var (value, status) = await client.ReadValueAsync(
symbolName, def.DataType, parsed?.BitIndex, def.ArrayLength, cancellationToken) symbolName, def.DataType, parsed?.BitIndex, def.ArrayLength, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
return CompleteRead(reference, def, symbolName, value, status, now);
results[i] = new DataValueSnapshot(value, status, now, now); }
if (status == TwinCATStatusMapper.Good) catch (OperationCanceledException) { throw; }
_health = new DriverHealth(DriverState.Healthy, now, null); catch (Exception ex)
else
{ {
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
return new DataValueSnapshot(null, TwinCATStatusMapper.BadCommunicationError, null, now);
}
}
/// <summary>Maps one read outcome to a snapshot and applies the health / log side effects.</summary>
private DataValueSnapshot CompleteRead(
string reference, TwinCATTagDefinition def, string symbolName, object? value, uint status, DateTime now)
{
if (status == TwinCATStatusMapper.Good)
{
_health = new DriverHealth(DriverState.Healthy, now, null);
return new DataValueSnapshot(value, status, now, now);
}
if (status == TwinCATStatusMapper.BadTypeMismatch) if (status == TwinCATStatusMapper.BadTypeMismatch)
_logger.LogWarning( _logger.LogWarning(
"TwinCAT driver '{DriverInstanceId}' refused '{Reference}': declared {DataType} " + "TwinCAT driver '{DriverInstanceId}' refused '{Reference}': declared {DataType} " +
@@ -315,17 +362,63 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
_driverInstanceId, status, reference); _driverInstanceId, status, reference);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"ADS status {status:X8} reading {reference}"); $"ADS status {status:X8} reading {reference}");
return new DataValueSnapshot(value, status, now, now);
}
/// <summary>
/// Reads one device's gathered tags as ADS sum-reads of at most <c>SumReadMaxItems</c> symbols each.
/// A target that refuses the sum command flips <see cref="DeviceState.SumReadUnsupported"/> and the
/// rest of the bucket is read individually; any other failure marks only the affected chunk Bad.
/// </summary>
private async Task ReadBucketAsync(
DeviceState device, List<SumReadEntry> bucket, DataValueSnapshot[] results, DateTime now,
CancellationToken cancellationToken)
{
var chunkSize = Math.Max(1, _options.SumReadMaxItems);
for (var start = 0; start < bucket.Count; start += chunkSize)
{
var chunk = bucket.GetRange(start, Math.Min(chunkSize, bucket.Count - start));
if (device.SumReadUnsupported || chunk.Count == 1)
{
foreach (var e in chunk)
results[e.Index] = await ReadOneAsync(e.Reference, e.Def, device, now, cancellationToken).ConfigureAwait(false);
continue;
}
try
{
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
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);
var bulk = await client.ReadValuesAsync(items, cancellationToken).ConfigureAwait(false);
for (var k = 0; k < chunk.Count; k++)
{
var e = chunk[k];
var (value, status) = k < bulk.Count ? bulk[k] : (null, TwinCATStatusMapper.BadCommunicationError);
results[e.Index] = CompleteRead(e.Reference, e.Def, e.SymbolName, value, status, now);
} }
} }
catch (OperationCanceledException) { throw; } catch (OperationCanceledException) { throw; }
catch (TwinCATSumReadRefusedException refused)
{
device.SumReadUnsupported = true;
_logger.LogWarning(
"TwinCAT driver '{DriverInstanceId}' device '{Device}' refused the ADS sum-read command " +
"(ADS error {AdsError}); falling back to individual reads for this device",
_driverInstanceId, device.Options.HostAddress, refused.AdsErrorCode);
foreach (var e in chunk)
results[e.Index] = await ReadOneAsync(e.Reference, e.Def, device, now, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) catch (Exception ex)
{ {
results[i] = new DataValueSnapshot(null, TwinCATStatusMapper.BadCommunicationError, null, now); foreach (var e in chunk)
results[e.Index] = new DataValueSnapshot(null, TwinCATStatusMapper.BadCommunicationError, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
} }
} }
return results;
} }
// ---- IWritable ---- // ---- IWritable ----
@@ -793,7 +886,11 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
else else
// 05/STAB-16: on a healthy tick, re-register any notifications whose replay // 05/STAB-16: on a healthy tick, re-register any notifications whose replay
// failed (a transient AddNotification blip) so they recover without a client swap. // failed (a transient AddNotification blip) so they recover without a client swap.
{
await RetryDeadRegistrationsAsync(state, ct).ConfigureAwait(false); await RetryDeadRegistrationsAsync(state, ct).ConfigureAwait(false);
// #645/#314: sample the PLC state / task-cycle facts for /hosts. Best-effort.
await SampleDeviceDiagnosticsAsync(state, client, ct).ConfigureAwait(false);
}
} }
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
catch catch
@@ -813,6 +910,114 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
} }
} }
// ---- IDriverDiagnostics (#645 / #314) ----
// Well-known system symbols, exported by TC3 (and most TC2) PLC runtimes under TwinCAT_SystemInfoVarList.
// _TaskInfo[1] is the first PLC task; CycleTime / LastExecTime are UDINT counts of 100 ns.
internal const string SymOnlineChangeCnt = "TwinCAT_SystemInfoVarList._AppInfo.OnlineChangeCnt";
internal const string SymAppName = "TwinCAT_SystemInfoVarList._AppInfo.AppName";
internal const string SymCycleTime = "TwinCAT_SystemInfoVarList._TaskInfo[1].CycleTime";
internal const string SymLastExecTime = "TwinCAT_SystemInfoVarList._TaskInfo[1].LastExecTime";
/// <summary>Converts a TwinCAT 100 ns tick count to milliseconds.</summary>
internal static double TicksToMilliseconds(uint ticks100ns) => ticks100ns / 10_000.0;
/// <summary>
/// Most recent <see cref="TwinCATDeviceDiagnostics"/> for a device, or null before its first
/// successful probe sample (or for an unknown host address).
/// </summary>
/// <param name="hostAddress">The device's ADS host address.</param>
/// <returns>The snapshot, or null.</returns>
internal TwinCATDeviceDiagnostics? GetDeviceDiagnostics(string hostAddress) =>
_devices.TryGetValue(hostAddress, out var s) ? s.LastDiagnostics : null;
/// <summary>
/// Samples the ADS device state and the four system symbols once per healthy probe tick and stores the
/// result on <see cref="DeviceState.LastDiagnostics"/>. Every read is individually best-effort: a
/// runtime that does not export a symbol (older TwinCAT 2 builds) keeps the previous value, and a
/// symbol that answers "not found" is not asked again until the client is rebuilt. Never throws
/// (except cancellation) — diagnostics must not disturb the probe.
/// </summary>
internal async Task SampleDeviceDiagnosticsAsync(DeviceState state, ITwinCATClient client, CancellationToken ct)
{
try
{
var prev = state.LastDiagnostics;
var adsState = prev?.AdsState;
try { adsState = await client.ReadAdsStateAsync(ct).ConfigureAwait(false) ?? adsState; }
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch { /* keep previous */ }
var appName = await ReadDiagSymbolAsync(state, client, SymAppName, TwinCATDataType.String, ct).ConfigureAwait(false) as string
?? prev?.AppName;
var onlineChange = ToUInt(await ReadDiagSymbolAsync(state, client, SymOnlineChangeCnt, TwinCATDataType.UDInt, 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 cycleMs = cycleTicks is { } c ? TicksToMilliseconds(c) : prev?.CycleTimeMs;
var execMs = execTicks is { } x ? TicksToMilliseconds(x) : prev?.LastExecTimeMs;
state.LastDiagnostics = new TwinCATDeviceDiagnostics(adsState, appName, onlineChange, cycleMs, execMs, DateTime.UtcNow);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch { /* best-effort */ }
}
private static uint? ToUInt(object? v) => v switch
{
uint u => u,
null => null,
_ => Convert.ToUInt32(v, System.Globalization.CultureInfo.InvariantCulture),
};
private async Task<object?> ReadDiagSymbolAsync(
DeviceState state, ITwinCATClient client, string symbol, TwinCATDataType type, CancellationToken ct)
{
if (state.UnavailableDiagSymbols.ContainsKey(symbol)) return null;
try
{
var (value, status) = await client.ReadValueAsync(symbol, type, null, null, ct).ConfigureAwait(false);
if (status == TwinCATStatusMapper.Good) return value;
if (status == TwinCATStatusMapper.BadNodeIdUnknown)
state.UnavailableDiagSymbols[symbol] = 0;
return null;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch { return null; }
}
/// <summary>
/// The <c>/hosts</c> diagnostics bag (Gitea #640 channel): per device, ADS state, PLC app name, online
/// change count, task cycle time, last execution time and jitter (last exec − cycle). A single-device
/// driver uses flat <c>TwinCAT.*</c> keys; several devices prefix the device name (or host address).
/// A pure in-memory read — the probe loop does the sampling. The host bounds the bag
/// (<see cref="DriverDiagnostics.MaxEntries"/>), so a many-device driver keeps the first keys in
/// ordinal order.
/// </summary>
/// <returns>The key/value pairs; empty before the first sample.</returns>
IReadOnlyDictionary<string, string> IDriverDiagnostics.GetDiagnostics()
{
var bag = new Dictionary<string, string>(StringComparer.Ordinal);
var multi = _devices.Count > 1;
foreach (var state in _devices.Values)
{
if (state.LastDiagnostics is not { } d) continue;
var prefix = multi ? $"TwinCAT.{state.Options.DeviceName ?? state.Options.HostAddress}." : "TwinCAT.";
if (d.AdsState is not null) bag[prefix + "AdsState"] = d.AdsState;
if (!string.IsNullOrEmpty(d.AppName)) bag[prefix + "AppName"] = d.AppName;
if (d.OnlineChangeCnt is { } oc) bag[prefix + "OnlineChangeCnt"] = oc.ToString(System.Globalization.CultureInfo.InvariantCulture);
if (d.CycleTimeMs is { } cy) bag[prefix + "CycleTimeMs"] = FormatMs(cy);
if (d.LastExecTimeMs is { } ex) bag[prefix + "LastExecTimeMs"] = FormatMs(ex);
if (d.JitterMs is { } j) bag[prefix + "JitterMs"] = FormatMs(j);
}
return bag;
}
// 0.1 ms resolution: the bag is published on a heartbeat and its digest is in the publish fingerprint,
// so sub-0.1 ms wobble should not read as a change.
private static string FormatMs(double ms) =>
Math.Round(ms, 1, MidpointRounding.AwayFromZero).ToString("0.0", System.Globalization.CultureInfo.InvariantCulture);
private void TransitionDeviceState(DeviceState state, HostState newState) private void TransitionDeviceState(DeviceState state, HostState newState)
{ {
HostState old; HostState old;
@@ -1234,10 +1439,22 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
public Task? ProbeTask { get; set; } public Task? ProbeTask { get; set; }
/// <summary>Disposes the active ADS client if any.</summary> /// <summary>Disposes the active ADS client if any.</summary>
/// <summary>Set when the target refused the ADS sum-read command (#645); the device reads one tag at a
/// time from then on. Cleared only by a fresh <see cref="DeviceState"/> (driver re-initialise).</summary>
public volatile bool SumReadUnsupported;
/// <summary>Latest probe-sampled PLC diagnostics (#645 / #314); null before the first sample.</summary>
public TwinCATDeviceDiagnostics? LastDiagnostics { get; set; }
/// <summary>Diagnostic system symbols the target answered "not found" for — not re-asked until the client is rebuilt.</summary>
public ConcurrentDictionary<string, byte> UnavailableDiagSymbols { get; } = new(StringComparer.Ordinal);
public void DisposeClient() public void DisposeClient()
{ {
Client?.Dispose(); Client?.Dispose();
Client = null; Client = null;
// A rebuilt client may face a PLC that now exports the symbols (e.g. after a program download).
UnavailableDiagSymbols.Clear();
} }
/// <summary>Disposes the connection gate semaphore.</summary> /// <summary>Disposes the connection gate semaphore.</summary>
@@ -78,6 +78,8 @@ public static class TwinCATDriverFactoryExtensions
UseNativeNotifications = dto.UseNativeNotifications ?? true, UseNativeNotifications = dto.UseNativeNotifications ?? true,
EnableControllerBrowse = dto.EnableControllerBrowse ?? false, EnableControllerBrowse = dto.EnableControllerBrowse ?? false,
NotificationMaxDelayMs = dto.NotificationMaxDelayMs ?? 0, NotificationMaxDelayMs = dto.NotificationMaxDelayMs ?? 0,
UseSumRead = dto.UseSumRead ?? false,
SumReadMaxItems = dto.SumReadMaxItems ?? 200,
}; };
} }
@@ -114,6 +116,12 @@ public static class TwinCATDriverFactoryExtensions
/// <summary>Gets or sets the maximum notification delay in milliseconds.</summary> /// <summary>Gets or sets the maximum notification delay in milliseconds.</summary>
public int? NotificationMaxDelayMs { get; init; } public int? NotificationMaxDelayMs { get; init; }
/// <summary>Gets or sets a value indicating whether a poll's reads are batched into ADS sum-reads.</summary>
public bool? UseSumRead { get; init; }
/// <summary>Gets or sets the maximum symbols per sum-read command.</summary>
public int? SumReadMaxItems { get; init; }
/// <summary>Gets or sets the list of configured devices.</summary> /// <summary>Gets or sets the list of configured devices.</summary>
public List<TwinCATDeviceDto>? Devices { get; init; } public List<TwinCATDeviceDto>? Devices { get; init; }
@@ -0,0 +1,21 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// <summary>
/// Thrown by <see cref="ITwinCATClient.ReadValuesAsync"/> when the TARGET refuses the ADS sum-read
/// command as a whole (service or index group not supported). It is not a comms fault: the driver marks
/// the device "no sum-read" and re-reads the batch with individual reads, so the poll still completes.
/// </summary>
internal sealed class TwinCATSumReadRefusedException : Exception
{
/// <summary>Initializes a new instance of the <see cref="TwinCATSumReadRefusedException"/> class.</summary>
/// <param name="adsErrorCode">The overall ADS error code the target answered with.</param>
/// <param name="inner">The underlying exception, if any.</param>
public TwinCATSumReadRefusedException(uint adsErrorCode, Exception? inner = null)
: base($"ADS sum-read refused by the target (ADS error {adsErrorCode})", inner)
{
AdsErrorCode = adsErrorCode;
}
/// <summary>Gets the overall ADS error code.</summary>
public uint AdsErrorCode { get; }
}
@@ -43,6 +43,20 @@
class="form-control form-control-sm" /> class="form-control form-control-sm" />
<div class="form-text">0 = push immediately. Increase to coalesce high-churn signals.</div> <div class="form-text">0 = push immediately. Increase to coalesce high-churn signals.</div>
</div> </div>
<div class="col-md-4 mb-3">
<div class="form-check form-switch mt-4">
<InputCheckbox id="tcSumRead" @bind-Value="_form.UseSumRead" @bind-Value:after="EmitAsync"
class="form-check-input" />
<label class="form-check-label" for="tcSumRead">Batch polled reads (ADS sum-read)</label>
</div>
<div class="form-text">One round-trip per chunk instead of per tag. Falls back to single reads if the target refuses it. Default off.</div>
</div>
<div class="col-md-4 mb-3">
<label class="form-label" for="tcSumReadMax">Sum-read max items</label>
<InputNumber id="tcSumReadMax" @bind-Value="_form.SumReadMaxItems" @bind-Value:after="EmitAsync"
class="form-control form-control-sm" />
<div class="form-text">Symbols per sum-read command (default 200).</div>
</div>
</div> </div>
</div> </div>
</section> </section>
@@ -151,6 +165,8 @@
public bool UseNativeNotifications { get; set; } = true; public bool UseNativeNotifications { get; set; } = true;
public bool EnableControllerBrowse { get; set; } = false; public bool EnableControllerBrowse { get; set; } = false;
public int NotificationMaxDelayMs { get; set; } = 0; public int NotificationMaxDelayMs { get; set; } = 0;
public bool UseSumRead { get; set; } = false;
public int SumReadMaxItems { get; set; } = 200;
// Probe // Probe
public bool ProbeEnabled { get; set; } = true; public bool ProbeEnabled { get; set; } = true;
@@ -168,6 +184,8 @@
UseNativeNotifications = o.UseNativeNotifications, UseNativeNotifications = o.UseNativeNotifications,
EnableControllerBrowse = o.EnableControllerBrowse, EnableControllerBrowse = o.EnableControllerBrowse,
NotificationMaxDelayMs = o.NotificationMaxDelayMs, NotificationMaxDelayMs = o.NotificationMaxDelayMs,
UseSumRead = o.UseSumRead,
SumReadMaxItems = o.SumReadMaxItems,
ProbeEnabled = o.Probe.Enabled, ProbeEnabled = o.Probe.Enabled,
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds, ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds, ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
@@ -181,6 +199,8 @@
UseNativeNotifications = UseNativeNotifications, UseNativeNotifications = UseNativeNotifications,
EnableControllerBrowse = EnableControllerBrowse, EnableControllerBrowse = EnableControllerBrowse,
NotificationMaxDelayMs = NotificationMaxDelayMs, NotificationMaxDelayMs = NotificationMaxDelayMs,
UseSumRead = UseSumRead,
SumReadMaxItems = SumReadMaxItems,
Probe = new TwinCATProbeOptions Probe = new TwinCATProbeOptions
{ {
Enabled = ProbeEnabled, Enabled = ProbeEnabled,
@@ -71,12 +71,49 @@ internal class FakeTwinCATClient : ITwinCATClient
string symbolPath, TwinCATDataType type, int? bitIndex, int? arrayCount, CancellationToken ct) string symbolPath, TwinCATDataType type, int? bitIndex, int? arrayCount, CancellationToken ct)
{ {
if (ThrowOnRead) throw Exception ?? new InvalidOperationException(); if (ThrowOnRead) throw Exception ?? new InvalidOperationException();
SingleReadCount++;
LastReadArrayCount = arrayCount; LastReadArrayCount = arrayCount;
var status = ReadStatuses.TryGetValue(symbolPath, out var s) ? s : TwinCATStatusMapper.Good; var status = ReadStatuses.TryGetValue(symbolPath, out var s) ? s : TwinCATStatusMapper.Good;
var value = Values.TryGetValue(symbolPath, out var v) ? v : null; var value = Values.TryGetValue(symbolPath, out var v) ? v : null;
return Task.FromResult((value, status)); return Task.FromResult((value, status));
} }
/// <summary>Gets the number of individual <see cref="ReadValueAsync"/> calls served.</summary>
public int SingleReadCount { get; private set; }
/// <summary>Gets the size of each <see cref="ReadValuesAsync"/> batch the driver issued, in order (#645).</summary>
public List<int> SumReadBatches { get; } = new();
/// <summary>Gets or sets an exception <see cref="ReadValuesAsync"/> throws instead of answering — a
/// <see cref="TwinCATSumReadRefusedException"/> models a target without sum commands.</summary>
public Exception? SumReadException { get; set; }
/// <summary>Gets or sets the ADS state text <see cref="ReadAdsStateAsync"/> returns.</summary>
public string? AdsStateText { get; set; }
/// <summary>Simulates an ADS sum-read: records the batch size, then answers each item from the same
/// tables the single read uses.</summary>
/// <param name="reads">The batch.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>One outcome per item.</returns>
public virtual Task<IReadOnlyList<(object? value, uint status)>> ReadValuesAsync(
IReadOnlyList<TwinCATBulkReadItem> reads, CancellationToken ct)
{
if (ThrowOnRead) throw Exception ?? new InvalidOperationException();
SumReadBatches.Add(reads.Count);
if (SumReadException is { } ex) throw ex;
var results = new (object? value, uint status)[reads.Count];
for (var i = 0; i < reads.Count; i++)
{
var path = reads[i].SymbolPath;
results[i] = (Values.TryGetValue(path, out var v) ? v : null,
ReadStatuses.TryGetValue(path, out var s) ? s : TwinCATStatusMapper.Good);
}
return Task.FromResult<IReadOnlyList<(object? value, uint status)>>(results);
}
/// <summary>Simulates reading the ADS device state.</summary>
/// <param name="ct">The cancellation token.</param>
/// <returns>The configured state text.</returns>
public virtual Task<string?> ReadAdsStateAsync(CancellationToken ct) => Task.FromResult(AdsStateText);
/// <summary>Simulates writing a value to a symbol.</summary> /// <summary>Simulates writing a value to a symbol.</summary>
/// <param name="symbolPath">The path to the symbol to write.</param> /// <param name="symbolPath">The path to the symbol to write.</param>
/// <param name="type">The data type of the symbol.</param> /// <param name="type">The data type of the symbol.</param>
@@ -0,0 +1,148 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>Gitea #645 / #314 — PLC state / task-cycle facts on the optional IDriverDiagnostics bag.</summary>
[Trait("Category", "Unit")]
public sealed class TwinCATDiagnosticsTests
{
private const string Host1 = "ads://5.23.91.23.1.1:851";
private const string Host2 = "ads://5.23.91.24.1.1:851";
private static TwinCATDriver NewDriver(FakeTwinCATClientFactory factory, bool probe, params (string host, string? name)[] devices) =>
new(new TwinCATDriverOptions
{
Devices = [.. devices.Select(d => new TwinCATDeviceOptions(d.host, d.name))],
Probe = new TwinCATProbeOptions { Enabled = probe, Interval = TimeSpan.FromMilliseconds(20) },
}, "drv-diag", factory);
private static FakeTwinCATClient PlcClient() => new()
{
AdsStateText = "Run",
Values =
{
[TwinCATDriver.SymAppName] = "Plc30 App",
[TwinCATDriver.SymOnlineChangeCnt] = 7u,
[TwinCATDriver.SymCycleTime] = 100_000u, // 10.0 ms
[TwinCATDriver.SymLastExecTime] = 25_000u, // 2.5 ms
},
};
private static async Task Sample(TwinCATDriver drv, FakeTwinCATClient client, string host)
{
var state = drv.GetDeviceState(host)!;
await drv.SampleDeviceDiagnosticsAsync(state, client, CancellationToken.None);
}
/// <summary>Verifies the driver advertises the optional capability and is empty before any sample.</summary>
[Fact]
public async Task Bag_is_empty_before_the_first_sample()
{
var drv = NewDriver(new FakeTwinCATClientFactory(), false, (Host1, null));
await drv.InitializeAsync("{}", CancellationToken.None);
var diag = drv.ShouldBeAssignableTo<IDriverDiagnostics>()!;
diag.GetDiagnostics().ShouldBeEmpty();
}
/// <summary>Verifies a single-device driver publishes flat keys with state, app, cycle, exec and jitter.</summary>
[Fact]
public async Task Single_device_publishes_flat_keys()
{
var drv = NewDriver(new FakeTwinCATClientFactory(), false, (Host1, null));
await drv.InitializeAsync("{}", CancellationToken.None);
await Sample(drv, PlcClient(), Host1);
var bag = ((IDriverDiagnostics)drv).GetDiagnostics();
bag["TwinCAT.AdsState"].ShouldBe("Run");
bag["TwinCAT.AppName"].ShouldBe("Plc30 App");
bag["TwinCAT.OnlineChangeCnt"].ShouldBe("7");
bag["TwinCAT.CycleTimeMs"].ShouldBe("10.0");
bag["TwinCAT.LastExecTimeMs"].ShouldBe("2.5");
bag["TwinCAT.JitterMs"].ShouldBe("-7.5");
DriverDiagnostics.Bound(bag).Count.ShouldBe(bag.Count); // within the host's 16-entry cap
}
/// <summary>Verifies several devices are told apart by device name, falling back to host address.</summary>
[Fact]
public async Task Multiple_devices_are_prefixed()
{
var drv = NewDriver(new FakeTwinCATClientFactory(), false, (Host1, "Elma01"), (Host2, null));
await drv.InitializeAsync("{}", CancellationToken.None);
await Sample(drv, PlcClient(), Host1);
await Sample(drv, PlcClient(), Host2);
var bag = ((IDriverDiagnostics)drv).GetDiagnostics();
bag["TwinCAT.Elma01.AdsState"].ShouldBe("Run");
bag[$"TwinCAT.{Host2}.CycleTimeMs"].ShouldBe("10.0");
bag.ContainsKey("TwinCAT.AdsState").ShouldBeFalse();
}
/// <summary>Verifies a runtime without _TaskInfo (older TwinCAT 2) still yields the facts it has, and
/// the missing symbols are asked for once, not on every probe tick.</summary>
[Fact]
public async Task Missing_task_info_gives_a_partial_bag_and_is_not_re_asked()
{
var drv = NewDriver(new FakeTwinCATClientFactory(), false, (Host1, null));
await drv.InitializeAsync("{}", CancellationToken.None);
var client = new FakeTwinCATClient { AdsStateText = "Run" };
client.Values[TwinCATDriver.SymAppName] = "Plc1";
client.ReadStatuses[TwinCATDriver.SymCycleTime] = TwinCATStatusMapper.BadNodeIdUnknown;
client.ReadStatuses[TwinCATDriver.SymLastExecTime] = TwinCATStatusMapper.BadNodeIdUnknown;
client.ReadStatuses[TwinCATDriver.SymOnlineChangeCnt] = TwinCATStatusMapper.BadNodeIdUnknown;
await Sample(drv, client, Host1);
var readsAfterFirst = client.SingleReadCount;
await Sample(drv, client, Host1);
var bag = ((IDriverDiagnostics)drv).GetDiagnostics();
bag["TwinCAT.AdsState"].ShouldBe("Run");
bag["TwinCAT.AppName"].ShouldBe("Plc1");
bag.Keys.ShouldNotContain("TwinCAT.CycleTimeMs");
bag.Keys.ShouldNotContain("TwinCAT.JitterMs");
client.SingleReadCount.ShouldBe(readsAfterFirst + 1); // only AppName is read again
}
/// <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()
{
var drv = NewDriver(new FakeTwinCATClientFactory(), false, (Host1, null));
await drv.InitializeAsync("{}", CancellationToken.None);
var client = PlcClient();
await Sample(drv, client, Host1);
client.ThrowOnRead = true;
client.AdsStateText = null;
await Sample(drv, client, Host1);
var bag = ((IDriverDiagnostics)drv).GetDiagnostics();
bag["TwinCAT.CycleTimeMs"].ShouldBe("10.0");
bag["TwinCAT.AdsState"].ShouldBe("Run");
}
/// <summary>Verifies the probe loop itself fills the bag — no manual sampling.</summary>
[Fact]
public async Task Probe_loop_populates_the_bag()
{
var factory = new FakeTwinCATClientFactory { Customise = PlcClient };
var drv = NewDriver(factory, true, (Host1, null));
await drv.InitializeAsync("{}", CancellationToken.None);
try
{
IReadOnlyDictionary<string, string> bag = new Dictionary<string, string>();
for (var i = 0; i < 100 && bag.Count == 0; i++)
{
await Task.Delay(50);
bag = ((IDriverDiagnostics)drv).GetDiagnostics();
}
bag["TwinCAT.OnlineChangeCnt"].ShouldBe("7");
}
finally { await drv.ShutdownAsync(CancellationToken.None); }
}
}
@@ -0,0 +1,237 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>Gitea #645 / #310 — a poll's reads batched into ADS sum-read commands, with per-item status,
/// a chunk cap and a fallback to individual reads when the target refuses the command.</summary>
[Trait("Category", "Unit")]
public sealed class TwinCATSumReadTests
{
private const string Host = "ads://5.23.91.23.1.1:851";
private static TwinCATTagDefinition Tag(string name, TwinCATDataType type = TwinCATDataType.DInt) =>
new(name, Host, "MAIN." + name, type);
private static (TwinCATDriver drv, FakeTwinCATClientFactory factory) NewDriver(
bool useSumRead, int maxItems, params TwinCATTagDefinition[] tags)
{
var factory = new FakeTwinCATClientFactory();
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
RawTags = TwinCATRawTags.Entries(tags),
Probe = new TwinCATProbeOptions { Enabled = false },
UseSumRead = useSumRead,
SumReadMaxItems = maxItems,
}, "drv-sum", factory);
return (drv, factory);
}
private static FakeTwinCATClient Seeded(params (string symbol, object value)[] values)
{
var c = new FakeTwinCATClient();
foreach (var (symbol, value) in values) c.Values[symbol] = value;
return c;
}
/// <summary>Verifies that sum-read is opt-in: off, every tag is read on its own.</summary>
[Fact]
public async Task Disabled_reads_each_tag_individually()
{
var (drv, factory) = NewDriver(false, 200, Tag("A"), Tag("B"), Tag("C"));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = () => Seeded(("MAIN.A", 1), ("MAIN.B", 2), ("MAIN.C", 3));
var snaps = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
snaps.Select(s => s.Value).ShouldBe([1, 2, 3]);
factory.Clients[0].SumReadBatches.ShouldBeEmpty();
factory.Clients[0].SingleReadCount.ShouldBe(3);
}
/// <summary>Verifies that one poll's tags go out as a single sum command, values in request order.</summary>
[Fact]
public async Task Enabled_batches_a_poll_into_one_sum_command()
{
var (drv, factory) = NewDriver(true, 200, Tag("A"), Tag("B"), Tag("C"));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = () => Seeded(("MAIN.A", 1), ("MAIN.B", 2), ("MAIN.C", 3));
var snaps = await drv.ReadAsync(["C", "A", "B"], CancellationToken.None);
snaps.Select(s => s.Value).ShouldBe([3, 1, 2]);
snaps.ShouldAllBe(s => s.StatusCode == TwinCATStatusMapper.Good);
factory.Clients[0].SumReadBatches.ShouldBe([3]);
factory.Clients[0].SingleReadCount.ShouldBe(0);
}
/// <summary>Verifies that one item's error code lands on that tag only.</summary>
[Fact]
public async Task Per_item_error_code_is_mapped_to_that_tag_only()
{
var (drv, factory) = NewDriver(true, 200, Tag("A"), Tag("B"), Tag("C"));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = () =>
{
var c = Seeded(("MAIN.A", 1), ("MAIN.C", 3));
c.ReadStatuses["MAIN.B"] = TwinCATStatusMapper.BadNodeIdUnknown; // ADS 1808 DeviceSymbolNotFound
return c;
};
var snaps = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
snaps[0].StatusCode.ShouldBe(TwinCATStatusMapper.Good);
snaps[1].StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
snaps[1].Value.ShouldBeNull();
snaps[2].StatusCode.ShouldBe(TwinCATStatusMapper.Good);
snaps[2].Value.ShouldBe(3);
}
/// <summary>Verifies the size cap: a bigger poll splits into chunks and a lone remainder reads singly.</summary>
[Fact]
public async Task Poll_larger_than_the_cap_is_split_into_chunks()
{
var tags = Enumerable.Range(0, 5).Select(i => Tag("T" + i)).ToArray();
var (drv, factory) = NewDriver(true, 2, tags);
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = () =>
{
var c = new FakeTwinCATClient();
for (var i = 0; i < 5; i++) c.Values["MAIN.T" + i] = i * 10;
return c;
};
var snaps = await drv.ReadAsync([.. tags.Select(t => t.Name)], CancellationToken.None);
snaps.Select(s => s.Value).ShouldBe([0, 10, 20, 30, 40]);
factory.Clients[0].SumReadBatches.ShouldBe([2, 2]);
factory.Clients[0].SingleReadCount.ShouldBe(1); // the 5th tag: a one-item "batch" is just a read
}
/// <summary>Verifies the refusal fallback: the poll still completes and the device stops asking.</summary>
[Fact]
public async Task Refused_sum_command_falls_back_to_individual_reads_and_sticks()
{
var (drv, factory) = NewDriver(true, 200, Tag("A"), Tag("B"), Tag("C"));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = () =>
{
var c = Seeded(("MAIN.A", 1), ("MAIN.B", 2), ("MAIN.C", 3));
c.SumReadException = new TwinCATSumReadRefusedException(1793);
return c;
};
var first = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
var second = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
first.Select(s => s.Value).ShouldBe([1, 2, 3]);
first.ShouldAllBe(s => s.StatusCode == TwinCATStatusMapper.Good);
second.Select(s => s.Value).ShouldBe([1, 2, 3]);
factory.Clients[0].SumReadBatches.ShouldBe([3]); // tried once, never again
factory.Clients[0].SingleReadCount.ShouldBe(6);
drv.GetHealth().State.ShouldBe(Core.Abstractions.DriverState.Healthy);
}
/// <summary>Verifies a comms failure is not mistaken for a refusal: the chunk goes Bad, sum stays on.</summary>
[Fact]
public async Task Comms_failure_marks_the_chunk_bad_and_does_not_disable_sum_read()
{
var (drv, factory) = NewDriver(true, 200, Tag("A"), Tag("B"));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = () =>
{
var c = Seeded(("MAIN.A", 1), ("MAIN.B", 2));
c.SumReadException = new InvalidOperationException("router gone");
return c;
};
var failed = await drv.ReadAsync(["A", "B"], CancellationToken.None);
factory.Clients[0].SumReadException = null;
var recovered = await drv.ReadAsync(["A", "B"], CancellationToken.None);
failed.ShouldAllBe(s => s.StatusCode == TwinCATStatusMapper.BadCommunicationError);
recovered.Select(s => s.Value).ShouldBe([1, 2]);
factory.Clients[0].SumReadBatches.ShouldBe([2, 2]);
}
/// <summary>Verifies unknown references do not disturb result order.</summary>
[Fact]
public async Task Unknown_reference_keeps_its_slot_in_a_batched_poll()
{
var (drv, factory) = NewDriver(true, 200, Tag("A"), Tag("B"));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = () => Seeded(("MAIN.A", 1), ("MAIN.B", 2));
var snaps = await drv.ReadAsync(["A", "nope", "B"], CancellationToken.None);
snaps[0].Value.ShouldBe(1);
snaps[1].StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
snaps[2].Value.ShouldBe(2);
factory.Clients[0].SumReadBatches.ShouldBe([2]);
}
/// <summary>Verifies the batch carries bit index and array length so the client decodes like a single read.</summary>
[Fact]
public async Task Bulk_items_carry_array_length_and_bit_index()
{
var captured = new List<TwinCATBulkReadItem>();
var factory = new FakeTwinCATClientFactory { Customise = () => new CapturingClient(captured) };
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
RawTags = TwinCATRawTags.Entries([
new TwinCATTagDefinition("Arr", Host, "MAIN.Arr", TwinCATDataType.Int, ArrayLength: 4),
new TwinCATTagDefinition("Bit", Host, "MAIN.Word.3", TwinCATDataType.Bool),
]),
Probe = new TwinCATProbeOptions { Enabled = false },
UseSumRead = true,
}, "drv-sum", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["Arr", "Bit"], CancellationToken.None);
captured.Count.ShouldBe(2);
captured[0].ArrayCount.ShouldBe(4);
captured[1].BitIndex.ShouldBe(3);
}
/// <summary>Verifies the JSON keys reach the options.</summary>
[Fact]
public void Options_parse_from_driver_config_json()
{
var on = TwinCATDriverFactoryExtensions.ParseOptionsForTests(
"""{"useSumRead": true, "sumReadMaxItems": 50}""", "d");
on.UseSumRead.ShouldBeTrue();
on.SumReadMaxItems.ShouldBe(50);
var off = TwinCATDriverFactoryExtensions.ParseOptionsForTests("{}", "d");
off.UseSumRead.ShouldBeFalse();
off.SumReadMaxItems.ShouldBe(200);
}
/// <summary>Verifies which overall ADS codes mean "no sum commands here" rather than a comms fault.</summary>
[Theory]
[InlineData(1792u, true)]
[InlineData(1793u, true)]
[InlineData(1794u, true)]
[InlineData(1795u, true)]
[InlineData(1797u, true)]
[InlineData(1798u, true)]
[InlineData(6u, false)] // TargetPortNotFound
[InlineData(1861u, false)] // ClientSyncTimeOut
[InlineData(1808u, false)] // symbol not found: a per-item matter
public void Overall_ads_codes_classify_as_refusal_only_for_service_errors(uint code, bool refusal) =>
AdsTwinCATClient.IsSumRefusal(code).ShouldBe(refusal);
private sealed class CapturingClient(List<TwinCATBulkReadItem> sink) : FakeTwinCATClient
{
public override Task<IReadOnlyList<(object? value, uint status)>> ReadValuesAsync(
IReadOnlyList<TwinCATBulkReadItem> reads, CancellationToken ct)
{
sink.AddRange(reads);
return base.ReadValuesAsync(reads, ct);
}
}
}
@@ -72,6 +72,8 @@ public sealed class TwinCATDriverPageFormSerializationTests
UseNativeNotifications = true, UseNativeNotifications = true,
EnableControllerBrowse = false, EnableControllerBrowse = false,
NotificationMaxDelayMs = 100, NotificationMaxDelayMs = 100,
UseSumRead = true,
SumReadMaxItems = 64,
Probe = new TwinCATProbeOptions Probe = new TwinCATProbeOptions
{ {
Enabled = true, Enabled = true,
@@ -88,6 +90,8 @@ public sealed class TwinCATDriverPageFormSerializationTests
roundTripped.UseNativeNotifications.ShouldBeTrue(); roundTripped.UseNativeNotifications.ShouldBeTrue();
roundTripped.EnableControllerBrowse.ShouldBeFalse(); roundTripped.EnableControllerBrowse.ShouldBeFalse();
roundTripped.NotificationMaxDelayMs.ShouldBe(100); roundTripped.NotificationMaxDelayMs.ShouldBe(100);
roundTripped.UseSumRead.ShouldBeTrue();
roundTripped.SumReadMaxItems.ShouldBe(64);
roundTripped.Probe.Enabled.ShouldBeTrue(); roundTripped.Probe.Enabled.ShouldBeTrue();
roundTripped.Probe.Interval.ShouldBe(TimeSpan.FromSeconds(6)); roundTripped.Probe.Interval.ShouldBe(TimeSpan.FromSeconds(6));
roundTripped.Probe.Timeout.ShouldBe(TimeSpan.FromSeconds(2)); roundTripped.Probe.Timeout.ShouldBe(TimeSpan.FromSeconds(2));