# 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 _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(...)` 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 _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(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: (Sum commands) - Beckhoff InfoSys: (NotificationSettings) - Beckhoff GitHub: