Merge b647-twincat: ADS sum-read (opt-in) + TwinCAT diagnostics on /hosts (#645 #310/#314, #612 docs)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 19:33:22 -04:00
17 changed files with 1463 additions and 49 deletions
+7
View File
@@ -1130,6 +1130,13 @@ 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 / ceiling 500, 32 KiB payload
budget). It reads raw bytes and decodes as the declared type, so only a scalar passing the strict width+kind check
(`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.
- **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
+48 -1
View File
@@ -185,9 +185,56 @@ 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, 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.
- **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 read-singly.** 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. **Any other non-comms overall code** (1792 general error, 1795 offset, 1797 size, 1798 data, ...) means
*this request* was rejected — typically too big — so that chunk is read singly for this poll, nothing is
latched, and a Warning is logged once per device so the live gate can see sum-read is not working there. A
comms-level failure (router / port / timeout) 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`, opt-in)
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 ## 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`
(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 (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
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,32 @@ 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.
/// Values above 500 (the ADS sub-command ceiling) are treated as 500. Only consulted when
/// <see cref="UseSumRead"/> is <c>true</c>.
/// </summary>
public int SumReadMaxItems { get; init; } = 200;
/// <summary>
/// When <c>true</c>, each healthy probe tick also reads up to four <c>TwinCAT_SystemInfoVarList</c>
/// symbols (app name, online-change count, task cycle and last-exec time) and the ADS state the probe
/// already fetched, and publishes them on the <c>/hosts</c> diagnostics bag (Gitea #645 / #314).
/// Default <c>false</c>: that is up to four extra read requests per device per probe interval against
/// the PLC, not yet live-gated. Pending the read-only live gate on a TC2 and a TC3 target.
/// </summary>
public bool DiagnosticsEnabled { get; init; }
/// <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,184 @@ 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);
Task<(object? value, uint status)> ReadSingly(int index) =>
ReadValueAsync(reads[index].SymbolPath, reads[index].Type, reads[index].BitIndex,
reads[index].ArrayCount, cancellationToken);
// 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: eligible items become sum
// commands. Anything the sum command cannot carry exactly 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 ReadSingly(i).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)
{
// Arrays are raw bytes on the single path too, with the same strict element rule.
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;
}
// The single read value-converts a numeric pair (DInt over a PLC INT is fine there); raw
// bytes decoded as the declared type would not. Only an exact width + kind match rides the
// sum command — the rest keeps the Beckhoff conversion by reading singly.
if (!TwinCATSumReadPlanner.ScalarEligible(r.Type, shape))
{
results[i] = await ReadSingly(i).ConfigureAwait(false);
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 ReadSingly(i).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;
// Split to the byte budget; a lone item (or an oversized one) is just a read.
var groups = TwinCATSumReadPlanner.Partition(
[.. planned.Select(p => p.Layout.ByteLength)], TwinCATSumReadPlanner.MaxBatchBytes);
foreach (var group in groups)
{
var items = group.Select(g => planned[g]).ToList();
if (items.Count == 1)
{
results[items[0].Index] = await ReadSingly(items[0].Index).ConfigureAwait(false);
continue;
}
await ReadGroupAsync(items, reads, results, ReadSingly, cancellationToken).ConfigureAwait(false);
}
return results;
}
private async Task ReadGroupAsync(
List<PlannedSumRead> items, IReadOnlyList<TwinCATBulkReadItem> reads,
(object? value, uint status)[] results,
Func<int, Task<(object? value, uint status)>> readSingly, CancellationToken cancellationToken)
{
async Task FallBackAsync()
{
foreach (var p in items) results[p.Index] = await readSingly(p.Index).ConfigureAwait(false);
}
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)
.ReadRawAsync(cancellationToken).ConfigureAwait(false);
}
catch (AdsErrorException ex) when (TwinCATSumReadPlanner.IsRefusal((uint)ex.ErrorCode))
{
throw new TwinCATSumReadRefusedException((uint)ex.ErrorCode, ex);
}
catch (ArgumentException)
{
// The library slices the response by the REQUESTED lengths and throws when a target answers with
// less. That is this target mangling this response, not a comms fault: read the group singly.
NoteSumFallback(0);
await FallBackAsync().ConfigureAwait(false);
return;
}
// On an overall failure the library RETURNS the code (SubErrors all set, ReadBlocks null).
if (sum.ErrorCode != AdsErrorCode.NoError)
{
var overall = (uint)sum.ErrorCode;
if (TwinCATSumReadPlanner.IsRefusal(overall))
throw new TwinCATSumReadRefusedException(overall);
if (TwinCATSumReadPlanner.IsRequestRejected(overall))
{
NoteSumFallback(overall);
await FallBackAsync().ConfigureAwait(false); // too big / bad size for THIS request: no latch
return;
}
var mapped = MapAndSignal(overall);
foreach (var p in items) results[p.Index] = (null, mapped);
return;
}
var utf8 = StringsAreUtf8();
for (var k = 0; k < items.Count; k++)
{
var p = items[k];
var subError = sum.SubErrors is { } errs && k < errs.Length ? errs[k] : AdsErrorCode.NoError;
ReadOnlyMemory<byte>? block = sum.ReadBlocks is { } blocks && k < blocks.Count ? blocks[k] : null;
results[p.Index] = TwinCATSumReadPlanner.DecodeItem(
reads[p.Index].Type, subError, block, p.Layout, p.IsArray, utf8, MapAndSignal);
}
}
private const uint SymbolValueByHandle = 0xF005;
private readonly record struct PlannedSumRead(int Index, uint IndexGroup, uint IndexOffset, TwinCATArrayLayout Layout, bool IsArray);
private string? _lastAdsState;
private long _sumFallbackCount;
private long _lastSumFallbackCode;
private void NoteSumFallback(uint code)
{
Interlocked.Exchange(ref _lastSumFallbackCode, code);
Interlocked.Increment(ref _sumFallbackCount);
}
/// <inheritdoc />
public long SumFallbackCount => Interlocked.Read(ref _sumFallbackCount);
/// <inheritdoc />
public uint LastSumFallbackCode => (uint)Interlocked.Read(ref _lastSumFallbackCode);
/// <inheritdoc />
public string? LastAdsState => Volatile.Read(ref _lastAdsState);
/// <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
@@ -289,7 +468,9 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
try try
{ {
var state = await _client.ReadStateAsync(cancellationToken).ConfigureAwait(false); var state = await _client.ReadStateAsync(cancellationToken).ConfigureAwait(false);
return state.ErrorCode == AdsErrorCode.NoError; var ok = state.ErrorCode == AdsErrorCode.NoError;
Volatile.Write(ref _lastAdsState, ok ? state.State.AdsState.ToString() : null);
return ok;
} }
catch catch
{ {
@@ -55,6 +55,53 @@ 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 ADS device state name (<c>Run</c>, <c>Stop</c>, <c>Config</c>, …) the most recent
/// <see cref="ProbeAsync"/> read, or <c>null</c> when unknown. Reuses the probe's own ReadState so
/// diagnostics add no request (Gitea #645 / #314). Default <c>null</c>.
/// </summary>
string? LastAdsState => null;
/// <summary>
/// How many sum-read chunks this client has read singly because the target rejected the sum request
/// without refusing the service (non-latching). The driver warns once per device when it grows, so the
/// live gate can see sum-read is not working on that target. Default 0.
/// </summary>
long SumFallbackCount => 0;
/// <summary>The overall ADS code of the most recent such fallback (0 = a malformed response).</summary>
uint LastSumFallbackCode => 0;
/// <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 +179,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,26 @@
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 cycle time minus last execution time: how much of the cycle was spare. Negative means the
/// task overran its cycle. (Not jitter — that needs the variation across cycles.)</summary>
public double? HeadroomMs => CycleTimeMs is { } c && LastExecTimeMs is { } x ? c - x : 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,47 +290,145 @@ 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
{
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
// An array-typed tag (def.ArrayLength != null) reads the array as raw bytes and decodes
// it with TwinCATArrayCodec — the same decode a native array notification runs — into an
// element-typed CLR array. Scalar tags pass null (scalar path) — bit-indexed BOOLs are
// inherently scalar so the client ignores arrayCount when a bitIndex is present.
var (value, status) = await client.ReadValueAsync(
symbolName, def.DataType, parsed?.BitIndex, def.ArrayLength, cancellationToken)
.ConfigureAwait(false);
return CompleteRead(reference, def, symbolName, value, status, now);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
_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)
_logger.LogWarning(
"TwinCAT driver '{DriverInstanceId}' refused '{Reference}': declared {DataType} " +
"cannot decode the PLC symbol '{Symbol}' (string/numeric kind mismatch, array element type " +
"or length mismatch, or a reference/container)",
_driverInstanceId, reference, def.DataType, symbolName);
else
_logger.LogWarning(
"TwinCAT driver '{DriverInstanceId}' ADS read error 0x{Status:X8} for '{Reference}'",
_driverInstanceId, status, reference);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"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.Clamp(_options.SumReadMaxItems, 1, TwinCATSumReadPlanner.MaxItemsCeiling);
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 try
{ {
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false); var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath); var items = new TwinCATBulkReadItem[chunk.Count];
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath; for (var k = 0; k < chunk.Count; k++)
// An array-typed tag (def.ArrayLength != null) reads the array as raw bytes and decodes items[k] = new TwinCATBulkReadItem(chunk[k].SymbolName, chunk[k].Def.DataType, chunk[k].BitIndex, chunk[k].Def.ArrayLength);
// it with TwinCATArrayCodec — the same decode a native array notification runs — into an
// element-typed CLR array. Scalar tags pass null (scalar path) — bit-indexed BOOLs are
// inherently scalar so the client ignores arrayCount when a bitIndex is present.
var (value, status) = await client.ReadValueAsync(
symbolName, def.DataType, parsed?.BitIndex, def.ArrayLength, cancellationToken)
.ConfigureAwait(false);
results[i] = new DataValueSnapshot(value, status, now, now); var fallbacksBefore = client.SumFallbackCount;
if (status == TwinCATStatusMapper.Good) var bulk = await client.ReadValuesAsync(items, cancellationToken).ConfigureAwait(false);
_health = new DriverHealth(DriverState.Healthy, now, null); if (client.SumFallbackCount > fallbacksBefore && !device.SumFallbackWarned)
else
{ {
if (status == TwinCATStatusMapper.BadTypeMismatch) device.SumFallbackWarned = true;
_logger.LogWarning( _logger.LogWarning(
"TwinCAT driver '{DriverInstanceId}' refused '{Reference}': declared {DataType} " + "TwinCAT driver '{DriverInstanceId}' device '{Device}': the target rejected an ADS sum-read " +
"cannot decode the PLC symbol '{Symbol}' (string/numeric kind mismatch, array element type " + "request (ADS error {AdsError}; 0 = malformed response) and the chunk was read singly. Sum-read " +
"or length mismatch, or a reference/container)", "is not working on this target; this warning is logged once per device",
_driverInstanceId, reference, def.DataType, symbolName); _driverInstanceId, device.Options.HostAddress, client.LastSumFallbackCode);
else }
_logger.LogWarning( for (var k = 0; k < chunk.Count; k++)
"TwinCAT driver '{DriverInstanceId}' ADS read error 0x{Status:X8} for '{Reference}'", {
_driverInstanceId, status, reference); var e = chunk[k];
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, var (value, status) = k < bulk.Count ? bulk[k] : (null, TwinCATStatusMapper.BadCommunicationError);
$"ADS status {status:X8} reading {reference}"); 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 ----
@@ -774,6 +877,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
while (!ct.IsCancellationRequested) while (!ct.IsCancellationRequested)
{ {
var success = false; var success = false;
ITwinCATClient? probed = null;
try try
{ {
// Probe-initiated connects honor TwinCATProbeOptions.Timeout — distinct from // Probe-initiated connects honor TwinCATProbeOptions.Timeout — distinct from
@@ -791,9 +895,12 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
if (!success) if (!success)
await RecycleClientAsync(state, ct).ConfigureAwait(false); await RecycleClientAsync(state, ct).ConfigureAwait(false);
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);
probed = client;
}
} }
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
catch catch
@@ -808,11 +915,139 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped); TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped);
// #645/#314: opt-in PLC state / task-cycle sampling for /hosts. After the host-state transition so a
// slow target cannot delay it; never throws (except cancellation).
if (_options.DiagnosticsEnabled && probed is not null)
{
try { await SampleDeviceDiagnosticsAsync(state, probed, ct).ConfigureAwait(false); }
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
}
try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); } try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { break; } catch (OperationCanceledException) { break; }
} }
} }
// ---- 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;
// 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
?? 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 headroom (cycle − last exec). 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 name = state.Options.DeviceName ?? state.Options.HostAddress;
if (multi)
{
// One compact line per device so the host's 16-entry cap shows up to 16 devices, not 2.
var parts = new List<string>(6);
if (d.AdsState is not null) parts.Add(d.AdsState);
if (!string.IsNullOrEmpty(d.AppName)) parts.Add(d.AppName);
if (d.OnlineChangeCnt is { } n) parts.Add("online-changes " + n.ToString(System.Globalization.CultureInfo.InvariantCulture));
if (d.CycleTimeMs is { } cy) parts.Add("cycle " + FormatMs(cy) + " ms");
if (d.LastExecTimeMs is { } ex) parts.Add("exec " + FormatMs(ex) + " ms");
if (d.HeadroomMs is { } h) parts.Add("headroom " + FormatMs(h) + " ms");
bag[$"TwinCAT.{name}"] = string.Join("; ", parts);
continue;
}
const string prefix = "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 { } cy2) bag[prefix + "CycleTimeMs"] = FormatMs(cy2);
if (d.LastExecTimeMs is { } ex2) bag[prefix + "LastExecTimeMs"] = FormatMs(ex2);
if (d.HeadroomMs is { } h2) bag[prefix + "HeadroomMs"] = FormatMs(h2);
}
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;
@@ -976,6 +1211,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
throw; throw;
} }
device.Client = client; device.Client = client;
device.UnavailableDiagSymbols.Clear(); // a rebuilt client may face a PLC that now exports them
device.Backoff.RecordSuccess(); // 05/STAB-8: recovery resets the window immediately device.Backoff.RecordSuccess(); // 05/STAB-8: recovery resets the window immediately
// Re-register every native notification onto the fresh client. Native ADS is push, so // Re-register every native notification onto the fresh client. Native ADS is push, so
@@ -1233,11 +1469,27 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
/// so the loop cannot touch a disposed client.</summary> /// so the loop cannot touch a disposed client.</summary>
public Task? ProbeTask { get; set; } public Task? ProbeTask { get; set; }
/// <summary>Set when the target answered the ADS sum-read command with "service / index group not
/// supported" (#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>True once the "sum request rejected, chunk read singly" warning was logged for this device.</summary>
public volatile bool SumFallbackWarned;
/// <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);
/// <summary>Disposes the active ADS client if any.</summary> /// <summary>Disposes the active ADS client if any.</summary>
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,9 @@ 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,
DiagnosticsEnabled = dto.DiagnosticsEnabled ?? false,
}; };
} }
@@ -114,6 +117,15 @@ 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 a value indicating whether probe-tick PLC diagnostics are sampled.</summary>
public bool? DiagnosticsEnabled { 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,113 @@
using Beckhoff = TwinCAT.Ads;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// <summary>
/// The pure decisions behind an ADS sum-read (Gitea #645 / #310), split out of
/// <see cref="AdsTwinCATClient"/> so they are unit-testable without an ADS connection: which items may
/// ride a sum command, how a poll is split to fit a request budget, which overall ADS codes mean what,
/// and how one item's raw block becomes a value.
/// </summary>
internal static class TwinCATSumReadPlanner
{
/// <summary>Hard ceiling on items per sum command (ADS documents roughly 500 sub-commands).</summary>
public const int MaxItemsCeiling = 500;
/// <summary>Per-command byte budget: the sum of the items' read lengths plus <see cref="PerItemOverhead"/> each.
/// Keeps a poll that includes large arrays inside a modest router / CE buffer.</summary>
public const int MaxBatchBytes = 32 * 1024;
/// <summary>Bytes of per-item ADS error code carried in a sum response.</summary>
public const int PerItemOverhead = 4;
/// <summary>
/// True when a SCALAR may be read as raw bytes in a sum command. The sum path decodes the bytes as the
/// DECLARED type, so it needs the strict width + float/integer-kind rule (the notification rule); the
/// lenient <see cref="TwinCATTypeGuard.CheckRead"/> relies on the Beckhoff value conversion a raw read
/// does not do. An item failing this but passing CheckRead must be read individually.
/// </summary>
/// <param name="declared">The tag's declared type.</param>
/// <param name="plc">The PLC symbol's shape.</param>
/// <returns>True when the raw-bytes decode is exact.</returns>
public static bool ScalarEligible(TwinCATDataType declared, TwinCATPlcSymbolShape plc) =>
TwinCATTypeGuard.CheckNotification(declared, plc) is null;
/// <summary>The service or index group itself is unsupported: the device never does sum commands. Latches.</summary>
/// <param name="adsError">The overall ADS code.</param>
/// <returns>True for 1793 (service not supported) and 1794 (invalid index group).</returns>
public static bool IsRefusal(uint adsError) => adsError is 1793 or 1794;
/// <summary>A transport-level failure (router / port / timeout), not an answer from the sum service.</summary>
/// <param name="adsError">The overall ADS code.</param>
/// <returns>True when the code maps to a communication error or timeout.</returns>
public static bool IsCommsFailure(uint adsError) =>
TwinCATStatusMapper.MapAdsError(adsError) is var s
&& (s == TwinCATStatusMapper.BadCommunicationError || s == TwinCATStatusMapper.BadTimeout);
/// <summary>
/// The sum service answered with an overall error that is neither "unsupported" nor a comms failure
/// (general error, bad size / data / offset, ...): it rejected THIS request. Read the chunk singly for this
/// poll, do not latch.
/// </summary>
/// <param name="adsError">The overall ADS code.</param>
/// <returns>True for any such code.</returns>
public static bool IsRequestRejected(uint adsError) => !IsRefusal(adsError) && !IsCommsFailure(adsError);
/// <summary>
/// Splits items (in order) into groups whose summed <c>length + PerItemOverhead</c> stays within
/// <paramref name="budget"/>. An item that alone exceeds the budget gets its own group (the caller reads a
/// one-item group individually).
/// </summary>
/// <param name="byteLengths">Each item's read length.</param>
/// <param name="budget">The byte budget per command.</param>
/// <returns>Groups of positions into <paramref name="byteLengths"/>.</returns>
public static List<List<int>> Partition(IReadOnlyList<int> byteLengths, int budget)
{
var groups = new List<List<int>>();
List<int>? current = null;
var used = 0;
for (var i = 0; i < byteLengths.Count; i++)
{
var cost = byteLengths[i] + PerItemOverhead;
if (current is null || used + cost > budget)
{
current = [];
groups.Add(current);
used = 0;
}
current.Add(i);
used += cost;
}
return groups;
}
/// <summary>
/// Turns one item's sum-response slice into a value. A per-item ADS error maps to its status; a missing or
/// short block (a target answering with less than was asked for) is a size error for that item only.
/// </summary>
/// <param name="type">The declared type.</param>
/// <param name="subError">The item's ADS error code (0 = none).</param>
/// <param name="block">The item's bytes, or null when the response had none.</param>
/// <param name="layout">The planned layout (a scalar is one element).</param>
/// <param name="isArray">True to return the whole array, false for element 0.</param>
/// <param name="utf8">True when the target's STRINGs are UTF-8.</param>
/// <param name="mapError">Maps an ADS code to an OPC UA status (also raises symbol-version signals).</param>
/// <returns>The value and status.</returns>
public static (object? value, uint status) DecodeItem(
TwinCATDataType type, Beckhoff.AdsErrorCode subError, ReadOnlyMemory<byte>? block,
TwinCATArrayLayout layout, bool isArray, bool utf8, Func<uint, uint> mapError)
{
if (subError != Beckhoff.AdsErrorCode.NoError) return (null, mapError((uint)subError));
if (block is not { } b || b.Length < layout.ByteLength)
return (null, TwinCATStatusMapper.MapAdsError(1797));
try
{
var decoded = TwinCATArrayCodec.Decode(type, b.Span, layout, utf8);
return (isArray ? decoded : decoded.GetValue(0), TwinCATStatusMapper.Good);
}
catch (ArgumentException)
{
return (null, TwinCATStatusMapper.MapAdsError(1797));
}
}
}
@@ -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,28 @@
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"
min="1" max="500" class="form-control form-control-sm" />
<div class="form-text">Symbols per sum-read command (1-500, default 200).</div>
</div>
<div class="col-md-4 mb-3">
<div class="form-check form-switch mt-4">
<InputCheckbox id="tcDiagnostics" @bind-Value="_form.DiagnosticsEnabled" @bind-Value:after="EmitAsync"
class="form-check-input" />
<label class="form-check-label" for="tcDiagnostics">PLC diagnostics on /hosts</label>
</div>
<div class="form-text">Up to 4 extra reads per device per probe tick. Default off (not yet live-gated).</div>
</div>
</div> </div>
</div> </div>
</section> </section>
@@ -151,6 +173,9 @@
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;
public bool DiagnosticsEnabled { get; set; } = false;
// Probe // Probe
public bool ProbeEnabled { get; set; } = true; public bool ProbeEnabled { get; set; } = true;
@@ -168,6 +193,9 @@
UseNativeNotifications = o.UseNativeNotifications, UseNativeNotifications = o.UseNativeNotifications,
EnableControllerBrowse = o.EnableControllerBrowse, EnableControllerBrowse = o.EnableControllerBrowse,
NotificationMaxDelayMs = o.NotificationMaxDelayMs, NotificationMaxDelayMs = o.NotificationMaxDelayMs,
UseSumRead = o.UseSumRead,
SumReadMaxItems = o.SumReadMaxItems,
DiagnosticsEnabled = o.DiagnosticsEnabled,
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 +209,9 @@
UseNativeNotifications = UseNativeNotifications, UseNativeNotifications = UseNativeNotifications,
EnableControllerBrowse = EnableControllerBrowse, EnableControllerBrowse = EnableControllerBrowse,
NotificationMaxDelayMs = NotificationMaxDelayMs, NotificationMaxDelayMs = NotificationMaxDelayMs,
UseSumRead = UseSumRead,
SumReadMaxItems = SumReadMaxItems,
DiagnosticsEnabled = DiagnosticsEnabled,
Probe = new TwinCATProbeOptions Probe = new TwinCATProbeOptions
{ {
Enabled = ProbeEnabled, Enabled = ProbeEnabled,
@@ -71,12 +71,54 @@ 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="LastAdsState"/> 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;
if (SimulateSumFallback) SumFallbackCount++;
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>Gets or sets whether each batch counts as a non-latching sum fallback.</summary>
public bool SimulateSumFallback { get; set; }
/// <summary>Gets the simulated count of non-latching sum fallbacks.</summary>
public long SumFallbackCount { get; private set; }
/// <summary>Gets the simulated overall code of the last fallback.</summary>
public uint LastSumFallbackCode => 1795;
/// <summary>Gets the ADS state the "probe" last read.</summary>
public string? LastAdsState => 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,209 @@
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, bool diagnostics, 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) },
DiagnosticsEnabled = diagnostics,
}, "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, true, (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 headroom.</summary>
[Fact]
public async Task Single_device_publishes_flat_keys()
{
var drv = NewDriver(new FakeTwinCATClientFactory(), false, true, (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.HeadroomMs"].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, true, (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"].ShouldBe("Run; Plc30 App; online-changes 7; cycle 10.0 ms; exec 2.5 ms; headroom 7.5 ms");
bag[$"TwinCAT.{Host2}"].ShouldContain("cycle 10.0 ms");
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, true, (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.HeadroomMs");
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, true, (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, 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); }
}
/// <summary>Verifies a bag for the maximum number of devices fits the host's cap (one line per device).</summary>
[Fact]
public async Task Many_devices_all_fit_the_bag_cap()
{
var hosts = Enumerable.Range(1, 12).Select(i => ($"ads://5.23.91.{i}.1.1:851", (string?)$"D{i}")).ToArray();
var drv = NewDriver(new FakeTwinCATClientFactory(), false, true, hosts);
await drv.InitializeAsync("{}", CancellationToken.None);
foreach (var (host, _) in hosts) await Sample(drv, PlcClient(), host);
var bag = DriverDiagnostics.Bound(((IDriverDiagnostics)drv).GetDiagnostics());
bag.Count.ShouldBe(12);
}
/// <summary>Verifies sampling is opt-in: with it off the probe loop adds no reads and the bag stays empty.</summary>
[Fact]
public async Task Disabled_by_default_adds_no_reads()
{
var factory = new FakeTwinCATClientFactory { Customise = PlcClient };
var drv = NewDriver(factory, true, false, (Host1, null));
await drv.InitializeAsync("{}", CancellationToken.None);
try
{
for (var i = 0; i < 100 && drv.GetHostStatuses().All(h => h.State != HostState.Running); i++)
await Task.Delay(20);
await Task.Delay(100); // several more probe ticks
}
finally { await drv.ShutdownAsync(CancellationToken.None); }
factory.Clients[0].SingleReadCount.ShouldBe(0);
((IDriverDiagnostics)drv).GetDiagnostics().ShouldBeEmpty();
}
/// <summary>Verifies a symbol the PLC did not export is asked for again once the client is rebuilt.</summary>
[Fact]
public async Task Unavailable_symbols_are_cleared_when_a_new_client_is_installed()
{
var factory = new FakeTwinCATClientFactory();
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host1)],
RawTags = TwinCATRawTags.Entries([new TwinCATTagDefinition("A", Host1, "MAIN.A", TwinCATDataType.DInt)]),
Probe = new TwinCATProbeOptions { Enabled = false },
DiagnosticsEnabled = true,
}, "drv-diag", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var first = new FakeTwinCATClient();
first.ReadStatuses[TwinCATDriver.SymCycleTime] = TwinCATStatusMapper.BadNodeIdUnknown;
factory.Customise = () => first;
await drv.ReadAsync(["A"], CancellationToken.None); // installs the first client
await Sample(drv, first, Host1);
drv.GetDeviceState(Host1)!.UnavailableDiagSymbols.ContainsKey(TwinCATDriver.SymCycleTime).ShouldBeTrue();
first.SimulateWireDrop();
factory.Customise = () => new FakeTwinCATClient();
await drv.ReadAsync(["A"], CancellationToken.None); // reconnect installs a new client
drv.GetDeviceState(Host1)!.UnavailableDiagSymbols.ShouldBeEmpty();
}
}
@@ -0,0 +1,109 @@
using Shouldly;
using TwinCAT.Ads;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>The pure sum-read decisions (Gitea #645) — the part of the wire path a fake client cannot reach.</summary>
[Trait("Category", "Unit")]
public sealed class TwinCATSumReadPlannerTests
{
private static TwinCATPlcSymbolShape Plc(string name, int size, bool isFloat = false) =>
new(TwinCATPlcKind.Scalar, size, isFloat, name);
/// <summary>Verifies the strict width + kind rule: only an exact match rides a sum command, the lenient
/// CheckRead pairs (value-converted by Beckhoff on a single read) are read singly instead.</summary>
[Theory]
[InlineData(TwinCATDataType.DInt, "DINT", 4, false, true)]
[InlineData(TwinCATDataType.DInt, "UDINT", 4, false, true)] // same width + kind: tolerated
[InlineData(TwinCATDataType.Real, "REAL", 4, true, true)]
[InlineData(TwinCATDataType.Bool, "BOOL", 1, false, true)]
[InlineData(TwinCATDataType.DInt, "INT", 2, false, false)] // would over-read into the neighbour
[InlineData(TwinCATDataType.Real, "LREAL", 8, true, false)] // half a double
[InlineData(TwinCATDataType.Real, "DINT", 4, false, false)] // bit pattern reinterpreted
[InlineData(TwinCATDataType.DInt, "REAL", 4, true, false)]
[InlineData(TwinCATDataType.Int, "DINT", 4, false, false)] // truncation
public void Scalar_eligibility_needs_exact_width_and_kind(
TwinCATDataType declared, string plcType, int plcSize, bool plcFloat, bool eligible)
{
var shape = Plc(plcType, plcSize, plcFloat);
TwinCATSumReadPlanner.ScalarEligible(declared, shape).ShouldBe(eligible);
// The mismatching pairs are exactly the ones the lenient single-read check lets through.
if (!eligible) TwinCATTypeGuard.CheckRead(declared, shape).ShouldBeNull();
}
/// <summary>Verifies a string tag over a numeric symbol is not eligible.</summary>
[Fact]
public void A_string_tag_over_a_numeric_symbol_is_not_eligible() =>
TwinCATSumReadPlanner.ScalarEligible(TwinCATDataType.String, Plc("DINT", 4)).ShouldBeFalse();
/// <summary>Verifies only service / index-group errors latch; request-size errors split without latching.</summary>
[Theory]
[InlineData(1793u, true, false)]
[InlineData(1794u, true, false)]
[InlineData(1792u, false, true)]
[InlineData(1797u, false, true)]
[InlineData(1798u, false, true)]
[InlineData(1795u, false, true)] // invalid offset: this request rejected, read singly
[InlineData(1796u, false, true)]
[InlineData(1808u, false, true)]
[InlineData(6u, false, false)] // comms: every item goes Bad
[InlineData(7u, false, false)]
[InlineData(1861u, false, false)]
public void Overall_ads_codes_classify(uint code, bool refusal, bool rejected)
{
TwinCATSumReadPlanner.IsRefusal(code).ShouldBe(refusal);
TwinCATSumReadPlanner.IsRequestRejected(code).ShouldBe(rejected);
}
/// <summary>Verifies the byte budget splits a poll and gives an oversized item a group of its own.</summary>
[Fact]
public void Partition_respects_the_byte_budget()
{
// cost = length + 4: 104, 104, 104 | 1004 (own group) | 14
var groups = TwinCATSumReadPlanner.Partition([100, 100, 100, 1000, 10], 330);
groups.Select(g => g.ToArray()).ToArray().ShouldBe([[0, 1, 2], [3], [4]]);
}
/// <summary>Verifies an empty poll partitions to nothing.</summary>
[Fact]
public void Partition_of_nothing_is_empty() =>
TwinCATSumReadPlanner.Partition([], 1000).ShouldBeEmpty();
/// <summary>Verifies a Good block decodes to a scalar / an array.</summary>
[Fact]
public void Decode_returns_scalar_and_array_values()
{
byte[] four = [0x2A, 0, 0, 0];
var scalar = TwinCATSumReadPlanner.DecodeItem(TwinCATDataType.DInt, AdsErrorCode.NoError, four,
new TwinCATArrayLayout(1, 4), false, false, TwinCATStatusMapper.MapAdsError);
scalar.ShouldBe((42, TwinCATStatusMapper.Good));
byte[] arr = [1, 0, 2, 0];
var array = TwinCATSumReadPlanner.DecodeItem(TwinCATDataType.Int, AdsErrorCode.NoError, arr,
new TwinCATArrayLayout(2, 2), true, false, TwinCATStatusMapper.MapAdsError);
array.status.ShouldBe(TwinCATStatusMapper.Good);
array.value.ShouldBe(new short[] { 1, 2 });
}
/// <summary>Verifies a per-item ADS error maps to that item.</summary>
[Fact]
public void Decode_maps_a_sub_error() =>
TwinCATSumReadPlanner.DecodeItem(TwinCATDataType.DInt, (AdsErrorCode)1808, null,
new TwinCATArrayLayout(1, 4), false, false, TwinCATStatusMapper.MapAdsError)
.ShouldBe((null, TwinCATStatusMapper.BadNodeIdUnknown));
/// <summary>Verifies a short or missing block is a size error for that item, never an uncaught throw.</summary>
[Fact]
public void Decode_of_a_short_or_missing_block_is_a_per_item_size_error()
{
var expected = (default(object), TwinCATStatusMapper.MapAdsError(1797));
TwinCATSumReadPlanner.DecodeItem(TwinCATDataType.DInt, AdsErrorCode.NoError, new byte[] { 1, 2 },
new TwinCATArrayLayout(1, 4), false, false, TwinCATStatusMapper.MapAdsError).ShouldBe(expected);
TwinCATSumReadPlanner.DecodeItem(TwinCATDataType.DInt, AdsErrorCode.NoError, null,
new TwinCATArrayLayout(1, 4), false, false, TwinCATStatusMapper.MapAdsError).ShouldBe(expected);
}
}
@@ -0,0 +1,239 @@
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 a non-latching fallback is surfaced once per device, not once per poll.</summary>
[Fact]
public async Task Non_latching_fallback_warns_once_per_device()
{
var (drv, factory) = NewDriver(true, 200, Tag("A"), Tag("B"));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = () => new FakeTwinCATClient { Values = { ["MAIN.A"] = 1, ["MAIN.B"] = 2 }, SimulateSumFallback = true };
await drv.ReadAsync(["A", "B"], CancellationToken.None);
var state = drv.GetDeviceState(Host)!;
state.SumFallbackWarned.ShouldBeTrue();
state.SumReadUnsupported.ShouldBeFalse(); // not latched: the next poll tries the sum command again
await drv.ReadAsync(["A", "B"], CancellationToken.None);
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);
}
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,9 @@ public sealed class TwinCATDriverPageFormSerializationTests
UseNativeNotifications = true, UseNativeNotifications = true,
EnableControllerBrowse = false, EnableControllerBrowse = false,
NotificationMaxDelayMs = 100, NotificationMaxDelayMs = 100,
UseSumRead = true,
SumReadMaxItems = 64,
DiagnosticsEnabled = true,
Probe = new TwinCATProbeOptions Probe = new TwinCATProbeOptions
{ {
Enabled = true, Enabled = true,
@@ -88,6 +91,9 @@ 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.DiagnosticsEnabled.ShouldBeTrue();
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));