Files
lmxopcua/docs/plans/twincat-plan.md
Joseph Doherty 2d07d716dc Recover stashed driver-gaps work from pre-v2-mxgw-merge working tree
Captures uncommitted work that lived in the working tree on
v2-mxgw-integration but was orthogonal to the migration. Stashed
during the v2-mxgw merge to master (2026-04-30) and replanted here on
a feature branch off master so it's git-visible rather than living in
the stash list.

Two distinct buckets:

1. Tracked fixture/config refinements (10 files, ~36 lines):
   - scripts/e2e/test-opcuaclient.ps1
   - src/ZB.MOM.WW.OtOpcUa.Admin/appsettings.json
   - 5 docker-compose.yml under tests/.../IntegrationTests/Docker/
     (AbCip, Modbus, OpcUaClient, S7)
   - 4 fixture .cs files (AbServerFixture, ModbusSimulatorFixture,
     OpcPlcFixture, Snap7ServerFixture)

2. Untracked driver-gaps queue artifacts (~8000 lines):
   - docs/plans/{abcip,ablegacy,focas,opcuaclient,s7,twincat}-plan.md
     — per-driver gap plans
   - docs/featuregaps.md — cross-cutting analysis
   - docs/v2/focas-deployment.md, docs/v2/implementation/focas-simulator-plan.md
   - followup.md — auto/driver-gaps queue follow-ups
   - scripts/queue/ — PR-queue automation tooling (12 files including
     pr-manifest.yaml at 1473 lines)

This commit is a snapshot for recoverability — review and split into
focused PRs (or discard) before merging anywhere downstream.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 08:28:01 -04:00

48 KiB

TwinCAT Driver — Implementation Plan

Source of gap analysis: featuregaps.md → TwinCAT

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 correctnessLInt/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. OperabilityNotificationSettings(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 decompositionStructure 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.csMapToClrType 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.csMapSymbolTypeName 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 TwinCATNativeNotificationTestsFakeTwinCATClient 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.csBrowseSymbolsAsync 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.IStructTypeSubItems (collection of IMember); each member has BaseType, Name, Offset.
  • TwinCAT.TypeSystem.IArrayTypeDimensions, 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.csDriver_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