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:
@@ -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
@@ -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
@@ -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; }
|
||||||
|
}
|
||||||
+31
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
@@ -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));
|
||||||
|
|||||||
Reference in New Issue
Block a user