TwinCAT PR 2 � IReadable + IWritable #121

Merged
dohertj2 merged 1 commits from twincat-pr2-read-write into v2 2026-04-19 18:34:54 -04:00
Owner

ITwinCATClient abstraction, AdsTwinCATClient default impl wrapping Beckhoff AdsClient (ReadValueAsync ResultAnyValue, WriteValueAsync ResultWrite, ReadStateAsync probe). Per-device connection caching. 14 new tests, 75/75 passing. Merges to v2.

ITwinCATClient abstraction, AdsTwinCATClient default impl wrapping Beckhoff AdsClient (ReadValueAsync ResultAnyValue, WriteValueAsync ResultWrite, ReadStateAsync probe). Per-device connection caching. 14 new tests, 75/75 passing. Merges to v2.
dohertj2 added 1 commit 2026-04-19 18:34:51 -04:00
TwinCAT PR 2 — IReadable + IWritable. ITwinCATClient + ITwinCATClientFactory abstraction — one client per AMS target, reused across reads/writes/probes. Shape differs from AbCip/AbLegacy where libplctag handles are per-tag — TwinCAT's AdsClient is a single connection with symbolic reads/writes issued against it, so the abstraction is coarser. AdsTwinCATClient is the default implementation wrapping Beckhoff.TwinCAT.Ads's AdsClient — ConnectAsync calls AdsClient.Connect(AmsNetId.Parse(netId), port) after setting Timeout in ms; ReadValueAsync dispatches TwinCATDataType to the CLR Type via MapToClrType (bool/sbyte/byte/short/ushort/int/uint/long/ulong/float/double/string/uint for time types) and calls AdsClient.ReadValueAsync(symbol, type, ct) which returns ResultAnyValue; unwraps .Value + .ErrorCode and maps non-NoError codes via TwinCATStatusMapper.MapAdsError. BOOL-within-word reads extract the bit after the underlying word read using ExtractBit over short/ushort/int/uint/long/ulong. WriteValueAsync converts the boxed value via ConvertForWrite (Convert.ToXxx per type) then calls AdsClient.WriteValueAsync returning ResultWrite; checks .ErrorCode for status mapping. BOOL-within-word writes throw NotSupportedException with a pointer to task #181 — same RMW gap as Modbus BitInRegister / AbCip BOOL-in-DINT / AbLegacy bit-within-N-file. ProbeAsync calls AdsClient.ReadStateAsync + checks AdsErrorCode.NoError. TwinCATDriver implements IReadable + IWritable — per-device ITwinCATClient cached in DeviceState.Client, lazy-connected on first read/write via EnsureConnectedAsync, connect-failure path disposes + clears the client so next call re-attempts cleanly. ReadAsync ordered-snapshot pattern matching AbCip/AbLegacy: unknown ref → BadNodeIdUnknown, unknown device → BadNodeIdUnknown, OperationCanceledException rethrow, any other exception → BadCommunicationError + Degraded health. WriteAsync similar — non-Writable tag → BadNotWritable upfront, NotSupportedException → BadNotSupported, FormatException/InvalidCastException (guard pattern) → BadTypeMismatch, OverflowException → BadOutOfRange, generic → BadCommunicationError. Symbol name resolution goes through TwinCATSymbolPath.TryParse(def.SymbolPath) with fallback to the raw def.SymbolPath if the path doesn't parse — the Beckhoff AdsClient handles the final validation at wire time. ShutdownAsync disposes each device's client. 14 new unit tests in TwinCATReadWriteTests using FakeTwinCATClient + FakeTwinCATClientFactory — unknown ref → BadNodeIdUnknown, successful DInt read with Good status + captured value + IsConnected=true after EnsureConnectedAsync, repeat reads reuse the connection (one Connect + multiple reads), ADS error code mapping via FakeTwinCATClient.ReadStatuses, read exception → BadCommunicationError + Degraded health, connect exception disposes the client, batched reads preserve order across DInt/Real/String types, non-Writable rejection, successful write logs symbol+type+value+bit for test inspection, write status-code mapping, write exception → BadCommunicationError, batch preserves order across success/non-writable/unknown, cancellation propagation, ShutdownAsync disposes the client. Total TwinCAT unit tests now 75/75 passing (+14 from PR 1's 61); full solution builds 0 errors; Modbus / AbCip / AbLegacy / other drivers untouched. 28e3470300
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dohertj2 merged commit 2d5aaf1eda into v2 2026-04-19 18:34:54 -04:00
dohertj2 referenced this issue from a commit 2026-04-20 02:30:04 -04:00
Draft ADR-001 — Equipment node walker: how driver tags bind to the UNS address space. Frames the decision blocking task #195 (IdentificationFolderBuilder wire-in): the Equipment-namespace browse tree requires a Config-DB-driven walker that traverses UNS → Equipment → Tag + hangs Identification sub-folders + identifier properties, and the open question is how driver-discovered tags bind to the UNS Equipment nodes the walker materializes. Context section documents what already exists (IdentificationFolderBuilder unused; NodeScopeResolver at Phase-1 cluster-only stub; Equipment + UnsArea + UnsLine + Tag tables with decisions #110 #116 #117 #120 #121 already landed as the data-model contract) vs what's missing (the walker itself + the ITagDiscovery/Config-DB composition strategy). Four options laid out with trade-offs: Option A Config-primary (Tag rows are the sole source of truth; ITagDiscovery becomes enrichment; BadNotFound placeholder when driver can't address a declared tag); Option B Discovery-primary (driver output is authoritative; Config-DB Equipment rows select subsets); Option C Parallel namespaces (driver-native ns + UNS overlay ns cross-referencing via OPC UA Organizes); Option D Config-primary-with-discovery-assist (same as A at runtime, plus an Admin UI offline discovery panel that lets operators one-click-import discovered tags into the draft). Recommendation: Option A now, defer Option D to v2.1. Reasons: matches decision #110's framing straight-through, identical composition across every Equipment-kind driver, Phase 6.4 Admin UI already authors Tag rows, BadNotFound is a legible failure mode, and nothing in A blocks adding D later without changing the walker contract. If the ADR is accepted, spawns two tasks: Task A builds EquipmentNodeWalker in Core.OpcUa (cluster → namespace → area → line → equipment → tag traversal, IdentificationFolderBuilder per Equipment, 5 identifier properties, BadNotFound placeholders, integration tests); Task B extends NodeScopeResolver to join against Config DB + populate full NodeScope path (unblocks per-Equipment/per-UnsLine ACL granularity + closes task #195 with the ACL integration test from the builder's docstring cross-reference). Consequences-if-we-don't-decide section captures the status quo: Identification metadata ships in DB + Admin UI but never reaches the OPC UA endpoint, external consumers can't resolve equipment via OPC UA properties as decision #121 promises, and NodeScopeResolver stays cluster-level so finer ACL grants are effectively cluster-wide at dispatch (Phase 6.2 rollout limitation, not correctness bug). Draft status — seeking decision before spawning the two implementation tasks. If accepted I'll add the tasks + start on Task A.
dohertj2 referenced this issue from a commit 2026-04-20 02:40:46 -04:00
EquipmentNodeWalker — pure-function UNS tree materialization (ADR-001 Task A, task #210). The walker traverses the Config-DB snapshot for a single Equipment-kind namespace (Areas / Lines / Equipment / Tags) and streams IAddressSpaceBuilder.Folder + Variable + AddProperty calls to materialize the canonical 5-level Unified Namespace browse tree that decisions #116-#121 promise external consumers. Pure function: no OPC UA SDK dependency, no DB access, no state — consumes pre-loaded EF Core row collections + streams into the supplied builder. Server-side wiring (load snapshot → call walker → per-tag capability probe) is Task B's scope, alongside NodeScopeResolver's Config-DB join + the ACL integration test that closes task #195. This PR is the Core.OpcUa primitive the server will consume. Walk algorithm — content is grouped up-front (lines by area, equipment by line, tags by equipment) into OrdinalIgnoreCase dictionaries so the per-level nested foreach stays O(N+M) rather than O(N·M) at each UNS level; orderings are deterministic on Name with StringComparer.Ordinal so diffs across runs (e.g. integration-test assertions) are stable. Areas → Lines → Equipment emitted as Folder nodes with browse-name = Name per decision #120. Under each Equipment folder: five identifier properties per decision #121 (EquipmentId + EquipmentUuid always; MachineCode always — it's a required column on the entity; ZTag + SAPID skipped when null to avoid empty-string property noise); IdentificationFolderBuilder.Build materializes the OPC 40010 sub-folder when HasAnyFields(equipment) returns true, skipped otherwise to avoid a pointless empty folder; then one Variable node per Tag row bound to this Equipment (Tag.EquipmentId non-null matches Equipment.EquipmentId) emitted in Name order. Tags with null EquipmentId are walker-skipped — those are SystemPlatform-kind (Galaxy) tags that take the driver-native DiscoverAsync path per decision #120. DriverAttributeInfo construction: FullName = Tag.TagConfig (driver-specific wire-level address); DriverDataType parsed from Tag.DataType which stores the enum name string per decision #138; unparseable values fall back to DriverDataType.String so a one-off driver-specific type doesn't abort the whole walk (driver still sees the original address at runtime + can surface its own typed value via the variant). Address validation is deliberately NOT done at build time per ADR-001 Option A: unreachable addresses surface as OPC UA Bad status via the natural driver-read failure path at runtime, legible to operators through their Admin UI + OPC UA client inspection. Eight new EquipmentNodeWalkerTests: empty content emits nothing; Area/Line/Equipment folder emission order matches Name-sorted deterministic traversal; five identifier properties appear on Equipment nodes with correct values, ZTag + SAPID skipped when null + emitted when non-null; Identification sub-folder materialized when at least one OPC 40010 field is non-null + omitted when all are null; tags with matching EquipmentId emit as Variable nodes under the Equipment folder in Name order, tags with null EquipmentId walker-skipped; unparseable DataType falls back to String. RecordingBuilder test double captures Folder/Variable/Property calls into a tree structure tests can navigate. Core project builds 0 errors; Core.Tests 190/190 (was 182, +8 new walker tests). No Server/Admin changes — Task B lands the server-side wiring + consumes this walker from DriverNodeManager.
dohertj2 referenced this issue from a commit 2026-04-20 03:11:17 -04:00
ADR-001 wire-in — EquipmentNodeWalker runs inside OpcUaApplicationHost before driver DiscoverAsync, closing tasks #212 + #213. Completes the in-server half of the ADR-001 Option A story: Task A (PR #153) shipped the pure-function walker in Core.OpcUa; Task B (PR #154) shipped the NodeScopeResolver + ScopePathIndexBuilder + evaluator-level authz proof. This PR lands the BuildAddressSpaceAsync wire-in the walker was always meant to plug into + a full-stack OPC UA client-browse integration test that proves the UNS folder skeleton is actually visible to real UA clients end-to-end, not just to the RecordingBuilder test double. OpcUaApplicationHost gains an optional ctor parameter equipmentContentLookup of type Func<string, EquipmentNamespaceContent?>? — when supplied + non-null for a driver instance, EquipmentNodeWalker.Walk is invoked against that driver's node manager BEFORE GenericDriverNodeManager.BuildAddressSpaceAsync streams the driver's native DiscoverAsync output on top. Walker-first ordering matters: the UNS Area/Line/Equipment folder skeleton + Identification sub-folders + the five identifier properties (decision #121) are in place so driver-native references (driver-specific tag paths) land ALONGSIDE the UNS tree rather than racing it. Callers that don't supply a lookup (every existing pre-ADR-001 test + the v1 upgrade path) get identical behavior — the null-check is the backward-compat seam per the opt-in design sketched in ADR-001. The lookup delegate is driver-instance-scoped, not server-scoped, so a single server with multiple drivers can serve e.g. one Equipment-kind namespace (Galaxy proxy with a full UNS) alongside several native-kind namespaces (Modbus / AB CIP / TwinCAT / FOCAS that do not have their own UNS because decisions #116-#121 scope UNS to Equipment-kind only). SealedBootstrap.Start will wire this lookup against the Config-DB snapshot loader in a follow-up — the lookup plumbing lands first so that wiring reduces to one-line composition rather than a ctor-signature churn. New OpcUaEquipmentWalkerIntegrationTests spins up a real OtOpcUaServer on a non-default port with an EmptyDriver that registers with zero native content + a lookup that returns a seeded EquipmentNamespaceContent (one area warsaw / one line line-a / one equipment oven-3 / one tag Temperature). An OPC UA client session connects anonymously against the un-secured endpoint, browses the standard hierarchy, + asserts: (a) area folder warsaw contains line-a folder as a child; (b) line folder line-a contains oven-3 folder as a child; (c) equipment folder oven-3 contains EquipmentId + EquipmentUuid + MachineCode identifier properties — ZTag + SAPID correctly absent because the fixture leaves them null per decision #121 skip-when-null behavior; (d) the bound Tag emits a Variable node under the equipment folder with NodeId == Tag.TagConfig (the wire-level driver address) + the client can ReadValue against it end-to-end through the DriverNodeManager dispatch path. Because the EmptyDriver's DiscoverAsync is a no-op the test proves UNS content came from the walker, not the driver — the original ADR-001 question "what actually owns the browse tree" now has a mechanical answer visible at the OPC UA wire level. Test class uses its own port (48500+rand) + per-test PKI root so it runs in parallel with the existing OpcUaServerIntegrationTests fixture (48400+rand) without binding or cert collisions. Server project builds 0 errors; Server.Tests 181/181 (was 179, +2 new full-stack walker tests). Task #212 + #213 closed; the follow-up SealedBootstrap wiring is the natural next pickup because the ctor plumbing lands here + that becomes a narrow downstream PR.
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: dohertj2/lmxopcua#121