# TwinCAT Driver — Implementation Plan

> Source of gap analysis: [featuregaps.md → TwinCAT](../featuregaps.md#twincat-beckhoff-ads)
>
> Covers Build = Yes items only.

## Summary

The TwinCAT driver (`src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/`) ships a solid baseline:
six capability interfaces over `Beckhoff.TwinCAT.Ads` v6 `AdsClient`, native
`AdsTransMode.OnChange` notifications, AMS address parsing, symbol-path parser
with multi-dim subscripts, controller-side browse with system-symbol filtering,
and a 30-case live integration suite against TCBSD + Hyper-V XAR. Twelve gaps
remain rated Build=Yes in `docs/featuregaps.md` and they cluster cleanly into
five themes:

1. **Data-type correctness** — `LInt`/`ULInt` silently truncated to Int32
   (explicit `// matches Int64 gap` comment in `TwinCATDataType.cs:40`),
   `TIME`/`DATE`/`DT`/`TOD` marshalled as raw `UDINT` rather than native UA
   types, `ENUM`/`ALIAS` skipped at browse, bit-indexed BOOL writes throw,
   multi-dim and whole-array reads not batched.
2. **Performance** — every read is a `ReadValueAsync` call with re-resolved
   symbolic name; no Sum commands, no handle caching. Multi-thousand-tag
   scans pay symbol resolution + per-tag AMS round-trip cost on every cycle.
3. **Operability** — `NotificationSettings(OnChange, cycleMs, 0)` clamps
   max-delay to zero with no per-tag override; probe loop only checks
   reachability — no cycle-time / jitter / `_AppInfo` / RT-state telemetry.
4. **UDT decomposition** — `Structure` is declared in the enum but discovery
   skips non-atomic symbols (`AdsTwinCATClient.cs:224`); to expose nested UDT
   trees we need TMC-file parsing or runtime data-type table introspection.
5. **Alarms** — no `IAlarmSource` implementation; TC3 EventLogger / AMS port
   110 events never surface as OPC UA AC events.

The plan ships as five phases / 12 PRs. Phases 1-3 are all narrow scope and can
land in parallel where dependencies allow. Phase 4 (UDT/TMC) is the largest
single piece of work and is called out as such. Phase 5 (alarms) requires
investigation up front (Beckhoff TC3 EventLogger NuGet availability — see
Open questions).

Hyper-V conflict gating: live integration runs against the TCBSD VM
(`docs/drivers/TwinCAT-Test-Fixture.md`, AmsNetId `41.169.163.43.1.1` at
`10.100.0.128`) since the local Hyper-V XAR can't co-exist with Docker
Desktop. All wire-level tests gate on `[TwinCATFact]` / `[TwinCATTheory]`
and skip cleanly when `TWINCAT_TARGET_NETID` is unset.

## Phased delivery

| Phase | Theme | PRs | Sequencing |
|---|---|---|---|
| 1 | Data-type correctness | 1.1 — 1.5 | Independent; ship in any order |
| 2 | Performance — Sum + handles | 2.1 — 2.3 | 2.3 depends on 2.2 |
| 3 | Operability — max-delay + diagnostics | 3.1 — 3.2 | Independent |
| 4 | UDT decomposition with TMC parsing | 4.1 | Stand-alone; significant scope |
| 5 | TC3 EventLogger alarms | 5.1 | Stand-alone; spike first |

Total: 12 PRs covering the 12 Build=Yes gaps.

Recommended landing order: **Phase 1 (correctness) → Phase 3 (operability) →
Phase 2 (perf) → Phase 5 (alarms) → Phase 4 (UDT)**. Correctness first because
it's cheap and removes fixtures' `Skip("Int64 gap")`-style workarounds.
Operability before perf because the diagnostics surface created in 3.2 makes it
much easier to validate Sum-command throughput claims in 2.1.

## Per-PR detail

### Phase 1 — Data-type correctness

#### PR 1.1 — Int64 fidelity for `LINT` / `ULINT`

**Scope**: Map `LInt`/`ULInt` to `DriverDataType.Int64` (currently truncates to
Int32 per `TwinCATDataType.cs:40` comment "matches Int64 gap"). `MapToClrType`
already returns `typeof(long)`/`typeof(ulong)`; the truncation is purely in the
`ToDriverDataType` extension.

**Files**:
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDataType.cs` — change line 40 to
  `=> DriverDataType.Int64;` (drop the gap comment).
- Verify `DriverDataType.Int64` exists in `Core.Abstractions` — if not, add it
  (likely scope creep into `ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverDataType.cs`).

**Beckhoff.TwinCAT.Ads API**: none — the wire-level `AdsClient.ReadValueAsync`
already returns `long`/`ulong` boxed in `result.Value` when called with
`typeof(long)` per `MapToClrType`.

**Test plan**:
- Unit: extend `TwinCATCapabilityTests` — assert `LInt.ToDriverDataType() ==
  Int64`, `ULInt.ToDriverDataType() == Int64`.
- Integration: extend `GVL_Primitives` to include an `LINT` (`nLargeCounter`)
  seeded with `0x1_0000_0000L` (above Int32 range). Add a `[TwinCATTheory]`
  case asserting the value round-trips without truncation. May need a new
  `GVL_Primitives.lLong : LINT` symbol if not already present (the existing
  16-primitive theory in `TwinCAT3SmokeTests.cs` covers `LInt`/`ULInt` —
  inspect what value it seeds and tighten the assertion).

**Effort**: S (half day).
**Deps**: none.

**Docs / fixture / e2e**:
- Docs: `docs/Driver.TwinCAT.Cli.md` "Data types" table — drop the "marshal as
  `UDINT` on the wire" caveat for `LInt` / `ULInt` (this PR keeps Int64 fidelity);
  `docs/drivers/TwinCAT-Test-Fixture.md` "Bugs caught by live runs" gains a 4th
  entry pinning the truncation regression.
- Fixture (TCBSD PLC project): `PLC/GVLs/GVL_Primitives.TcGVL` adds
  `vLargeCounter : LINT := 16#1_0000_0000` (above Int32 range) + matching
  `vLargeCounterU : ULINT`; `tests/.../TwinCatProject/README.md` "GVL_Primitives
  numeric seeds" enumerates the new symbols.
- Integration tests: `TwinCAT3SmokeTests.cs` — extend the 16-case
  `[TwinCATTheory]` to 17/18 cases covering the new LINT/ULINT seeds; assert
  the value round-trips without truncation.
- E2E: no change to `scripts/e2e/test-twincat.ps1` — the bridge script targets
  a single DINT counter, untouched by Int64 work.

#### PR 1.2 — TIME / DATE / DT / TOD as native UA types

**Scope**: Stop marshalling `TIME` / `DATE` / `DT` / `TOD` as raw `UDINT`
(`AdsTwinCATClient.cs:278-280`). Map according to IEC 61131-3 semantics:

- `TIME` (ms duration) → `DriverDataType.Duration` (UA `Double` seconds, or
  add `Duration` to `DriverDataType` if missing).
- `DATE` (days since 1970-01-01) → `DriverDataType.DateTime` (midnight UTC).
- `DT` (seconds since 1970-01-01) → `DriverDataType.DateTime`.
- `TOD` (ms since midnight) → `DriverDataType.DateTime` (today's date +
  offset) or a dedicated `TimeOfDay` type if the abstraction supports it.

**Files**:
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDataType.cs` — update
  `ToDriverDataType` mapping for the four IEC time types.
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs` — `MapToClrType`
  returns the raw UDINT today; keep that for the wire read but post-process
  inside `ReadValueAsync` / `ConvertForWrite` to convert UDINT ↔ `DateTime` /
  `TimeSpan`. Symmetrical change in `OnAdsNotificationEx` so subscriptions see
  the same shape.

**Beckhoff.TwinCAT.Ads API**: still `AdsClient.ReadValueAsync(symbol,
typeof(uint), ct)`. Beckhoff exposes `PlcOpenDate` / `PlcOpenTimeOfDay` etc.
in `TwinCAT.Ads.TypeSystem` — using those types directly would simplify
conversion but tightens our coupling. Investigate during PR.

**Test plan**:
- Unit: round-trip helpers UDINT-since-epoch ↔ `DateTime` for each variant.
- Integration: add `GVL_Primitives.dCurrentTime : DT` seeded with a known
  literal (e.g. `DT#2026-01-15-12:00:00`); assert the driver returns a
  `DateTime` matching that instant within 1 s.

**Effort**: M (1-2 days).
**Deps**: none. May expose missing `Duration` in `DriverDataType` enum.

**Docs / fixture / e2e**:
- Docs: `docs/Driver.TwinCAT.Cli.md` "Data types" section — replace the
  "marshal as `UDINT` on the wire — CLI takes a numeric raw value" paragraph
  with native syntax (e.g. `read -t DateTime` returns ISO-8601, `write -t Time
  -v 00:00:01.500` for IEC TIME duration). New examples for each of the four
  IEC time types under `read` / `write`.
- Fixture (TCBSD PLC project): `PLC/GVLs/GVL_Primitives.TcGVL` adds
  `dCurrentTime : DT := DT#2026-01-15-12:00:00`, `tCycleDuration : TIME :=
  T#1500ms`, `dToday : DATE := DATE#2026-04-25`, `tShiftStart : TOD :=
  TOD#06:30:00`. Existing primitives theory in
  `tests/.../TwinCatProject/README.md` § "Type coverage" gets the seed values
  documented.
- Integration tests: `TwinCAT3SmokeTests.cs` — new
  `Driver_round_trips_TIME_DATE_DT_TOD_as_native_UA_types` `[TwinCATFact]`
  reading each variable and asserting the CLR shape (`TimeSpan` / `DateTime`).
  Update the existing 16-case primitive `[TwinCATTheory]` to assert native
  types instead of raw `UDINT` for these four entries.
- E2E: `scripts/e2e/test-twincat.ps1` unchanged for now (single DINT bridge);
  follow-up could add a DT-typed bridge node but it's not on the critical path.

#### PR 1.3 — Bit-indexed BOOL writes (read-modify-write)

**Scope**: Replace the `NotSupportedException` at `AdsTwinCATClient.cs:99-100`
with a read-modify-write sequence: read parent word as `uint`, set/clear bit,
write the word back. Must serialize against concurrent writes to the same
parent word — a single `SemaphoreSlim` keyed on parent symbol path is
sufficient (concurrency on bit writes within the same parent is rare and the
PLC cycle is the natural lower bound on contention anyway).

**Files**:
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs` — replace `throw`
  branch in `WriteValueAsync` with RMW logic mirroring `ReadValueAsync`'s
  bit-index path. Add `ConcurrentDictionary<string, SemaphoreSlim>
  _bitWriteLocks` keyed on parent symbol.

**Beckhoff.TwinCAT.Ads API**: `AdsClient.ReadValueAsync(parent, typeof(uint))`
+ `AdsClient.WriteValueAsync(parent, modifiedWord)`. Both already used.

**Test plan**:
- Unit: extend `TwinCATReadWriteTests` with a `FakeTwinCATClient` test
  covering set + clear of bits 0, 7, 15, 31 of a `uint` parent.
- Integration: add a new `[TwinCATFact]` —
  `Driver_round_trips_bit_indexed_BOOL_write_and_read` against
  `GVL_Primitives.vWord.4` (the `0xBEEF` word's bit-4); flip to true, read
  back as true, flip to false, read back as false.

**Effort**: S-M (1 day).
**Deps**: none. Closes task #181 referenced in the existing `NotSupported`
exception message.

**Docs / fixture / e2e**:
- Docs: `docs/Driver.TwinCAT.Cli.md` `write` section — add an example
  `otopcua-twincat-cli write -n ... -s "GVL_Primitives.vWord.4" -t Bool -v
  true` and a note explaining the RMW semantics + concurrency caveat (parent
  word is locked per write — concurrent bit writes on the same word
  serialize). `docs/drivers/TwinCAT-Test-Fixture.md` "Bugs caught by live
  runs" updates entry #3 to note that writes now also work (read previously
  shipped; write was the gap).
- Fixture (TCBSD PLC project): no schema change required —
  `GVL_Primitives.vWord` already exists with seed `0xBEEF`. Tests use bits 4
  (clear) and 7 (set) to round-trip.
- Integration tests: `TwinCAT3SmokeTests.cs` — new
  `Driver_round_trips_bit_indexed_BOOL_write_and_read` `[TwinCATFact]`. Unit
  tests in `TwinCATReadWriteTests` extended via `FakeTwinCATClient` for bits
  0/7/15/31 of a `uint` parent.
- E2E: no change.

#### PR 1.4 — Multi-dim and whole-array reads

**Scope**: Expand `ReadValueAsync` / `WriteValueAsync` to handle whole-array
reads via Beckhoff's array marshalling, instead of element-by-element. The
symbol-path parser already produces `TwinCATSymbolSegment.Subscripts` with N
dims; today the driver only reads single elements (one path per request).

**Files**:
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs` — when a tag
  declares `IsArray=true` (extend `TwinCATTagDefinition`), use
  `AdsClient.ReadValueAsync(symbol, typeof(int[]))` / `typeof(double[,])` etc.
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs` — surface
  `IsArray` + `ArrayDim` through `DriverAttributeInfo` in `DiscoverAsync`.
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTagDefinition.cs` (if exists,
  in `TwinCATDriverOptions.cs`) — add `bool IsArray`, `int[]? ArrayDimensions`.

**Beckhoff.TwinCAT.Ads API**: `AdsClient.ReadValueAsync(symbol, Type, ct)`
accepts CLR array types. For dynamically-sized reads use
`AdsClient.ReadAnyAsync<T[]>(...)` or pass `Array.CreateInstance(elemType,
dims)`. SymbolLoader yields a `Symbol.Category == DataTypeCategory.Array` we
can inspect to autoderive dimensions during discovery.

**Test plan**:
- Unit: parse `Matrix[1,2]` and verify ranking / dimension flow into the
  request shape via `FakeTwinCATClient`.
- Integration: extend `GVL_Arrays` with a 5x5 `aReal2D : ARRAY [1..5, 1..5]
  OF REAL`; new `[TwinCATFact]` reads the whole array in one call and
  verifies element count + values.

**Effort**: M (2-3 days).
**Deps**: none. Sets up the array-shape plumbing the rest of the driver
needs anyway.

**Docs / fixture / e2e**:
- Docs: `docs/Driver.TwinCAT.Cli.md` `read` section — add whole-array example
  (`read -s "GVL_Arrays.aReal2D"` returns the full matrix as JSON) plus a
  dedicated "Arrays" sub-section calling out 1-D / N-D / array-of-struct
  semantics. `docs/drivers/TwinCAT-Test-Fixture.md` "What it actually covers"
  list adds the whole-array bullet.
- Fixture (TCBSD PLC project): `PLC/GVLs/GVL_Arrays.TcGVL` already declares
  `ARRAY[1..4,1..4] OF REAL` per `TwinCatProject/README.md` § "Array
  coverage". This PR adds a 5x5 `aReal2D : ARRAY [1..5, 1..5] OF REAL`
  initialised with a deterministic pattern (e.g. `(i-1)*5 + (j-1)`) so the
  whole-array test can assert each element. README "Array coverage" gets the
  new symbol.
- Integration tests: `TwinCAT3SmokeTests.cs` — new
  `Driver_reads_whole_2D_array_in_one_call` `[TwinCATFact]`. Unit tests
  extend `TwinCATSymbolPathTests` for multi-dim subscript shape.
- E2E: no change to `scripts/e2e/test-twincat.ps1` (scalar bridge); a future
  array-bridge scenario is captured in the consolidated section below.

#### PR 1.5 — ENUM and ALIAS at discovery

**Scope**: `MapSymbolTypeName` returns `null` for any non-atomic type
(`AdsTwinCATClient.cs:224`), so ENUM and ALIAS symbols are silently dropped
during browse. ENUM is essentially a sized-integer with named members; ALIAS
is a renamed atomic. Both are extremely common in real projects (motor states,
recipe-step IDs, bit-flag groups).

**Files**:
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs` —
  `MapSymbolTypeName` keyed only on the type name today; switch to inspecting
  `symbol.DataType` + `symbol.Category` from `TwinCAT.TypeSystem`. For
  `DataTypeCategory.Enum` walk `EnumType.EnumValues` and pick the underlying
  base type. For `DataTypeCategory.Alias` resolve `AliasType.BaseType`
  recursively until atomic.
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/BrowseSymbolsAsync` —
  surface enum members so the OPC UA layer can later emit them as
  EnumStrings.

**Beckhoff.TwinCAT.Ads API**: `TwinCAT.Ads.TypeSystem.SymbolLoaderFactory`
already returns full `IDataType` objects with `Category`, `EnumType`,
`AliasType`, etc. No new APIs.

**Test plan**:
- Unit: extend `TwinCATSymbolBrowserTests` — fake an enum symbol via
  `FakeTwinCATClient`; assert it browses with the underlying base type.
- Integration: add `E_LineState : (Idle, Running, Faulted)` + a GVL instance
  variable; new `[TwinCATFact]` browses + reads it as `Int16` (or whatever
  the underlying type is).

**Effort**: M (1-2 days).
**Deps**: none. POINTER / REFERENCE / INTERFACE / UNION are explicitly
out-of-scope for this PR — they need real-world demand and a much larger
type-system rework. ENUM and ALIAS are the 80% case.

**Docs / fixture / e2e**:
- Docs: `docs/Driver.TwinCAT.Cli.md` `browse` section — note that ENUM and
  ALIAS symbols now appear in the output (previously dropped); add a Data
  types row for "Enum (surfaced as underlying integer with EnumStrings)"
  and "Alias (resolved to base atomic)". `docs/drivers/TwinCAT-Test-
  Fixture.md` "What it actually covers" extends with the enum/alias bullet.
- Fixture (TCBSD PLC project): `PLC/DUTs/E_AxisState.TcDUT` and
  `E_Severity.TcDUT` already exist; `PLC/DUTs/T_Temperature.TcDUT` and
  `T_MeterPerSec.TcDUT` already exist. `PLC/GVLs/GVL_Enums.TcGVL` already
  exposes them at the root per `TwinCatProject/README.md` § "Enum + alias
  coverage" — no fixture change needed for this PR. README's "Integration-
  test contract" gets a new entry for `GVL_Enums.currentSeverity` /
  `currentTemperature` so the new browse assertion has a stable target.
- Integration tests: `TwinCAT3SmokeTests.cs` — new
  `Driver_browses_enums_and_aliases_with_resolved_base_types` `[TwinCATFact]`
  asserting the four `GVL_Enums` symbols surface with the correct underlying
  CLR type (`Int32` for E_AxisState, `Int16` for E_Severity, `Double` for
  the LREAL aliases).
- E2E: no change.

### Phase 2 — Performance (Sum commands + handle caching)

#### PR 2.1 — ADS Sum-read / Sum-write

**Scope**: Today `ReadAsync` loops over `fullReferences` issuing one
`ReadValueAsync` per tag (`TwinCATDriver.cs:118-156`). Beckhoff's ADS Sum
commands (`IndexGroup=0xF080..0xF084`) batch N reads/writes into a single AMS
request. `Beckhoff.TwinCAT.Ads` v6 exposes this via
`AdsClient.ReadWriteAsync` with `SumCommand` request envelopes —
specifically `SumSymbolRead` / `SumSymbolWrite` from
`TwinCAT.Ads.SumCommand`. ~10x throughput on multi-thousand-tag scans
according to Beckhoff InfoSys.

**Files**:
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs` — new
  `ReadValuesAsync(IReadOnlyList<(string symbol, Type clrType)>, ct)` returning
  a parallel array of `(value, status)`.
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs::ReadAsync` — bucket
  `fullReferences` by `DeviceHostAddress`, call the new client method per
  bucket. `bitIndex` handling stays per-tag (RMW post-step).
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs` — add the
  bulk-read / bulk-write surface.

**Beckhoff.TwinCAT.Ads API**:
- `AdsClient.ReadWriteAsync(IndexGroup=0xF080, IndexOffset=count, ...)` for
  raw sum-read by handle.
- Higher-level: `TwinCAT.Ads.SumCommand.SumSymbolRead(client, symbols)` /
  `SumSymbolWrite(client, symbols, values)` in v6. Verify the exact namespace
  during PR — Beckhoff sometimes re-shuffles between minor versions.
- For symbolic (no handle) batching: `SumSymbolReadByName`.

**Test plan**:
- Unit: `FakeTwinCATClient.ReadValuesAsync` fakes the bulk surface; test
  ordering preservation, partial-failure mapping, empty-input handling.
- Integration: `[TwinCATFact]` reads 100 declared tags in one call, asserts
  value parity with 100 single-call equivalents and measures wall-clock
  difference (assert under 50% of the loop baseline).

**Effort**: M-L (3 days).
**Deps**: none (handle caching in 2.2 amplifies the win but isn't required).

**Docs / fixture / e2e**:
- Docs: `docs/v3/twincat-backlog.md` perf note moves out (Sum-commands no
  longer deferred) — add a closed-out bullet pointing at this PR. New
  performance section in `docs/drivers/TwinCAT-Test-Fixture.md` documenting
  the throughput baseline + Sum-command delta. `docs/Driver.TwinCAT.Cli.md`
  doesn't expose Sum directly to the user — the CLI still drives one symbol
  per call — so no CLI doc change.
- Fixture (TCBSD PLC project, primary fixture-extension surface): add a new
  `PLC/GVLs/GVL_Perf.TcGVL` declaring `aTags : ARRAY[1..1000] OF DINT` plus
  a `MAIN` rung (or new `FB_PerfChurn` POU) that increments each element on
  a rotating subset. `TwinCatProject/README.md` § "Required project state"
  gains a "Performance scenarios" subsection documenting the 1000-tag GVL.
- Integration tests: new perf test
  `Driver_sum_read_1000_tags_beats_loop_baseline_by_5x` (`[TwinCATFact]`,
  perf-tier — guarded behind a separate `TWINCAT_PERF=1` env flag so CI
  noise from VM jitter doesn't flap the suite). Unit tests cover ordering,
  partial-failure mapping, empty-input via `FakeTwinCATClient.ReadValuesAsync`.
- E2E: `scripts/e2e/test-twincat.ps1` unchanged for the canonical bridge;
  perf scripts live alongside as a separate `scripts/perf/twincat-sum.ps1`
  if/when introduced (deferred — integration test is sufficient).

#### PR 2.2 — Handle-based access with caching

**Scope**: Cache `AdsClient.CreateVariableHandleAsync` results so per-read
overhead drops from "resolve symbolic name + read by name" to "read by handle"
— smaller AMS payloads, no name resolution on each call. Cache lifetime is
process-scoped; eviction is via the PR 2.3 invalidation listener. Until 2.3
ships the cache must be cleared on `AdsClient` reconnect (the existing
auto-reconnect path in `EnsureConnectedAsync`).

**Files**:
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs` — add
  `ConcurrentDictionary<string, uint> _handleCache`. Wrap reads/writes through
  `EnsureHandleAsync(symbolPath)` that hits the cache or calls
  `CreateVariableHandleAsync`. On `AdsErrorCode.DeviceSymbolVersionInvalid`
  (0x710 / 1808) evict the entry and retry once.
- Dispose path: `DeleteVariableHandleAsync` for every cached handle on
  `AdsClient.Dispose` to be a good citizen with the runtime.

**Beckhoff.TwinCAT.Ads API**:
- `AdsClient.CreateVariableHandleAsync(string symbol, ct)` → returns
  `ResultHandle` with `.Handle` (uint).
- `AdsClient.ReadAnyAsync<T>(IndexGroup=0xF005, IndexOffset=handle, ct)`
  reads by handle.
- `AdsClient.WriteAnyAsync(IndexGroup=0xF005, IndexOffset=handle, value, ct)`.
- `AdsClient.DeleteVariableHandleAsync(uint handle, ct)`.

**Test plan**:
- Unit: `FakeTwinCATClient` records handle-create / read-by-handle calls;
  test asserts second read of same symbol uses cached handle (zero new
  creates).
- Integration: subscribe + read 50 tags, capture AMS round-trips via probe
  counter, assert the second pass uses ~50% of the bytes (handle = 4 bytes
  vs symbol path = N bytes).

**Effort**: M (2 days).
**Deps**: combines with PR 2.1 for sum-read-by-handle (highest perf path).
Without 2.3, handles can go stale after an online change — call out the
caveat in driver options and add a manual `FlushOptionalCachesAsync` invocation
that wipes the handle cache.

**Docs / fixture / e2e**:
- Docs: `docs/drivers/TwinCAT-Test-Fixture.md` perf section gets a paragraph
  noting that handles drop AMS payload size for repeated reads (4 bytes vs.
  N-byte symbol path); call out the staleness caveat (online-change
  invalidation lands in 2.3). `docs/Driver.TwinCAT.Cli.md` adds a brief note
  in the `subscribe` / `read` sections that handles are cached transparently
  — no user-visible flag.
- Fixture (TCBSD PLC project): no change required — handle caching is
  observable via byte-counter on the wire, not via PLC-side state. The
  perf-scenario `GVL_Perf.aTags` from PR 2.1 doubles as the exercise target.
- Integration tests: new
  `Driver_handle_cache_avoids_repeat_symbol_resolution` `[TwinCATFact]`
  reads the same 50 symbols twice; asserts second pass uses cached handles
  (probed via diagnostics counters from PR 3.2 if shipped, otherwise via a
  test-only hook on `AdsTwinCATClient`). Unit tests on
  `FakeTwinCATClient.HandleCacheTests` assert second read of same symbol
  triggers zero new handle creates.
- E2E: no change.

#### PR 2.3 — Symbol-version invalidation listener

**Scope**: TwinCAT publishes a "symbol table version changed" notification on
ADS Index Group `ADSIGRP_SYMVAL_BYHND` (or rather, version bumps land via
`SystemServiceLoadFile` style notifications + `SymbolVersion` reads). When the
PLC takes an online change, all cached handles are silently invalidated; the
next read returns `DeviceSymbolVersionInvalid` if you're lucky and a wrong
value if you're not. We register a notification on the symbol-version index
and wipe the handle cache on bump.

**Files**:
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs` — on connect,
  call `AddDeviceNotificationAsync(ADSIGRP_SYM_VERSION, 0, length=1, ...)`
  with `AdsTransMode.OnChange`. On callback, clear `_handleCache` + log.

**Beckhoff.TwinCAT.Ads API**:
- `AdsClient.AddDeviceNotificationAsync(uint indexGroup, uint indexOffset,
  int length, NotificationSettings, object userData, ct)` — the raw,
  index-group-based variant (not the symbol-name `Ex` variant we use today).
- Index group: `AdsReservedIndexGroup.SymbolVersion` (0xF008). One byte
  payload that's the current symbol-version counter. Confirm during PR — open
  question (c) below.

**Test plan**:
- Unit: extend `TwinCATNativeNotificationTests` — `FakeTwinCATClient` exposes
  a `FireSymbolVersionChange()` method; test asserts handle cache is cleared
  and subsequent reads recreate handles.
- Integration: `[TwinCATFact]` triggers an online change on the TCBSD project
  (rebuild a GVL with one new variable + login activate) — needs a project
  helper that automates the online-change. May ship behind a manual gate
  (`[TwinCATFact(Reason="requires-manual-online-change")]`) initially.

**Effort**: M (2 days).
**Deps**: PR 2.2 (no point invalidating an empty cache). Confirm
`SymbolVersion` index-group constant in `Beckhoff.TwinCAT.Ads` v6 — open
question (c) below.

**Docs / fixture / e2e**:
- Docs: `docs/drivers/TwinCAT-Test-Fixture.md` section on "What it does NOT
  cover" — drop the implicit "online-change handling" gap. New paragraph in
  the perf section noting handle cache is now self-invalidating.
  `docs/Driver.TwinCAT.Cli.md` no change (transparent to CLI user).
- Fixture (TCBSD PLC project): no schema change. Operator workflow gains an
  online-change drill — `TwinCatProject/README.md` adds a § "Online-change
  test scenario" describing the steps (open project, add a dummy variable
  to `GVL_Perf`, "Login + Activate" → triggers the symbol-version bump).
  This is the manual gate for the integration assertion.
- Integration tests: new `Driver_invalidates_handle_cache_on_symbol_version_bump`
  `[TwinCATFact]` — initially gated `[TwinCATFact(Reason="requires-manual-online-change")]`
  until automation lands. Unit tests cover the callback path via
  `FakeTwinCATClient.FireSymbolVersionChange()`.
- E2E: no change.

### Phase 3 — Operability

#### PR 3.1 — Per-tag MaxDelay tuning

**Scope**: Today `NotificationSettings` is hard-coded as `(OnChange, cycleMs,
0)` (`AdsTwinCATClient.cs:144-145`). MaxDelay=0 means "fire as soon as the
change is detected, no coalescing"; for bursty high-frequency signals this
floods the OPC UA subscription queue. Surface MaxDelay as a per-tag option
(default 0 to preserve current behavior).

**Files**:
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverOptions.cs` — add
  `int? MaxDelayMs` to `TwinCATTagDefinition`.
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs::SubscribeAsync` —
  pass through to client.
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs::AddNotificationAsync`
  — accept `int maxDelayMs`, plumb into `NotificationSettings(...,
  cycleMs, maxDelayMs)`.

**Beckhoff.TwinCAT.Ads API**: `NotificationSettings(AdsTransMode mode, int
cycleTime, int maxDelay)` — both args in milliseconds per Beckhoff InfoSys
`tcadsnetref/7313319051`.

**Test plan**:
- Unit: extend `TwinCATNativeNotificationTests` — assert the plumbed
  `maxDelayMs` lands on `NotificationSettings`.
- Integration: subscribe to `GVL_Fixture.nCounter` with `MaxDelayMs=500`;
  assert delivery rate is ≤ 2 Hz even when PLC cycle is 10 ms.

**Effort**: S (half day).
**Deps**: none.

**Docs / fixture / e2e**:
- Docs: `docs/Driver.TwinCAT.Cli.md` `subscribe` flag table — add `--max-delay-ms`
  with default `0` and a note that nonzero coalesces high-frequency PLC
  signals. Update the description of `-i` / `--interval-ms` to disambiguate
  cycle vs. max-delay (both pass through to `NotificationSettings`).
  `docs/drivers/TwinCAT-Test-Fixture.md` "Notification coalescing under
  jitter" caveat — noting per-tag MaxDelay is now configurable.
- Fixture (TCBSD PLC project): no change required — `GVL_Fixture.nCounter`
  already increments on every 10 ms cycle (see `MAIN.TcPOU`), so the test
  can drive a 100 Hz change rate and verify ≤ 2 Hz delivery with
  `MaxDelayMs=500`. README "Required project state" gets a one-line note
  that the counter doubles as the coalescing-test driver.
- Integration tests: new `Driver_coalesces_notifications_at_max_delay`
  `[TwinCATFact]` subscribes to `GVL_Fixture.nCounter` with `MaxDelayMs=500`
  and asserts delivered-event count ≤ 3 over a 1 s window.
- E2E: `scripts/e2e/test-twincat.ps1` `Test-SubscribeSeesChange` is a
  one-shot subscribe; no change. A future high-rate variant could test
  coalescing end-to-end through the OPC UA bridge but it's not on the
  critical path.

#### PR 3.2 — Cycle-time / jitter / PLC-state diagnostics

**Scope**: Probe loop today only checks reachability via `ReadStateAsync`
(`TwinCATDriver.cs::ProbeLoopAsync`). Surface cycle-time, jitter, and online-
change counter as health signals via the standard `_AppInfo` /
`TwinCAT_SystemInfoVarList._AppInfo` GVL (the same one we filter out of
discovery). Specifically:

- `_AppInfo.OnlineChangeCnt` (UDINT) — incremented on every online change.
- `_AppInfo.AppName` (STRING) — TC project name, useful for
  cross-instance identification.
- `_TaskInfo[1].CycleTime` (UDINT, 100 ns units) — the configured PLC cycle.
- `_TaskInfo[1].LastExecTime` (UDINT, 100 ns units) — most recent measured
  cycle execution; jitter is the delta against `CycleTime`.

**Files**:
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs::ProbeLoopAsync` —
  augment success path to also read these four symbols. Surface via a new
  `TwinCATDeviceDiagnostics` record on `DeviceState`. Emit through
  `IDriverDiagnostics` (the cross-driver diagnostics surface introduced for
  Modbus prohibition events — task #154).
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATSystemSymbolFilter.cs` — leave
  the filter as-is for the user-visible browse; the probe path reads system
  symbols directly without going through discovery.

**Beckhoff.TwinCAT.Ads API**: still `AdsClient.ReadValueAsync(symbol, type,
ct)`. The symbols are read by name, not by index group, so no new API.

**Test plan**:
- Unit: `FakeTwinCATClient` exposes `SetSystemSymbolValue(string name, object
  value)` so tests can drive the diagnostics surface deterministically.
- Integration: `[TwinCATFact]` connects to TCBSD, asserts the diagnostics
  block populates `CycleTimeMs > 0` and `OnlineChangeCnt >= 0` within one
  probe interval.

**Effort**: M (1-2 days).
**Deps**: confirm `IDriverDiagnostics` shape from existing Modbus diagnostics
RPC (task #154 in MEMORY); it should be reusable.

**Docs / fixture / e2e**:
- Docs: new section "Diagnostics" in `docs/drivers/TwinCAT-Test-Fixture.md`
  documenting the four exposed signals (cycle time, jitter, online-change
  counter, app name) and where they surface in the cross-driver
  diagnostics RPC. `docs/Driver.TwinCAT.Cli.md` `probe` section gains a
  "Health probe" sub-section noting the same symbols can be read directly
  via `probe -s "TwinCAT_SystemInfoVarList._AppInfo.OnlineChangeCnt"`
  (the existing example) plus the new `_TaskInfo[1].CycleTime` /
  `LastExecTime`. Add `docs/v3/twincat-backlog.md` cross-link confirming
  cycle-time/jitter no longer deferred.
- Fixture (TCBSD PLC project): no change required — `_AppInfo` and
  `_TaskInfo[1]` are TwinCAT system GVLs, present on every runtime. The
  `TwinCATSystemSymbolFilter` already drops them from user browse;
  `TwinCatProject/README.md` adds a one-line "These symbols are read by
  the probe loop, not project-defined" callout.
- Integration tests: new `Probe_loop_surfaces_cycle_time_and_online_change_count`
  `[TwinCATFact]` asserts the diagnostics record populates within one
  probe interval against TCBSD. Unit tests via `FakeTwinCATClient.SetSystemSymbolValue`
  drive the diagnostics surface deterministically.
- E2E: no change. Future enhancement could expose driver diagnostics via a
  CLI subcommand (`otopcua-twincat-cli diagnostics -n ...`) — captured in
  the consolidated section below as a follow-up.

### Phase 4 — UDT decomposition with TMC parsing

#### PR 4.1 — Nested UDT browse via TMC parsing

**Scope**: Largest single piece of work in the plan. `TwinCATDataType.Structure`
exists but `BrowseSymbolsAsync` skips non-atomic symbols
(`AdsTwinCATClient.cs:224`); to expose nested UDT trees we either:

1. **Online**: walk the `IDataType` tree returned by `SymbolLoaderFactory` —
   each `IStructType` exposes `SubItems` recursively. This is what
   `Beckhoff.TwinCAT.Ads` v6's TypeSystem already gives us at runtime; we just
   never recursed.
2. **Offline (TMC file)**: parse the TwinCAT Module Class XML file the project
   compiles to (`*.tmc`), build a type catalogue, drive discovery from it
   without requiring a live runtime.

We ship the **online** path first (PR 4.1) because it covers 100% of the case
where the runtime is reachable, and `SymbolLoaderFactory` already does the
heavy lifting. TMC offline parsing is deferred to a hypothetical PR 4.2 if a
disconnected-discovery use case emerges (unlikely; live integration tests
demonstrate runtime is always available in our deployments).

**Files**:
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs` —
  `BrowseSymbolsAsync` recurses into `IStructType.SubItems`, yielding one
  `TwinCATDiscoveredSymbol` per leaf with the dotted instance path
  (`MyStruct.Inner.Field`). For arrays-of-structs, expand element-by-element
  up to a configurable bound (default 1024) — beyond that, expose only the
  array root with `IsArray=true`.
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs::DiscoverAsync` —
  fold the recursed structure into the existing `Discovered/` folder tree
  using `IAddressSpaceBuilder.Folder` for each struct member.
- New: `TwinCATTypeWalker.cs` — pure helper that takes an `IDataType` and
  yields `(instancePath, atomicType, readOnly)` tuples. Unit-testable without
  touching `AdsClient`.

**Beckhoff.TwinCAT.Ads API**:
- `TwinCAT.TypeSystem.IStructType` — `SubItems` (collection of
  `IMember`); each member has `BaseType`, `Name`, `Offset`.
- `TwinCAT.TypeSystem.IArrayType` — `Dimensions`, `BaseType`.
- `TwinCAT.TypeSystem.IEnumType` — handled in PR 1.5 (atomic surface).
- `TwinCAT.TypeSystem.IAliasType.BaseType` — recurse until atomic.

**Test plan**:
- Unit: new `TwinCATTypeWalkerTests` — feed synthetic `IDataType` trees,
  assert the flattened paths and types.
- Integration: extend `GVL_Plant` (already has `Line1.Stations[1].Axes[1].Motor`
  per `TwinCAT3SmokeTests.cs`) — the existing `Driver_reads_deeply_nested_UDT_path`
  test reads a known-leaf path; add a new test that browses into the same
  GVL and asserts the entire tree shape matches expectation. Should yield
  ~50+ leaves.

**Effort**: L (4-5 days). Most of the cost is in the addressspace-builder
folder/variable plumbing, not the type walking itself.
**Deps**: PR 1.5 (ENUM/ALIAS) — without it, struct members of enum type
silently drop. PR 1.4 (whole-array reads) is helpful but not blocking.

**Docs / fixture / e2e**:
- Docs: this is the **largest doc-write of the plan**.
  `docs/Driver.TwinCAT.Cli.md` gains a new top-level "UDT decomposition"
  section explaining the dotted-instance browse syntax (`MyStruct.Inner.
  Field`), array-of-struct expansion bound, and how members surface via
  `browse`. The existing `read` example "Nested UDT member" gets expanded
  with a multi-level case targeting the plant hierarchy. `docs/drivers/
  TwinCAT-Test-Fixture.md` "What it actually covers" gets a UDT bullet
  per-member rather than per-leaf. Update `docs/v3/twincat-backlog.md` —
  remove the implicit UDT-decomposition gap.
- Fixture (TCBSD PLC project, primary fixture-extension surface): the
  existing `GVL_Plant.Line1.Stations[1..3].Axes[1..4]...` 5-level
  hierarchy already provides ~50+ leaves per `TwinCatProject/README.md`
  § "5-level plant hierarchy" + § "Live value churn". This PR may add a
  few **edge cases** to stress the type walker:
  - `PLC/DUTs/ST_NestedFlags.TcDUT` — struct containing a BIT-packed
    member (e.g. `Flags : DWORD` with named bit-mask aliases).
  - `PLC/DUTs/ST_RecursiveCap.TcDUT` — struct with a self-pointer (must
    be capped by the type walker, not infinite-recurse). Demonstrates
    POINTER skip behavior.
  - Add an `ARRAY [1..2000] OF ST_AlarmRecord` to exercise the
    `MaxArrayExpansion` (default 1024) cutoff.
  README § "Complex hierarchy" gets the new edge-case DUTs documented.
- Integration tests: new `TwinCATTypeWalkerTests` (unit) feeding synthetic
  `IDataType` trees. Live: `Driver_browses_full_plant_hierarchy_yields_50_plus_leaves`,
  `Driver_caps_array_of_struct_expansion_at_configured_bound`,
  `Driver_handles_self_referential_struct_without_recursion` against the
  new edge-case DUTs.
- E2E: `scripts/e2e/test-twincat.ps1` could gain a UDT-bridge scenario
  (`-BridgeNodeId` pointing at `GVL_Plant.Line1.Stations[1].Axes[1].Motor.
  Temperature`) but this requires the OPC UA server's address-space to
  reflect the decomposed tree — keep as a follow-up after server-side
  rendering ships in v3.

### Phase 5 — TC3 EventLogger alarms

#### PR 5.1 — `IAlarmSource` via TC3 EventLogger

**Scope**: TwinCAT 3.1 build 4022+ ships TcEventLogger as a system service
exposing alarms/events on AMS port 110 (`AMSPORT_EVENTLOG`). Implement
`IAlarmSource` over that interface so PLC alarms surface as OPC UA AC events.

**Open question (b) below** drives the implementation: does Beckhoff publish
a managed wrapper, or do we hit AMS port 110 directly?

If a managed wrapper exists:
- `Beckhoff.TwinCAT.Ads.TcEventLogger` (or similar) — subscribe via
  `EventLogger.AlarmRaised` event.

If not (likely — InfoSys docs lean on `TcCOM` C++ APIs):
- Open a second `AdsClient` connection to port 110 via
  `_secondaryClient.Connect(netId, 110)`.
- Use `AddDeviceNotificationAsync` on the alarm-list index group
  (`ADSIGRP_TCEVENTLOG_ALARMS`, exact constant TBD during spike).
- Decode the binary event payload into `AlarmEvent` records (severity,
  source, message, time-of-occurrence, ack state).

**Files**:
- New: `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATAlarmSource.cs`
  — implements `IAlarmSource` (currently used by Galaxy / Wonderware).
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs` — declare
  `IAlarmSource` interface, delegate to the helper.
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverOptions.cs` — new
  `bool EnableAlarms` (default `false` until production-validated).

**Beckhoff.TwinCAT.Ads API**: TBD pending spike. Falls back to raw
`AdsClient.AddDeviceNotificationAsync` on port 110 if no managed wrapper.

**Test plan**:
- Unit: fake event-logger feeds synthetic alarms; assert `IAlarmSource`
  surface raises events with correct shape.
- Integration: TCBSD project gains an `Alarm.Raise(...)` call site on a GVL
  bool transition; new `[TwinCATFact]` subscribes via the driver, toggles the
  trigger, asserts the alarm appears in the source within 5 s.

**Effort**: L (4-5 days), most of which is the spike. If no managed wrapper
exists, add another L (3-4 days) to implement the binary protocol decoder.
**Deps**: spike answer to open question (b) — surface that as an explicit
investigation PR before committing to the build.

**Docs / fixture / e2e**:
- Docs: **new file** `docs/drivers/TwinCAT.md` (the existing
  `TwinCAT-Test-Fixture.md` is fixture-only) covering the alarm
  configuration surface — `EnableAlarms` option, AMS port 110 routing,
  severity / source / message decode, OPC UA AC mapping. Spike output
  goes to `docs/v3/twincat-eventlogger-spike.md` per open question (b).
  `docs/Driver.TwinCAT.Cli.md` gains a new `alarms` subcommand (subscribe
  + print stream) mirroring the OPC UA Client CLI's `alarms` verb.
  `docs/drivers/TwinCAT-Test-Fixture.md` "Alarms / history" caveat
  removed; capability matrix gets `IAlarmSource = yes`.
- Fixture (TCBSD PLC project, primary fixture-extension surface): add
  `PLC/POUs/FB_AlarmHarness.TcPOU` that calls `FB_TcLogEvent` (or
  equivalent TC3 EventLogger PLC API) on a 5 s tick, raising / clearing
  a known event class. New `PLC/GVLs/GVL_Alarms.TcGVL` exposes the
  trigger booleans the test toggles. `TwinCatProject/README.md` § new
  "Alarm scenarios" subsection documents the event class IDs + severity
  + cleared-on transitions. The existing `ST_Alarm` DUT remains for
  PLC-level data; the EventLogger is the AC source.
- Integration tests: new `TwinCATAlarmIntegrationTests.cs` —
  `Driver_raises_alarm_event_when_PLC_logs_event` `[TwinCATFact]`
  toggles the trigger via `WriteAsync`, asserts the alarm appears in
  `IAlarmSource.AlarmRaised` within 5 s. Includes a clear-event variant.
  Unit tests via fake event-logger feed synthetic alarms.
- E2E: `scripts/e2e/test-twincat.ps1` gains a `Test-AlarmRoundTrip`
  step (toggle PLC trigger → assert event surfaces via OPC UA AC client)
  once the server-side wiring is in. Likely defers to a follow-up PR
  after the server-tier alarm rendering catches up.

## Documentation, fixture, and e2e impact

Consolidated view across all 12 PRs. The **TCBSD fixture PLC project**
(`tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/`) is
the **primary fixture-extension surface** — it's a real TwinCAT XAE project
committed object-by-object as `.TcGVL` / `.TcDUT` / `.TcPOU` files. Most PRs
extend it by adding GVL variables, DUTs (structs / enums / aliases), or POUs
(function blocks driving live data churn). The TCBSD VM at AmsNetId
`41.169.163.43.1.1` on `10.100.0.128` is the deployment target (per memory
entry `project_tcbsd_fixture.md`); the project bypasses the local Hyper-V/RTIME
conflict (per `project_twincat_hyperv_conflict.md`) by running on ESXi.

### User docs touched

| PR | `docs/Driver.TwinCAT.Cli.md` | `docs/drivers/TwinCAT-Test-Fixture.md` | `docs/v3/twincat-backlog.md` | Other |
|---|---|---|---|---|
| 1.1 LINT/ULINT | Data-types caveat removed | Bugs-caught entry #4 | — | — |
| 1.2 TIME/DATE/DT/TOD | Native-type syntax + 4 examples | — | — | — |
| 1.3 Bit-write | `write` example + RMW note | Bugs-caught entry #3 update | — | — |
| 1.4 Arrays | New "Arrays" sub-section + read example | Coverage list bullet | — | — |
| 1.5 ENUM/ALIAS | `browse` data-types rows | Coverage list bullet | — | — |
| 2.1 Sum cmds | — | New "Performance" section | Closed-out perf bullet | — |
| 2.2 Handles | Cache note in `read` / `subscribe` | Perf-section paragraph | — | — |
| 2.3 Sym-version | — | Online-change-handling caveat dropped | — | — |
| 3.1 MaxDelay | `--max-delay-ms` flag | Coalescing caveat updated | — | — |
| 3.2 Diagnostics | `probe` health-symbols sub-section | New "Diagnostics" section | Cycle-time bullet closed | — |
| 4.1 UDT | New top-level "UDT decomposition" section | Coverage list per-member | UDT-decomp gap removed | — |
| 5.1 Alarms | New `alarms` subcommand | "Alarms" caveat removed | — | **New** `docs/drivers/TwinCAT.md`; **new** `docs/v3/twincat-eventlogger-spike.md` |

### TCBSD fixture PLC project changes

| PR | GVL changes | DUT changes | POU changes | README section |
|---|---|---|---|---|
| 1.1 LINT/ULINT | `GVL_Primitives.vLargeCounter`, `vLargeCounterU` | — | — | "GVL_Primitives numeric seeds" |
| 1.2 TIME/DATE/DT/TOD | `GVL_Primitives.dCurrentTime`, `tCycleDuration`, `dToday`, `tShiftStart` | — | — | "Type coverage" seed values |
| 1.3 Bit-write | _(reuse `GVL_Primitives.vWord`)_ | — | — | — |
| 1.4 Arrays | `GVL_Arrays.aReal2D : ARRAY[1..5,1..5] OF REAL` | — | — | "Array coverage" |
| 1.5 ENUM/ALIAS | _(reuse `GVL_Enums`; new `currentSeverity`/`currentTemperature` instance vars)_ | — | — | "Integration-test contract" entry |
| 2.1 Sum cmds | **`GVL_Perf.aTags : ARRAY[1..1000] OF DINT`** | — | New `FB_PerfChurn` driving rotating writes | New "Performance scenarios" subsection |
| 2.2 Handles | _(reuse `GVL_Perf.aTags`)_ | — | — | — |
| 2.3 Sym-version | _(no schema change; manual online-change drill)_ | — | — | New "Online-change test scenario" |
| 3.1 MaxDelay | _(reuse `GVL_Fixture.nCounter` 100 Hz driver)_ | — | — | One-line note in "Required project state" |
| 3.2 Diagnostics | _(reads system GVLs `_AppInfo`, `_TaskInfo[1]`)_ | — | — | Probe-symbols callout |
| 4.1 UDT | _(reuse `GVL_Plant`; possibly grow `aLargeAlarms : ARRAY[1..2000] OF ST_AlarmRecord`)_ | New `ST_NestedFlags`, `ST_RecursiveCap`, `ST_AlarmRecord` | — | "Complex hierarchy" edge-cases |
| 5.1 Alarms | New `GVL_Alarms` (trigger booleans) | — | New `FB_AlarmHarness` calling `FB_TcLogEvent` | New "Alarm scenarios" |

### Integration test additions

All new tests gate on `[TwinCATFact]` / `[TwinCATTheory]` against
`TWINCAT_TARGET_NETID`. Most ship in `tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.
IntegrationTests/TwinCAT3SmokeTests.cs`; PR 5.1 introduces a new
`TwinCATAlarmIntegrationTests.cs`. The existing 30-case suite grows to
roughly **45 cases** end-of-plan, plus a perf-tier guarded behind
`TWINCAT_PERF=1`.

### E2E scripts

`scripts/e2e/test-twincat.ps1` is the single TwinCAT e2e bridge today; it's
gated behind `TWINCAT_TRUST_WIRE=1` (see task #221 — CI fixture). The plan
intentionally **does not change** the canonical bridge for most PRs because
the bridge exercises one DINT counter through the OPC UA server, and that
path stays correct. PRs 1.2 (DT bridge), 1.4 (array bridge), 4.1 (UDT
bridge), 5.1 (alarm round-trip) each list speculative e2e extensions but
they're explicitly marked as follow-ups gated on server-side rendering
catching up.

## Skip-rated items (for context)

These are intentionally not built. Listed for future-reader completeness so
nobody re-invests effort that was already triaged:

| # | Gap | Why skip |
|---|---|---|
| 9 | Multi-target / multi-route AMS gateway | Per-device config in `TwinCATDriverOptions.Devices` already supports N targets |
| 10 | Secure ADS / ADS-over-TLS | Significant work — TC3.1 build 4024+ feature, host-router-level config; defer |
| 11 | Route credential management | Host-level AMS router responsibility (`StaticRoutes.xml`); not driver scope |
| 12 | NC-axis / CNC channel / EtherCAT slave I/O | Specialty; system-symbol filter actively drops `Mc_*` (`TwinCATSystemSymbolFilter.cs:28`) |
| 13 | System-service ports (200/10000) | Niche operational tooling; user-runtime ports cover real use cases |
| 15 | PLC RPC / method invocation | Niche; design-heavy; no demand signal yet |
| 16 | Per-PLC-runtime auto-discover | Cosmetic; manual port config in options works |
| 20 | File-system access via ADS (FOPEN/FREAD) | Niche; out of scope |

## Open questions

1. **(a) TMC parsing — separate library or embedded?**
   Phase 4 ships the **online type-walker** path which uses
   `Beckhoff.TwinCAT.Ads.TypeSystem.SymbolLoaderFactory` and needs a live
   runtime. If a future use case needs offline discovery (e.g. address-space
   pre-bake at build time without a reachable PLC), do we:
   - vendor a TMC-XML parser into this driver, or
   - build a separate `ZB.MOM.WW.OtOpcUa.Tooling.TwinCAT` CLI that emits a
     pre-baked tag manifest?
   The latter cleanly separates build-time tooling from runtime driver code
   and matches how Galaxy.Host is split. Decision deferred until demand
   appears; recommend the CLI route when it does.

2. **(b) Beckhoff TC3 EventLogger NuGet — published, or AMS port 110 raw?**
   Need to spike against the current `Beckhoff.TwinCAT.Ads` v6 NuGet API
   surface. Beckhoff InfoSys lists a `Tc3_EventLogger` PLC library and a
   TcCOM C++ API but the .NET surface is thinner. PR 5.1 starts with a
   one-day spike documented as `docs/v3/twincat-eventlogger-spike.md` before
   committing to the implementation path.

3. **(c) Symbol-version invalidation event details**
   PR 2.3 needs the exact index-group constant and notification semantics for
   the symbol-version counter. `AdsReservedIndexGroup.SymbolVersion` (0xF008)
   is the working hypothesis but the field on the v6 enum needs verification
   — the older `TwinCAT.Ads.AdsReservedIndexGroup` enum had different naming.
   Beckhoff InfoSys `tcadscommon/tcadscommon_indexgroups` is the reference;
   confirm during the PR 2.3 spike. Fallback: poll the version counter at
   probe-loop cadence and treat any change as an invalidation.

## References

- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs`
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs`
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDataType.cs`
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATSymbolPath.cs`
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATSystemSymbolFilter.cs`
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATAmsAddress.cs`
- `docs/featuregaps.md` — TwinCAT (Beckhoff ADS) section
- `docs/v3/twincat-backlog.md` — deferred items (TC2, multi-hop, lab IPC)
- `docs/drivers/TwinCAT-Test-Fixture.md` — TCBSD + XAR fixture details
- Beckhoff InfoSys: <https://infosys.beckhoff.com/english.php?content=../content/1033/tcadsdll2/117571083.html> (Sum commands)
- Beckhoff InfoSys: <https://infosys.beckhoff.com/english.php?content=../content/1033/tcadsnetref/7313319051.html> (NotificationSettings)
- Beckhoff GitHub: <https://github.com/Beckhoff/TC3-AdsClient-Csharp>
