diff --git a/docs/deployments/wonder-app-vd03-makino-z-34184.md b/docs/deployments/wonder-app-vd03-makino-z-34184.md index 244f82f2..7337f6cd 100644 --- a/docs/deployments/wonder-app-vd03-makino-z-34184.md +++ b/docs/deployments/wonder-app-vd03-makino-z-34184.md @@ -67,3 +67,16 @@ returning `Bad_WaitingForInitialData` until the rebuilt `ZB.MOM.WW.OtOpcUa.Drive self-contained host publish) is deployed to `E:\ApiInstall\OtOpcUa\` and `OtOpcUaHost` is restarted. Once redeployed, `parts-count`/`parts-required` should go Good (FixedTree + PMC/Parameter still pending the follow-on v3 command work). + +## FixedTree under the Equipment node (feature built 2026-06-26) + +The FOCAS **FixedTree** (Identity / Axes / Spindle / Program / Timers) now surfaces under the equipment as +read-only value nodes, via a generic post-connect `ITagDiscovery` injection feature (branch +`feat/focas-fixedtree-equipment-injection`; design + plan at +[`docs/plans/2026-06-26-otopcua-fixedtree-equipment-injection-design.md`](../plans/2026-06-26-otopcua-fixedtree-equipment-injection-design.md) +and [`…-injection.md`](../plans/2026-06-26-otopcua-fixedtree-equipment-injection.md)). After the driver +connects and its `FixedTreeCache` populates (~0–2 s), nodes are grafted at e.g. +`ns=2;s=EQ-3686c0272279/FOCAS/Identity/SeriesNumber` and `…/FOCAS/Axes/X/AbsolutePosition`, carrying live +values through the same path as the authored `parts-count`/`parts-required` tags, and survive redeploys. +**Offline-complete + end-to-end-green; live-validate on the next host deploy** by browsing +`ns=2;s=EQ-3686c0272279/FOCAS/…`. diff --git a/docs/plans/2026-06-25-otopcua-equipment-dataplane-investigation.md b/docs/plans/2026-06-25-otopcua-equipment-dataplane-investigation.md index b59dc727..10208169 100644 --- a/docs/plans/2026-06-25-otopcua-equipment-dataplane-investigation.md +++ b/docs/plans/2026-06-25-otopcua-equipment-dataplane-investigation.md @@ -73,8 +73,10 @@ Read live `OtOpcUaConfig` on `wonder-sql-vd03` (query run on-box so the SQL pass **Refuted by this read:** prime-suspect `DriverInstanceId` attribution mismatch (matches exactly) and H5 blank-`deviceHostAddress` (present). The deployed config is **clean**. ⇒ symptom #1 is a pure value-flow-plumbing break. New live leads: **`PollGroupId=NULL`** on both tags (is a poll group required to subscribe/poll?) and the **resolver-registration** path (equipment-tag refs are "resolver-produced, not seeded at `InitializeAsync`" per `FocasDriver.cs:247` — does poll-time `TryResolve` of the JSON-blob ref ever succeed?). A second offline subagent trace of DriverHostActor↔DriverInstanceActor↔PollGroupEngine↔resolver is running to pin the exact broken link. -### ⚠️ FixedTree feature (symptom #2 — user chose "build the feature") — ARCHITECTURE REALITY -Mapped the composition pipeline. Two address-space paths exist: (1) **Equipment/UNS projection** `AddressSpaceComposer.Compose` (config entities only) → `AddressSpaceApplier.MaterialiseEquipmentTags` → the served `ns=2` tree where `EQ-…` lives; (2) **raw-driver namespace** `GenericDriverNodeManager.BuildAddressSpaceAsync` → `driver.DiscoverAsync(IAddressSpaceBuilder)`. **Path 2 is DEAD: `BuildAddressSpaceAsync` has no runtime caller and `OpcUaApplicationHost.PopulateAddressSpaces` (its referenced caller) no longer exists.** Even `GalaxyDriver.DiscoverAsync` (`:588`) is reachable only via that dead path — Galaxy surfaces its hierarchy by being **authored as config equipment/tags**, not via discovery. ⇒ In the current Equipment-kind model **every served node is config-driven; `ITagDiscovery`/`DiscoverAsync` is legacy/dead for serving.** So "build the FixedTree feature" is NOT re-wiring an existing path — it's a **new dynamic-node-injection capability** into the Equipment projection, and it must solve a **timing problem**: composition runs at deploy/apply (before the driver connects), but FixedTree data only exists after the driver's async `FixedTreeCache` bootstrap. The far cheaper alternative that yields the same visible result is to **author FixedTree signals as config Tag rows** (each bound to a FOCAS fixed-tree reference) — same mechanism every other equipment tag uses. **Recommend re-confirming scope with the user given this cost delta before building.** +### ✅ FixedTree feature (symptom #2) — BUILT 2026-06-26 (architecture reality below) +Mapped the composition pipeline. Two address-space paths exist: (1) **Equipment/UNS projection** `AddressSpaceComposer.Compose` (config entities only) → `AddressSpaceApplier.MaterialiseEquipmentTags` → the served `ns=2` tree where `EQ-…` lives; (2) **raw-driver namespace** `GenericDriverNodeManager.BuildAddressSpaceAsync` → `driver.DiscoverAsync(IAddressSpaceBuilder)`. **Path 2 is DEAD: `BuildAddressSpaceAsync` has no runtime caller and `OpcUaApplicationHost.PopulateAddressSpaces` (its referenced caller) no longer exists.** Even `GalaxyDriver.DiscoverAsync` (`:588`) is reachable only via that dead path — Galaxy surfaces its hierarchy by being **authored as config equipment/tags**, not via discovery. ⇒ In the current Equipment-kind model **every served node is config-driven; `ITagDiscovery`/`DiscoverAsync` is legacy/dead for serving.** So "build the FixedTree feature" is NOT re-wiring an existing path — it's a **new dynamic-node-injection capability** into the Equipment projection, and it must solve a **timing problem**: composition runs at deploy/apply (before the driver connects), but FixedTree data only exists after the driver's async `FixedTreeCache` bootstrap. The far cheaper alternative that yields the same visible result is to **author FixedTree signals as config Tag rows** (each bound to a FOCAS fixed-tree reference) — same mechanism every other equipment tag uses. (The user chose to **build the dynamic feature** over the config-rows alternative.) + +**✅ BUILT (2026-06-26).** Implemented as a generic **post-connect `ITagDiscovery` injection pipeline**: when a driver reaches `Connected`, `DriverInstanceActor` runs bounded re-discovery into a capturing `IAddressSpaceBuilder` and ships `DiscoveredNodesReady` to `DriverHostActor`, which maps the nodes under the equipment (`EQ-…/FOCAS/…`, read-only), extends the `_nodeIdByDriverRef` routing map, and tells `OpcUaPublishActor` to incrementally materialise them — reusing the existing materialize→subscribe→poll→push pipeline (no full rebuild). Survives redeploys (re-applied at the tail of `PushDesiredSubscriptions`) and restarts (re-discovered on reconnect). Design: [`2026-06-26-otopcua-fixedtree-equipment-injection-design.md`](2026-06-26-otopcua-fixedtree-equipment-injection-design.md); implementation plan (11 bite-sized tasks, all green): [`2026-06-26-otopcua-fixedtree-equipment-injection.md`](2026-06-26-otopcua-fixedtree-equipment-injection.md). **Offline-complete** on branch `feat/focas-fixedtree-equipment-injection` (solution build 0 errors / 0 warnings; Runtime.Tests 312, OpcUaServer.Tests 304, FOCAS 247 + an end-to-end injection+value-flow test, all green). The review chain caught + fixed three real defects (a `DriverDataType→OPC-UA-type` string mismatch, a `Server.ReportEvent`-under-lock deadlock, and a `ConfigureAwait(false)` off-actor-context crash for async drivers). **Live wonder validation pending** (deploy the current host + browse `ns=2;s=EQ-3686c0272279/FOCAS/Identity/SeriesNumber`, `…/FOCAS/Axes/X/AbsolutePosition`). ### 🎯 ROOT CAUSE — symptom #1 (CONFIRMED, 2026-06-25, 2nd subagent trace + code verify) **The FOCAS poll read hangs forever because (1) all wire I/O for a device shares one socket with NO serialization, and (2) the steady-state read has NO timeout.** @@ -149,7 +151,7 @@ After the self-contained overlay (current Host) + two light single-DLL FOCAS swa 4. **`FlexibleStringConverter`** on the FOCAS config `Series` — the AdminUI persists the enum as a number (`"series":6`); the factory now tolerates number-or-string instead of throwing → stub. 5. **Scheme-less host tolerance** in `FocasHostAddress.TryParse` — the AdminUI persists `hostAddress` as a bare `ip:port`; `TryParse` now accepts it (canonical `focas://` unchanged) instead of failing init. - FOCAS test suite **247 green**; each fix carries a regression test. -- **Follow-up (product quality):** the AdminUI authors FOCAS configs (`series` as number, `hostAddress` without `focas://`) that the driver only now tolerates — the AdminUI↔driver config-format mismatch is worth reconciling at the source. Also: the shared `AddZbSerilog` not setting static `Serilog.Log.Logger` is a latent gap across all 3 apps. And the FixedTree-under-Equipment feature (task #14) is still pending. +- **Follow-up (product quality):** the AdminUI authors FOCAS configs (`series` as number, `hostAddress` without `focas://`) that the driver only now tolerates — the AdminUI↔driver config-format mismatch is worth reconciling at the source. Also: the shared `AddZbSerilog` not setting static `Serilog.Log.Logger` is a latent gap across all 3 apps. The FixedTree-under-Equipment feature (task #14) is now **BUILT** (offline-complete; see the 2026-06-26 design + implementation-plan docs above) — live wonder validation pending. ## Phase 2 — Get OtOpcUa runtime logs on wonder Make the Host emit driver-level logs so the data plane is observable. Options (least invasive first): point the service at a Serilog file sink via config/env, or temporarily run with `DOTNET_ENVIRONMENT=Development` (file sink + dev errors — cf. MxGateway note), or add a console capture. Preserve `appsettings*`/`data\`; restore the env after. Then read: did `InitializeAsync` start the FixedTree loop, does the bootstrap throw (and on which call), is `ReadAsync` invoked for the equipment tags, what does it return. diff --git a/docs/plans/2026-06-26-otopcua-fixedtree-equipment-injection-design.md b/docs/plans/2026-06-26-otopcua-fixedtree-equipment-injection-design.md new file mode 100644 index 00000000..7bccce5f --- /dev/null +++ b/docs/plans/2026-06-26-otopcua-fixedtree-equipment-injection-design.md @@ -0,0 +1,202 @@ +# OtOpcUa — dynamic injection of driver-discovered FixedTree nodes into the Equipment projection (design) + +**Date:** 2026-06-26 +**Status:** ✅ Implemented (2026-06-26) — 11 tasks, offline-complete on branch `feat/focas-fixedtree-equipment-injection` (solution build 0 errors / 0 warnings; Runtime.Tests 312, OpcUaServer.Tests 304, FOCAS 247 + an end-to-end injection+value-flow test, all green). Live wonder validation pending. + +**Follow-ups surfaced during the review chain (not blocking):** +- Config-unchanged driver→equipment **rebind** drops the cached plan but does not itself re-trigger discovery (`ReconcileDrivers` only restarts a child on a `DriverConfig` change) → the FixedTree subtree is absent under the new equipment until the driver's next reconnect/restart re-discovers it. +- **Multi-device-per-driver** equipment mapping is deferred (today strictly 1:1; the equipment is resolved from authored `EquipmentTags`, so a driver needs ≥1 authored tag for its FixedTree to graft — `EquipmentNode` carries no `DriverInstanceId`). +- Per-(re)connect re-discovery runs for **every `ITagDiscovery` driver** (Galaxy/OpcUaClient/TwinCAT too), bounded by stop-on-stable + an attempt cap; narrowing/opt-in for heavy network drivers is a follow-up. +- The end-to-end test asserts the recording-sink contract, not the real `OtOpcUaNodeManager` `BadWaitingForInitialData`→Good seed-overwrite at the OPC node layer — that is covered by the live wonder deploy. +**Companion to:** [`2026-06-25-otopcua-equipment-dataplane-investigation.md`](2026-06-25-otopcua-equipment-dataplane-investigation.md) (symptom #1 — live FOCAS values — FIXED + deployed; this design addresses **symptom #2**). +**Base branch:** `fix/focas-poll-io-serialization` (this feature builds on the now-deployed driver-host bootstrap re-spawn + FOCAS I/O fixes; that branch is ahead of `master` and not yet merged). + +--- + +## Problem + +Deployed FOCAS equipment serves only its **authored** config tags (`parts-count`/`parts-required`). The driver's +**FixedTree** (Identity / Axes / Spindle / Program / Timers — the auto-discovered CNC structure) **never appears** under +the served Equipment/UNS address space. + +**Root cause (confirmed in the investigation, H2):** the served Equipment tree is built **purely from Config-DB entities** +(`AddressSpaceComposer.Compose` → `AddressSpaceApplier` → node manager). The only code that emits FixedTree nodes is +`ITagDiscovery.DiscoverAsync` (each driver implements it), reachable **only** through `GenericDriverNodeManager.BuildAddressSpaceAsync` +— which has **no runtime caller** (its referenced host method `OpcUaApplicationHost.PopulateAddressSpaces` no longer exists). +So `DiscoverAsync`/`ITagDiscovery` is **dead for serving**: every served node is config-driven, and nothing surfaces a +driver's discovered hierarchy. + +Surfacing FixedTree under the Equipment node is therefore a **new dynamic-node-injection capability**, and it must solve a +**timing problem**: composition runs at deploy/apply time (before the driver connects), but the FixedTree shape +(axis count, spindle presence, which sections exist) is **capability-discovered ~0–2 s after the driver connects** +(`FocasDriver` populates `state.FixedTreeCache` in its bootstrap loop). + +## Goal + +After a driver connects, dynamically graft its discovered FixedTree nodes into the served Equipment projection under a +driver-named subfolder, e.g.: + +``` +ns=2;s=EQ-3686c0272279 (equipment "z-34184") + ├── parts-count (authored config tag — unchanged) + ├── parts-required (authored config tag — unchanged) + └── FOCAS (NEW — driver-named discovered subfolder) + ├── Identity/{SeriesNumber, Version, MaxAxes, CncType, MtType, AxisCount} + ├── Axes/{/{AbsolutePosition, MachinePosition, RelativePosition, DistanceToGo}, FeedRate/Actual, SpindleSpeed/Actual} + ├── Spindle/{/{Load, MaxRpm}} (capability-gated) + ├── Program/{Name, ONumber, Number, MainNumber, Sequence, BlockCount} (capability-gated) + ├── OperationMode/{Mode, ModeText} (capability-gated) + └── Timers/{PowerOnSeconds, OperatingSeconds, CuttingSeconds, CycleSeconds} (capability-gated) +``` + +Read-only value nodes carrying live values (e.g. `EQ-…/FOCAS/Axes/X/AbsolutePosition` reads Good). + +## Decisions (locked with the user 2026-06-26) + +| Decision | Choice | +|---|---| +| Driver scope | **Generic** — keyed off the shared `ITagDiscovery` interface (FOCAS, Galaxy, Modbus all implement it). FOCAS is the first/test consumer; others get it for free. **Zero per-driver code changes.** | +| Tree placement | **Under a driver-named subfolder** — `EQ-…/FOCAS/…` (collision-safe vs. authored tags; self-describing). | +| Device-host folder | **Collapse** the single device-host level → `EQ-…/FOCAS/Identity/…` (not `EQ-…/FOCAS/10.201.31.5:8193/Identity/…`), valid because today's deployment is strictly 1:1 driver↔equipment↔device. | +| Model-change notification | **Emit `GeneralModelChangeEvent`** after a runtime add so already-connected OPC UA clients can refresh their browse. | +| Multi-device-per-driver | **Deferred** (documented follow-up) — today is 1:1. | +| Discovered alarms | **Out of scope** — this feature surfaces value nodes only; alarms continue to come via the config path. | +| Writable discovered nodes | **Out of scope** — FixedTree is read-only CNC state. | + +## Approach (chosen): runtime post-connect injection via the actor pipeline + +Treat discovered FixedTree nodes as **"synthetic equipment tags" injected at runtime**, reusing the existing +materialize → subscribe → poll → push pipeline end-to-end. Only three new pieces; **no driver changes** (each driver's +existing `DiscoverAsync` is reused verbatim via a capturing builder). + +**Rejected alternatives:** +- *Composition-time pre-projection* — can't author the right nodes before the driver discovers capabilities; defeats the purpose. +- *Resurrect `GenericDriverNodeManager` as a 2nd namespace (ns=3)* — puts FixedTree in a separate tree (not **under** the equipment node), and that namespace's value-routing is also dead; more dead code to revive, wrong location. +- *Cheap baseline: author a Config-DB Tag row per FixedTree signal* — no new code, but static (can't adapt to per-CNC capabilities) and per-signal × per-machine manual authoring. User chose to build the dynamic feature instead. + +## Components + +### 1. `CapturingAddressSpaceBuilder` (new — runtime) +An `IAddressSpaceBuilder` implementation that **records** the streamed tree instead of creating OPC UA nodes. After a +driver's `DiscoverAsync(builder)` returns, it exposes a flat `IReadOnlyList`: + +``` +DiscoveredNode { + IReadOnlyList FolderPathSegments, // e.g. ["FOCAS", "", "Identity"] + string BrowseName, string DisplayName, + string FullReference, // == DriverAttributeInfo.FullName (the driver ref + routing key) + DriverDataType DataType, bool IsArray, uint? ArrayDim, + bool Writable, bool IsHistorized +} +``` + +- `Folder(browse, display)` returns a child capturing scope; `Variable(...)` records a node and returns an + `IVariableHandle` whose `FullReference` is `DriverAttributeInfo.FullName`. +- `MarkAsAlarmCondition(...)` returns a **no-op** sink; `AddProperty(...)` is **ignored** — value nodes only. + +### 2. `DriverInstanceActor` — post-connect discovery (bounded retry) +On entering `Connected`, kick a bounded re-discovery: +1. Run `DiscoverAsync(capturingBuilder)` against the live `IDriver` it owns. +2. `Tell` the parent `DriverHostActor` a new message `DiscoveredNodesReady(DriverInstanceId, IReadOnlyList)`. +3. Because FOCAS suppresses FixedTree until `FixedTreeCache` populates (~0–2 s), **retry** every ~2 s up to a cap + (~30 s) **or until the captured set stops growing**, then stop. `DiscoverAsync` reads the in-memory cache (no extra + wire I/O), so retries are cheap. Re-runs on every reconnect (downstream is idempotent). + +*(Drivers whose discovery is ready immediately — e.g. Galaxy/Modbus — satisfy this on the first attempt.)* + +### 3. `DriverHostActor` — injection handler +On `DiscoveredNodesReady(id, nodes)`: +1. Find the equipment bound to the driver instance: `composition.EquipmentNodes` where `DriverInstanceId == id`. + - 0 matches → log Info, skip. >1 match → log Warning, skip (multi-device follow-up). +2. **Dedup** discovered `FullReference`s against authored `EquipmentTags` for that driver (never double-create + `parts-count`, etc.). +3. Map each remaining node to a NodeId `EQ-…/FOCAS//` via `EquipmentNodeIds.Variable(...)` + (collapse the single device-host folder level). +4. **Cache** the mapped result in `_discoveredByDriver[id]` (survives redeploys — see Lifecycle). +5. Update `_nodeIdByDriverRef[(id, FullReference)]` for each. +6. `Tell` `OpcUaPublishActor` a new `MaterialiseDiscoveredNodes(equipmentId, "FOCAS", nodes)`. +7. Merge the new refs into the driver's desired set and re-`Tell` + `DriverInstanceActor.SetDesiredSubscriptions(union, interval, alarmRefs)` — the existing **live path** immediately + re-subscribes (the actor self-`Tell`s `Subscribe` when already `Connected`). + +### 4. `OpcUaPublishActor` / node manager — incremental materialize +New message `MaterialiseDiscoveredNodes(equipmentId, driverSubfolder, nodes)`: +- Idempotent `EnsureFolder` / `EnsureVariable` calls (the node manager already supports incremental add under `Lock` + via `AddChild` + `AddPredefinedNode`; `EnsureVariable` early-returns if the node exists). +- Variables materialize **read-only** (no `OnWriteValue`). +- After adding, emit a `GeneralModelChangeEvent` so connected clients can refresh their browse (the full-rebuild path + does not emit one; runtime adds should). + +## Data flow (value path — fully reused) + +``` +SetDesiredSubscriptions(union) → DriverInstanceActor subscribes the FixedTree refs + → PollGroupEngine polls each ref via FocasDriver.ReadAsync + → TryReadFixedTree (cache lookup, NO extra wire I/O) + → onChange → AttributeValuePublished(FullReference) + → DriverHostActor.ForwardToMux + → _nodeIdByDriverRef[(driverId, ref)] → AttributeValueUpdate(nodeId, value, quality, ts) + → OtOpcUaNodeManager writes the node value +``` + +The routing key is **consistent by construction**: the capturing builder records `handle.FullReference`, which is exactly +the ref the driver publishes (`AttributeValuePublished.FullReference`) and the ref `TryReadFixedTree` matches +(`reference.StartsWith(state.Options.HostAddress + "/")`). + +## Lifecycle / re-injection robustness (the timing problem, solved) + +- **First connect:** driver connects → ~0–2 s later `FixedTreeCache` populates → bounded re-discovery catches it → inject. +- **Redeploy with a structural `RebuildAddressSpace`:** the full teardown wipes injected nodes and `PushDesiredSubscriptions` + rebuilds `_nodeIdByDriverRef` from authored tags only. **Fix:** after every `PushDesiredSubscriptions`, `DriverHostActor` + **re-applies its cached `_discoveredByDriver`** (re-materialize + re-map + re-merge refs) — so FixedTree survives + redeploys without re-querying the driver. +- **Process restart:** `_discoveredByDriver` is lost, but `RestoreApplied` re-spawns drivers → each reconnects → + post-connect re-discovery re-injects (same ~0–2 s delay). Consistent with the symptom-#1 restore behavior already + deployed. +- **Idempotent throughout:** `EnsureFolder`/`EnsureVariable` early-return if present; `_nodeIdByDriverRef` is set-based; + `SetDesiredSubscriptions` is idempotent. + +## Error handling + +- Discovery throws / driver not ready → bounded retry, then give up quietly (Info); authored tags unaffected. +- No equipment bound to the driver instance, or ambiguous (multi-equipment) → Warning, skip injection. +- A FixedTree ref that fails to read at poll time → flows the same recoverable `BadCommunicationError` push as any + equipment tag (the symptom-#1 fix) — observable, not silent. + +## Testing + +- **Unit:** + - `CapturingAddressSpaceBuilder` records the tree + refs from a fake `ITagDiscovery` (folders, nested variables, + no-op alarm sink, ignored properties). + - Injector mapping: discovered nodes → `EQ-…/FOCAS/…` NodeIds; dedup against authored tags; device-host-folder collapse. + - `DriverInstanceActor` bounded post-connect re-discovery (set becomes non-empty on the Nth attempt; stops on cap / no-growth). + - `DriverHostActor` `DiscoveredNodesReady` handling + re-inject-after-`PushDesiredSubscriptions`. + - Read-only materialization (no write callback). +- **Integration (docker-dev):** a fake `ITagDiscovery` driver exposing a *delayed* discovery set → assert nodes appear + under the equipment and carry values; verify survival across a redeploy + a process restart. +- **Live (wonder, following the symptom-#1 pattern):** deploy the current Host + this change, browse + `EQ-3686c0272279/FOCAS/Identity/SeriesNumber` and `…/Axes/X/AbsolutePosition`, confirm Good values. The live deploy is + **not** blocking for the build (macro/axes values may be 0 on the idle machine — assert status, not magnitude); confirm + the live-deploy step with the user at execution time. + +## Scope / non-goals + +- **In:** read-only value nodes for any `ITagDiscovery` driver; 1:1 driver↔equipment; survives redeploy/restart; generic + mechanism with FOCAS as the first consumer. +- **Out (documented follow-ups):** discovered **alarms** injection; multi-device-per-driver-instance mapping; writable + discovered nodes. + +## Touched code (anticipated) + +- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs` — `DiscoveredNodesReady` handler, `_discoveredByDriver` + cache, re-inject after `PushDesiredSubscriptions`, desired-set merge. +- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs` — post-connect bounded re-discovery + new message. +- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs` — `MaterialiseDiscoveredNodes` receive. +- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` — `GeneralModelChangeEvent` emit on runtime add (verify + existing helper). +- New: `CapturingAddressSpaceBuilder` + `DiscoveredNode` DTO (runtime), `EquipmentNodeIds` reuse for mapping. +- Tests under `tests/...Runtime.Tests` / `tests/...OpcUaServer.Tests` and a fake `ITagDiscovery` test double. + +## Task tracking + +Umbrella native task **#14** (FixedTree feature). Implementation tasks to be generated by writing-plans from this design. diff --git a/docs/plans/2026-06-26-otopcua-fixedtree-equipment-injection.md b/docs/plans/2026-06-26-otopcua-fixedtree-equipment-injection.md new file mode 100644 index 00000000..698dfc8e --- /dev/null +++ b/docs/plans/2026-06-26-otopcua-fixedtree-equipment-injection.md @@ -0,0 +1,759 @@ +# FixedTree → Equipment dynamic-injection Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. + +**Goal:** After an `ITagDiscovery` driver connects, dynamically graft its discovered FixedTree nodes into the served Equipment/UNS OPC UA address space under a driver-named subfolder (`EQ-…/FOCAS/…`), carrying live values — reusing the existing materialize → subscribe → poll → push pipeline. + +**Architecture:** Treat discovered nodes as "synthetic equipment tags" injected at runtime. A capturing `IAddressSpaceBuilder` records each driver's `DiscoverAsync` output (zero driver changes); `DriverInstanceActor` runs discovery post-connect (bounded retry, since FOCAS's `FixedTreeCache` populates ~0–2 s after connect) and ships a `DiscoveredNodesReady` message; `DriverHostActor` maps the nodes under the equipment, extends `_nodeIdByDriverRef` + the desired-subscription set, and tells `OpcUaPublishActor` to incrementally materialize them (idempotent `EnsureFolder`/`EnsureVariable`, no full teardown), emitting a `GeneralModelChangeEvent`. Survives redeploys (re-applied after `PushDesiredSubscriptions`) and restarts (re-discovered on reconnect). + +**Tech Stack:** .NET 10, Akka.NET (Akka.Hosting, Akka.TestKit.Xunit2), OPC UA (`OPCFoundation.NetStandard.Opc.Ua`), xUnit v2 + Shouldly. + +**Design doc:** [`2026-06-26-otopcua-fixedtree-equipment-injection-design.md`](2026-06-26-otopcua-fixedtree-equipment-injection-design.md). Base branch: `fix/focas-poll-io-serialization` (this builds on the deployed driver-host bootstrap re-spawn + FOCAS I/O fixes; not yet merged to `master`). + +**Key code anchors (verified 2026-06-26):** +- `IAddressSpaceBuilder` / `IVariableHandle` / `DriverAttributeInfo` — `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/`. +- Reference capturing builder (flat collector): `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/BrowseCommand.cs:120` (`CollectingAddressSpaceBuilder`). +- NodeId scheme: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/EquipmentNodeIds.cs` (`Variable(equipmentId, folderPath, name)` → `{parent}/{name}`; `SubFolder` → `{equipmentId}/{folderPath}`). +- Materialize pattern: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs:248` (`MaterialiseEquipmentTags`) + `SafeEnsureFolder`/`SafeEnsureVariable`. +- Node manager: `OtOpcUaNodeManager.EnsureFolder` (`:1282`), `EnsureVariable` (`:1367`, seeds `BadWaitingForInitialData`), `BuildNodeShapeChangedEvent` (`:1525`, verb `DataTypeChanged` — model for a `NodeAdded` sibling). +- Publish actor receive + materialize calls: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs:217` (Receive block), `HandleRebuild` (`:275`). +- Driver value route: `DriverHostActor.ForwardToMux` (`:525`), `_nodeIdByDriverRef` built in `PushDesiredSubscriptions` (`:1019`, sends `SetDesiredSubscriptions` `:1052`), `ChildEntry` (`:203`), Receive blocks (`:482`, `:512`). +- Driver connect hook: `DriverInstanceActor` `_driver` field (`:110`), `Connected()` (`:317`), transition at `InitializeSucceeded` (`:278`); `SetDesiredSubscriptions` live re-subscribe path (`:340-353`). +- FOCAS discovery (reused verbatim): `FocasDriver.DiscoverAsync` (`:408`) emits `FOCAS/{deviceHost}/
/…`; FixedTree leaf `FullName` = `{deviceHost}/{path}`; suppresses FixedTree until `FixedTreeCache` set. + +--- + +## Conventions for every task + +- **TDD:** write the failing test first, run it (confirm the expected failure), implement minimally, run again (green), commit. +- **Build:** `dotnet build ZB.MOM.WW.OtOpcUa.slnx` from the repo root. +- **Run a single test class:** `dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~"`. +- **Commits:** Conventional Commits, on `fix/focas-poll-io-serialization` (do NOT touch the pre-existing unrelated working-tree edits: `CLAUDE.md`, `docker-dev/docker-compose.yml`, `pending.md`, `stillpending.md`, `docs/plans/2026-06-19-followups-batch.md.tasks.json` — `git add` only this feature's files). +- **No new dependencies, no proto change, no EF migration.** All edits are within existing projects. + +--- + +## Task 1: `DiscoveredNode` DTO + path-tracking `CapturingAddressSpaceBuilder` + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 3 + +**Files:** +- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNode.cs` +- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/CapturingAddressSpaceBuilder.cs` +- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/CapturingAddressSpaceBuilderTests.cs` + +Unlike the CLI's flat `CollectingAddressSpaceBuilder`, this one **tracks folder nesting** so each variable records its full path segments (e.g. `["FOCAS","10.201.31.5:8193","Identity"]` + browse `SeriesNumber`). + +**Step 1: Write the failing test** + +```csharp +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; + +[Trait("Category", "Unit")] +public sealed class CapturingAddressSpaceBuilderTests +{ + [Fact] + public void Records_nested_path_segments_full_reference_and_metadata() + { + var b = new CapturingAddressSpaceBuilder(); + var focas = b.Folder("FOCAS", "FOCAS"); + var device = focas.Folder("10.0.0.5:8193", "cnc"); + var identity = device.Folder("Identity", "Identity"); + identity.Variable("SeriesNumber", "SeriesNumber", new DriverAttributeInfo( + FullName: "10.0.0.5:8193/Identity/SeriesNumber", + DriverDataType: DriverDataType.String, IsArray: false, ArrayDim: null, + SecurityClass: SecurityClassification.ViewOnly, IsHistorized: false)); + + b.Nodes.Count.ShouldBe(1); + var n = b.Nodes[0]; + n.FolderPathSegments.ShouldBe(new[] { "FOCAS", "10.0.0.5:8193", "Identity" }); + n.BrowseName.ShouldBe("SeriesNumber"); + n.FullReference.ShouldBe("10.0.0.5:8193/Identity/SeriesNumber"); + n.DataType.ShouldBe(DriverDataType.String); + n.Writable.ShouldBeFalse(); // ViewOnly → read-only + } + + [Fact] + public void AddProperty_is_ignored_and_alarm_marking_is_a_noop_sink() + { + var b = new CapturingAddressSpaceBuilder(); + var f = b.Folder("FOCAS", "FOCAS"); + f.AddProperty("Manufacturer", DriverDataType.String, "FANUC"); // ignored, no throw + var h = f.Variable("V", "V", new DriverAttributeInfo("ref", DriverDataType.Int32, false, null, + SecurityClassification.ViewOnly, false, IsAlarm: true)); + var sink = h.MarkAsAlarmCondition(new AlarmConditionInfo("src", AlarmSeverity.Low, null)); + sink.ShouldNotBeNull(); // no-op sink, alarms out of scope + b.Nodes.Count.ShouldBe(1); + } +} +``` + +**Step 2: Run to verify it fails** — `dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~CapturingAddressSpaceBuilderTests"` → FAIL (types don't exist). + +**Step 3: Implement `DiscoveredNode.cs`** + +```csharp +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers; + +/// +/// A flattened variable captured from a driver's stream +/// by . Folder nesting is preserved in +/// so the injector can re-root the node under an equipment. +/// +public sealed record DiscoveredNode( + IReadOnlyList FolderPathSegments, + string BrowseName, + string DisplayName, + string FullReference, + DriverDataType DataType, + bool IsArray, + uint? ArrayDim, + bool Writable, + bool IsHistorized); +``` + +**Step 3b: Implement `CapturingAddressSpaceBuilder.cs`** + +```csharp +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers; + +/// +/// An that RECORDS the streamed tree instead of creating OPC UA +/// nodes — used to capture an driver's discovered hierarchy so the +/// runtime can graft it under an equipment node. Folder nesting is tracked (each child builder +/// carries its accumulated path), so every variable records its full . +/// Value nodes only: is ignored and alarm marking returns a no-op sink +/// (discovered alarms are out of scope — alarms come via the config path). +/// Single-threaded: a driver's DiscoverAsync streams on one caller; the root and its child +/// builders share one . Not thread-safe by design. +/// +public sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder +{ + private readonly List _nodes; + private readonly IReadOnlyList _path; + + public CapturingAddressSpaceBuilder() : this([], []) { } + + private CapturingAddressSpaceBuilder(List nodes, IReadOnlyList path) + { + _nodes = nodes; + _path = path; + } + + /// All variables captured across the whole tree (shared by the root and every child scope). + public IReadOnlyList Nodes => _nodes; + + public IAddressSpaceBuilder Folder(string browseName, string displayName) + => new CapturingAddressSpaceBuilder(_nodes, [.. _path, browseName]); + + public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) + { + _nodes.Add(new DiscoveredNode( + FolderPathSegments: _path, + BrowseName: browseName, + DisplayName: displayName, + FullReference: attributeInfo.FullName, + DataType: attributeInfo.DriverDataType, + IsArray: attributeInfo.IsArray, + ArrayDim: attributeInfo.ArrayDim, + Writable: attributeInfo.SecurityClass != SecurityClassification.ViewOnly, + IsHistorized: attributeInfo.IsHistorized)); + return new NullHandle(attributeInfo.FullName); + } + + public void AddProperty(string browseName, DriverDataType dataType, object? value) { /* metadata only — ignored */ } + + private sealed class NullHandle(string fullRef) : IVariableHandle + { + public string FullReference => fullRef; + public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink(); + } + + private sealed class NullSink : IAlarmConditionSink + { + public void OnTransition(AlarmEventArgs args) { } + } +} +``` + +**Step 4: Run to verify it passes** — same filter → PASS. + +**Step 5: Commit** + +```bash +git add src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNode.cs \ + src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/CapturingAddressSpaceBuilder.cs \ + tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/CapturingAddressSpaceBuilderTests.cs +git commit -m "feat(otopcua): capturing address-space builder for driver discovery" +``` + +--- + +## Task 2: `DiscoveredNodeMapper` — map discovered nodes under an equipment + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 3 + +**Files:** +- Create: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/DiscoveredInjection.cs` (the `DiscoveredFolder`/`DiscoveredVariable` materialize DTOs — placed in OpcUaServer so both the applier and the Runtime mapper can reference them; Runtime already references OpcUaServer) +- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNodeMapper.cs` +- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveredNodeMapperTests.cs` + +**Pure function** turning `IReadOnlyList` + an `equipmentId` + the driver's authored-tag refs into folders + variables (NodeIds under the equipment) + routing entries. Rules: +- **Device-folder collapse:** if every node shares an identical segment at index 1 (the single device folder under the driver root), drop index 1 → `EQ/FOCAS/Identity/…` rather than `EQ/FOCAS//Identity/…`. With ≥2 devices the segments differ → not collapsed (device level retained, degrades gracefully — multi-device equipment mapping itself is a deferred follow-up). +- **Dedup:** skip any node whose `FullReference` is in `authoredRefs` (already a Config-DB equipment tag for this driver — applies to drivers like Galaxy whose discovery refs equal the equipment-tag FullNames; for FOCAS the FixedTree refs never match authored refs, so all FixedTree nodes pass through). +- **NodeId:** `EquipmentNodeIds.Variable(equipmentId, folderPath, name)` where `folderPath` = collapsed segments joined by `/`. Folders deduped, each parented at its prefix. +- **DataType:** convert `DriverDataType` → the OPC-UA-builtin string `OtOpcUaNodeManager.EnsureVariable` expects. **Reuse the existing convention** — grep for how `EquipmentTagPlan.DataType` is produced from `DriverDataType` (e.g. a `DriverDataType.ToString()` / a mapping helper) and `OtOpcUaNodeManager.ResolveBuiltInDataType`; do NOT invent a new mapping. If a helper exists, call it; the switch below is a fallback to align if not. +- **Writable:** from `DiscoveredNode.Writable` (FixedTree is read-only). + +**Step 1: Write the failing test** + +```csharp +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; + +[Trait("Category", "Unit")] +public sealed class DiscoveredNodeMapperTests +{ + private static DiscoveredNode Node(string[] path, string name, string fullRef, + DriverDataType dt = DriverDataType.Float64, bool writable = false) + => new(path, name, name, fullRef, dt, false, null, writable, false); + + [Fact] + public void Maps_under_equipment_collapsing_single_device_folder() + { + var nodes = new[] + { + Node(["FOCAS", "10.0.0.5:8193", "Identity"], "SeriesNumber", "10.0.0.5:8193/Identity/SeriesNumber", DriverDataType.String), + Node(["FOCAS", "10.0.0.5:8193", "Axes", "X"], "AbsolutePosition", "10.0.0.5:8193/Axes/X/AbsolutePosition"), + }; + + var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: []); + + result.Variables.Select(v => v.NodeId).ShouldBe(new[] + { + "EQ-1/FOCAS/Identity/SeriesNumber", + "EQ-1/FOCAS/Axes/X/AbsolutePosition", + }, ignoreOrder: true); + // folders: EQ-1/FOCAS, EQ-1/FOCAS/Identity, EQ-1/FOCAS/Axes, EQ-1/FOCAS/Axes/X + result.Folders.Select(f => f.NodeId).ShouldContain("EQ-1/FOCAS/Axes/X"); + result.Folders.First(f => f.NodeId == "EQ-1/FOCAS/Axes/X").ParentNodeId.ShouldBe("EQ-1/FOCAS/Axes"); + // routing: driverRef → nodeId + result.RoutingByRef["10.0.0.5:8193/Identity/SeriesNumber"].ShouldBe("EQ-1/FOCAS/Identity/SeriesNumber"); + result.Variables.First(v => v.NodeId.EndsWith("SeriesNumber")).Writable.ShouldBeFalse(); + } + + [Fact] + public void Dedups_authored_refs() + { + var nodes = new[] + { + Node(["FOCAS", "10.0.0.5:8193"], "parts-count", "parts-count"), // authored + Node(["FOCAS", "10.0.0.5:8193", "Identity"], "SeriesNumber", "10.0.0.5:8193/Identity/SeriesNumber", DriverDataType.String), + }; + var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: new HashSet { "parts-count" }); + result.Variables.ShouldHaveSingleItem(); + result.Variables[0].NodeId.ShouldBe("EQ-1/FOCAS/Identity/SeriesNumber"); + } + + [Fact] + public void Does_not_collapse_when_two_devices_present() + { + var nodes = new[] + { + Node(["FOCAS", "10.0.0.5:8193", "Identity"], "SeriesNumber", "a", DriverDataType.String), + Node(["FOCAS", "10.0.0.6:8193", "Identity"], "SeriesNumber", "b", DriverDataType.String), + }; + var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: []); + result.Variables.Select(v => v.NodeId).ShouldBe(new[] + { + "EQ-1/FOCAS/10.0.0.5:8193/Identity/SeriesNumber", + "EQ-1/FOCAS/10.0.0.6:8193/Identity/SeriesNumber", + }, ignoreOrder: true); + } +} +``` + +**Step 2: Run to verify it fails.** + +**Step 3: Implement `DiscoveredInjection.cs` (DTOs)** + +```csharp +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; + +/// A folder to ensure during discovered-node injection (NodeId + parent + display). +public sealed record DiscoveredFolder(string NodeId, string? ParentNodeId, string DisplayName); + +/// A read-or-write variable to ensure during discovered-node injection. +public sealed record DiscoveredVariable( + string NodeId, string ParentNodeId, string DisplayName, string DataType, bool Writable, bool IsArray, uint? ArrayLength); +``` + +**Step 3b: Implement `DiscoveredNodeMapper.cs`** + +```csharp +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.OpcUaServer; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers; + +/// The mapped result of grafting discovered nodes under an equipment. +public sealed record DiscoveredInjectionPlan( + IReadOnlyList Folders, + IReadOnlyList Variables, + IReadOnlyDictionary RoutingByRef); // driver FullReference → equipment NodeId + +/// +/// Pure mapper: re-roots a driver's captured discovery tree under an equipment node, deduping +/// authored Config-DB refs and collapsing the single device-host folder. See the design doc. +/// +public static class DiscoveredNodeMapper +{ + public static DiscoveredInjectionPlan Map( + string equipmentId, IReadOnlyList nodes, ISet authoredRefs) + { + var kept = nodes.Where(n => !authoredRefs.Contains(n.FullReference)).ToList(); + + // Collapse a single shared device-folder level (index 1 under the driver root) when present. + var collapseIndex1 = kept.Count > 0 + && kept.All(n => n.FolderPathSegments.Count >= 2) + && kept.Select(n => n.FolderPathSegments[1]).Distinct(StringComparer.Ordinal).Count() == 1; + + static IReadOnlyList Effective(IReadOnlyList segs, bool collapse) + => collapse ? [segs[0], .. segs.Skip(2)] : segs; + + var folders = new Dictionary(StringComparer.Ordinal); + var variables = new List(); + var routing = new Dictionary(StringComparer.Ordinal); + + foreach (var n in kept) + { + var segs = Effective(n.FolderPathSegments, collapseIndex1); + + // Ensure every prefix folder EQ/seg0, EQ/seg0/seg1, … + for (var i = 0; i < segs.Count; i++) + { + var folderPath = string.Join('/', segs.Take(i + 1)); + var nodeId = EquipmentNodeIds.SubFolder(equipmentId, folderPath); + if (folders.ContainsKey(nodeId)) continue; + var parent = i == 0 ? equipmentId : EquipmentNodeIds.SubFolder(equipmentId, string.Join('/', segs.Take(i))); + folders[nodeId] = new DiscoveredFolder(nodeId, parent, segs[i]); + } + + var varFolderPath = string.Join('/', segs); + var varNodeId = EquipmentNodeIds.Variable(equipmentId, varFolderPath, n.BrowseName); + var varParent = EquipmentNodeIds.SubFolder(equipmentId, varFolderPath); + variables.Add(new DiscoveredVariable( + varNodeId, varParent, n.DisplayName, ToBuiltinTypeString(n.DataType), n.Writable, n.IsArray, n.ArrayDim)); + routing[n.FullReference] = varNodeId; + } + + return new DiscoveredInjectionPlan(folders.Values.ToList(), variables, routing); + } + + // Align with the existing DriverDataType → builtin-string convention used by EquipmentTagPlan / + // OtOpcUaNodeManager.ResolveBuiltInDataType. VERIFY against that during implementation. + private static string ToBuiltinTypeString(DriverDataType dt) => dt.ToString(); +} +``` + +> **Implementation note:** before finalizing `ToBuiltinTypeString`, grep how `EquipmentTagPlan.DataType` is produced from a `DriverDataType` and what strings `OtOpcUaNodeManager.ResolveBuiltInDataType` accepts (e.g. `"Float64"`, `"String"`, `"Int32"`). If `DriverDataType.ToString()` already matches, keep it; otherwise mirror the existing mapping helper. The mapper test asserts NodeIds/structure, not the exact type string — add a focused assertion once the convention is confirmed. + +**Step 4: Run to verify it passes.** + +**Step 5: Commit** + +```bash +git add src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/DiscoveredInjection.cs \ + src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNodeMapper.cs \ + tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveredNodeMapperTests.cs +git commit -m "feat(otopcua): map discovered nodes under an equipment subfolder" +``` + +--- + +## Task 3: Node-manager `RaiseNodesAddedModelChange()` + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 1, Task 2 + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` (add a public method near `BuildNodeShapeChangedEvent:1525`) +- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerModelChangeOnAddTests.cs` (model on `NodeManagerSurgicalShapeUpdateTests.cs`) + +Emit a Part 3 `GeneralModelChangeEvent` with verb `NodeAdded` so already-connected clients can refresh their browse after a runtime add. Mirror the existing `BuildNodeShapeChangedEvent` (verb `DataTypeChanged`) + its `Server.ReportEvent` seam, but build a `NodeAdded` change referencing the equipment subfolder root that gained children. + +**Step 1: Write the failing test** — instantiate the node manager as the surgical-shape test does, `EnsureFolder` + `EnsureVariable` a couple of nodes, call `RaiseNodesAddedModelChange(parentNodeId)`, and assert it does not throw and (where the harness exposes reported events, as the surgical test does) that a `GeneralModelChangeEvent` with verb `NodeAdded` was reported. Reuse the surgical test's harness/setup verbatim. + +**Step 2: Run to verify it fails** (method missing). + +**Step 3: Implement** — add: + +```csharp +/// +/// Announce that nodes were added at runtime (discovered-node injection) so subscribed clients can +/// refresh their browse. Part 3 §8.7.4: a GeneralModelChangeEvent is emitted by the Server object; +/// verb = NodeAdded, affected = the subfolder root that gained children. Mirrors +/// 's ReportEvent seam; tolerant if auditing/eventing is off. +/// +/// The equipment/subfolder NodeId string under which nodes were added. +public void RaiseNodesAddedModelChange(string affectedNodeId) +{ + GeneralModelChangeEventState e; + lock (Lock) + { + // BUILD the event under Lock (consistent snapshot of _folders/_variables), mirroring + // BuildNodeShapeChangedEvent: EventId, SourceNode = ObjectIds.Server, SourceName, Time, + // Severity, a ModelChangeStructureDataType with Affected = new NodeId(affectedNodeId, + // NamespaceIndex) + Verb = (byte)ModelChangeStructureVerbMask.NodeAdded, ClearChangeMasks. + e = BuildNodesAddedModelChange(affectedNodeId); + } + // REPORT OUTSIDE Lock — Server.ReportEvent re-enters the server's own subscription/event path; + // holding Lock across it risks a lock-order inversion (mirror ReportNodeShapeChangedEvent, NOT + // ReportConditionEvent which uses alarm.ReportEvent). Tolerant: eventing off / no monitored items. + try { Server.ReportEvent(SystemContext, e); } + catch (Exception ex) + { +#pragma warning disable CS0618 + Utils.LogError(ex, "OtOpcUaNodeManager: failed to report GeneralModelChangeEvent(NodeAdded) for {0}", affectedNodeId); +#pragma warning restore CS0618 + } +} +``` + +> ⚠️ **Lock discipline (corrected 2026-06-26):** BUILD the `GeneralModelChangeEventState` under `lock (Lock)` (copy the field-population block from `BuildNodeShapeChangedEvent` `:1525`, changing only `Verb` → `NodeAdded` and `Affected`), but **REPORT `Server.ReportEvent` OUTSIDE the lock** — exactly like `ReportNodeShapeChangedEvent` / `RevertOptimisticWriteIfNeeded`. `Server.ReportEvent` re-enters the SDK subscription/event path; holding `Lock` across it risks a lock-order-inversion deadlock with a client that has event subscriptions. (An earlier draft of this plan said "keep it inside `lock (Lock)`" — that was wrong for `Server.ReportEvent`; `ReportConditionEvent` is *not* a valid analogue since it uses `alarm.ReportEvent`, the node's own notifier chain.) + +**Step 4: Run to verify it passes.** + +**Step 5: Commit** + +```bash +git add src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs \ + tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerModelChangeOnAddTests.cs +git commit -m "feat(otopcua): GeneralModelChangeEvent(NodeAdded) for runtime node adds" +``` + +--- + +## Task 4: `AddressSpaceApplier.MaterialiseDiscoveredNodes(...)` + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** none (depends on Tasks 2, 3) + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs` (add after `MaterialiseEquipmentTags:304`) +- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs` (add cases) + +Add an idempotent pass that ensures the mapped folders then variables via the existing `SafeEnsureFolder`/`SafeEnsureVariable`, then raises the model-change. Folders MUST be ensured parent-before-child (sort by NodeId depth / segment count). + +**Step 1: Write the failing test** — using the applier test's existing fake sink, call `MaterialiseDiscoveredNodes` with 2 folders + 2 read-only variables and assert the sink received `EnsureFolder`/`EnsureVariable` with the right NodeIds/parents, `writable: false`, and that a re-apply is a no-op (idempotent — sink early-returns on existing). Assert `RaiseNodesAddedModelChange` is invoked (extend the fake sink/node-manager double to record it, mirroring how the existing test verifies materialize calls). + +**Step 2: Run to verify it fails.** + +**Step 3: Implement** + +```csharp +/// +/// Materialise driver-discovered nodes (FixedTree) under an equipment at runtime. Idempotent: +/// re-applies are cheap (the sink's EnsureFolder/EnsureVariable early-return on existing nodes), so +/// this is safely re-run after every address-space rebuild. Folders are ensured parent-first. +/// Emits a NodeAdded model-change so connected clients can refresh. +/// +public void MaterialiseDiscoveredNodes( + string equipmentRootNodeId, + IReadOnlyList folders, + IReadOnlyList variables) +{ + ArgumentNullException.ThrowIfNull(folders); + ArgumentNullException.ThrowIfNull(variables); + if (folders.Count == 0 && variables.Count == 0) return; + + foreach (var f in folders.OrderBy(f => f.NodeId.Count(c => c == '/'))) + SafeEnsureFolder(f.NodeId, f.ParentNodeId, f.DisplayName); + + foreach (var v in variables) + SafeEnsureVariable(v.NodeId, v.ParentNodeId, v.DisplayName, v.DataType, v.Writable, + historianTagname: null, isArray: v.IsArray, arrayLength: v.ArrayLength); + + _sink.RaiseNodesAddedModelChange(equipmentRootNodeId); + + _logger.LogInformation( + "AddressSpaceApplier: discovered nodes materialised under {Equipment} (folders={Folders}, vars={Vars})", + equipmentRootNodeId, folders.Count, variables.Count); +} +``` + +> Confirm `_sink`'s interface exposes `RaiseNodesAddedModelChange` (the sink type wraps `OtOpcUaNodeManager`); add it to the sink interface if the applier talks to an `IAddressSpaceSink` abstraction rather than the concrete manager. + +**Step 4: Run to verify it passes.** + +**Step 5: Commit** + +```bash +git add src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs \ + tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs +git commit -m "feat(otopcua): applier pass to materialise discovered nodes idempotently" +``` + +--- + +## Task 5: `OpcUaPublishActor.MaterialiseDiscoveredNodes` message + handler + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** none (depends on Task 4) + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs` (message record near the other records; `Receive<…>` at the block `:217`; handler near `HandleRebuild`) +- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs` (add a case) + +**Step 1: Write the failing test** — with the publish-actor test harness (fake applier), send `MaterialiseDiscoveredNodes(equipmentRoot, folders, variables)` and assert the handler forwards to `_applier.MaterialiseDiscoveredNodes(...)` with the same payload. + +**Step 2: Run to verify it fails.** + +**Step 3: Implement** — add the message + Receive + handler: + +```csharp +/// Inject driver-discovered nodes (FixedTree) under an equipment at runtime (post-connect). +public sealed record MaterialiseDiscoveredNodes( + string EquipmentRootNodeId, + IReadOnlyList Folders, + IReadOnlyList Variables); +``` + +In the Receive block (`:217`, alongside `Receive(HandleRebuild)`): + +```csharp +Receive(HandleMaterialiseDiscovered); +``` + +Handler: + +```csharp +private void HandleMaterialiseDiscovered(MaterialiseDiscoveredNodes msg) + => _applier.MaterialiseDiscoveredNodes(msg.EquipmentRootNodeId, msg.Folders, msg.Variables); +``` + +**Step 4: Run to verify it passes.** + +**Step 5: Commit** + +```bash +git add src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs \ + tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs +git commit -m "feat(otopcua): OpcUaPublishActor handles discovered-node materialisation" +``` + +--- + +## Task 6: `DriverInstanceActor` post-connect bounded re-discovery + +**Classification:** high-risk +**Estimated implement time:** ~5 min +**Parallelizable with:** none (depends on Task 1; touches actor lifecycle) + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs` (message records area `:60-160`; `Connected()` entry via `InitializeSucceeded:278`; new private async discovery method + a self-scheduled retry tick) +- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs` + +On reaching `Connected`, if `_driver is ITagDiscovery`, run discovery into a `CapturingAddressSpaceBuilder`, and `Context.Parent.Tell(new DiscoveredNodesReady(_driverInstanceId, nodes))`. Because FOCAS suppresses FixedTree until `FixedTreeCache` populates (~0–2 s), schedule a bounded retry: re-run every ~2 s up to a cap (~30 s / ~15 attempts) **or until the node count stops growing** (whichever first), then stop. `DiscoverAsync` reads in-memory cache → cheap. Reset/cancel the schedule on leaving `Connected` (DisconnectObserved/ForceReconnect) and re-arm on the next `Connected` entry. Use Akka scheduling (`Context.System.Scheduler.ScheduleTellOnce` self-tell of an internal `RediscoverTick`, tracked by an `ICancelable` so it's cancelled on state exit) — do NOT block the actor thread. + +**Message records to add** (near the other nested records): + +```csharp +/// Published to the parent (DriverHostActor) after a post-connect discovery pass. +public sealed record DiscoveredNodesReady(string DriverInstanceId, IReadOnlyList Nodes); + +/// Internal self-tick driving bounded post-connect re-discovery. +private sealed record RediscoverTick(int Generation, int Attempt, int LastCount); +``` + +**Step 1: Write the failing test** — drive a `DriverInstanceActor` with a fake `IDriver` that also implements `ITagDiscovery`, whose `DiscoverAsync` yields 0 nodes on the first ~2 attempts then a non-empty set (simulating FixedTreeCache populating). Bring the actor to `Connected` (send the same init messages the existing `DriverInstanceActorTests` use). Use the TestKit parent probe (`Context.Parent` → the TestKit `TestActor` via `ActorOf` under the testkit, or the existing harness's parent-probe pattern in `DriverInstanceActorTests`) and `ExpectMsg` — assert the eventually-delivered message carries the non-empty set, and that re-ticks stop after the set stabilises (no infinite stream). Use the TestKit scheduler / `Within` to advance. + +**Step 2: Run to verify it fails.** + +**Step 3: Implement** — add the discovery kick at the `InitializeSucceeded` Connected transition (after `ResubscribeDesired()`), a `Receive` in `Connected()`, and a `RunDiscoveryAsync` that: +- guards `_driver is ITagDiscovery disc` (else no-op), +- builds a `CapturingAddressSpaceBuilder`, awaits `disc.DiscoverAsync(builder, ct)`, +- `Context.Parent.Tell(new DiscoveredNodesReady(_driverInstanceId, builder.Nodes))`, +- if `attempt < cap` and `builder.Nodes.Count` still growing (or zero), schedules the next `RediscoverTick(_initGeneration, attempt+1, builder.Nodes.Count)` via `ICancelable` (store in a field, cancel on `DetachSubscription`/state exit). +- Tag ticks with `_initGeneration` and ignore stale-generation ticks (mirrors the existing `InitializeSucceeded.Generation` guard) so a reconnect cancels the prior loop. + +> Use `ReceiveAsync` (like the other async receives in `Connected()`), and wrap the discovery call in try/catch → log Info + reschedule (bounded). Mirror the existing cancelable-scheduling pattern already used in the actor (grep `Scheduler`/`ICancelable` in this file and `DriverHostActor`). + +**Step 4: Run to verify it passes.** + +**Step 5: Commit** + +```bash +git add src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs \ + tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs +git commit -m "feat(otopcua): driver-instance post-connect bounded re-discovery" +``` + +--- + +## Task 7: `DriverHostActor` — inject discovered nodes (handler + routing + subscribe) + +**Classification:** high-risk +**Estimated implement time:** ~5 min +**Parallelizable with:** none (depends on Tasks 2, 5, 6; touches actor + routing map) + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs` (fields near `_nodeIdByDriverRef`; `Receive<…>` in BOTH receive states `:482` and `:512`; new handler; store `_lastComposition` in `PushDesiredSubscriptions`) +- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs` + +Add `Receive(HandleDiscoveredNodes)` to the two states that already handle `AttributeValuePublished` (`:484`, `:512`). New fields: `_lastComposition` (set at the end of `PushDesiredSubscriptions`) and `_discoveredByDriver` (`Dictionary`). The handler: + +1. If `_lastComposition` is null → stash nothing / log Debug and return (composition not applied yet; a later `DiscoveredNodesReady` retry will land after apply). +2. Resolve the equipment: `_lastComposition.EquipmentNodes.Where(e => e.DriverInstanceId == id)`. 0 → log Info skip; >1 → log Warning skip (multi-device deferred). Else take its `EquipmentId`. +3. Compute `authoredRefs` = `_lastComposition.EquipmentTags.Where(t => t.DriverInstanceId == id).Select(t => t.FullName)` set. +4. `var plan = DiscoveredNodeMapper.Map(equipmentId, msg.Nodes, authoredRefs);` +5. If `plan.Variables` empty → return (nothing new yet). +6. `_discoveredByDriver[id] = plan;` +7. For each `(ref, nodeId)` in `plan.RoutingByRef`: add to `_nodeIdByDriverRef[(id, ref)]` (the same `HashSet` fan-out structure used in `PushDesiredSubscriptions:1019`). +8. `_opcUaPublishActor.Tell(new OpcUaPublishActor.MaterialiseDiscoveredNodes(equipmentId, plan.Folders, plan.Variables));` +9. Merge the discovered refs into the driver's desired set and re-push: `child.Actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(union, SubscriptionPublishingInterval, alarmRefs))` where `union` = authored refs already pushed for that driver **plus** `plan.RoutingByRef.Keys`. (Keep the alarmRefs as last pushed.) The actor's `Connected` `SetDesiredSubscriptions` handler immediately re-subscribes (`:340-353`). + +**Step 1: Write the failing test** — build a `DriverHostActor` via its existing test harness (`DriverHostActorTests`/`...WriteRoutingTests` show construction with fakes: a fake child/registry, fake OPC publish probe, a composition artifact). Apply a deployment whose composition has one equipment (`EQ-1`, `DriverInstanceId=d1`) + one authored tag, so `_lastComposition` is set and a child `d1` exists. Send `DriverInstanceActor.DiscoveredNodesReady("d1", )`. Assert: (a) the OPC publish probe received `MaterialiseDiscoveredNodes` with the mapped folders/vars; (b) the child probe received a `SetDesiredSubscriptions` whose refs include both the authored ref and the FixedTree refs; (c) a subsequent `AttributeValuePublished(d1, , value)` routes to an `AttributeValueUpdate` at the mapped NodeId (proves `_nodeIdByDriverRef` updated). + +**Step 2: Run to verify it fails.** + +**Step 3: Implement** per the steps above. Store `_lastComposition = composition;` at the end of `PushDesiredSubscriptions` (after the existing logic). Reuse the exact fan-out add pattern for `_nodeIdByDriverRef` from `:1019-1045`. + +**Step 4: Run to verify it passes.** + +**Step 5: Commit** + +```bash +git add src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs \ + tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs +git commit -m "feat(otopcua): inject discovered nodes into the equipment projection on connect" +``` + +--- + +## Task 8: `DriverHostActor` — re-inject discovered nodes after a rebuild + +**Classification:** high-risk +**Estimated implement time:** ~3 min +**Parallelizable with:** none (depends on Task 7; same file) + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs` (tail of `PushDesiredSubscriptions`) +- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs` (add a case) + +A structural redeploy triggers `RebuildAddressSpace` (full teardown) and `PushDesiredSubscriptions` rebuilds `_nodeIdByDriverRef` from authored tags only — losing the injected FixedTree nodes + mappings. After the existing `PushDesiredSubscriptions` work, **re-apply the cached `_discoveredByDriver`**: for each cached plan, re-add its `RoutingByRef` to `_nodeIdByDriverRef`, re-`Tell` `MaterialiseDiscoveredNodes`, and re-merge its refs into that driver's pushed `SetDesiredSubscriptions`. + +**Step 1: Write the failing test** — after Task 7's injection, simulate a second `PushDesiredSubscriptions` (re-apply the same deployment). Assert the OPC publish probe receives `MaterialiseDiscoveredNodes` AGAIN and the child's re-pushed `SetDesiredSubscriptions` still includes the FixedTree refs (i.e. they weren't dropped by the rebuild). + +**Step 2: Run to verify it fails** (today the rebuild drops them). + +**Step 3: Implement** — extract the per-driver merge-and-materialise into a helper reused by both `HandleDiscoveredNodes` and a new `ReapplyDiscovered()` call at the tail of `PushDesiredSubscriptions` (after `_lastComposition` is set). Guard for the case where the driver no longer exists in `_children` or the equipment was removed (drop that cache entry). + +**Step 4: Run to verify it passes.** + +**Step 5: Commit** + +```bash +git add src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs \ + tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs +git commit -m "feat(otopcua): re-inject discovered nodes after address-space rebuild" +``` + +--- + +## Task 9: Integration test — discovered nodes appear + carry values + survive lifecycle + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** none (depends on Tasks 7, 8) + +**Files:** +- Create: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs` +- (Reuse / extend any existing in-memory `IDriver` test double in the Runtime tests; create a `FakeDiscoverableDriver : IDriver, ITagDiscovery, ISubscribable` if none fits.) + +A focused in-process integration test (no docker, no CNC): wire `DriverHostActor` + `OpcUaPublishActor` + a real `AddressSpaceApplier`/node manager (as the publish-actor rebuild tests do) + a fake discoverable+subscribable driver whose `DiscoverAsync` exposes a delayed FixedTree set and whose poll returns values for those refs. Assert end-to-end: +1. After connect + the discovery delay, the node manager has variables at `EQ-…/FOCAS/…`. +2. A poll value for a FixedTree ref surfaces as a Good `AttributeValueUpdate` at the mapped NodeId (no longer `BadWaitingForInitialData`). +3. After a simulated rebuild (re-apply), the nodes + values persist. + +> If a full wiring proves too heavy for one test fixture, split into (9a) host→publish materialisation reaching a real node manager, and (9b) value-route smoke — but keep both in this file. Do NOT silently drop the lifecycle assertion; if you cannot wire a real node manager here, log that limitation in the test summary and cover it in Task 10's docker-dev step instead. + +**Step 5: Commit** + +```bash +git add tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs +git commit -m "test(otopcua): end-to-end discovered-node injection + value flow" +``` + +--- + +## Task 10: Build + full suite + docker-dev smoke + +**Classification:** small +**Estimated implement time:** ~5 min +**Parallelizable with:** none (depends on all prior) + +**Files:** none (verification only; fix wiring if the build/tests surface gaps) + +**Steps:** +1. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → 0 errors, 0 warnings. +2. `dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~Runtime.Tests"` → green. +3. `dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~OpcUaServer.Tests"` → green. +4. `dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~FOCAS"` → green (no regression). +5. **docker-dev smoke (optional but recommended):** build the docker-dev image, boot `central-1` (fused admin+driver), confirm via logs that a connected discoverable driver injects nodes (`AddressSpaceApplier: discovered nodes materialised`) and that browse shows `EQ-…/FOCAS/…`. (Mirror the symptom-#1 docker-dev confirmation in the investigation plan.) +6. Commit any wiring fixes with a `fix(otopcua):` message. + +--- + +## Task 11: Docs + +**Classification:** trivial +**Estimated implement time:** ~3 min +**Parallelizable with:** none (depends on Task 10) + +**Files:** +- Modify: `docs/plans/2026-06-25-otopcua-equipment-dataplane-investigation.md` (mark symptom #2 / the FixedTree feature done, link this plan + the design doc) +- Modify: `docs/deployments/wonder-app-vd03-makino-z-34184.md` (note FixedTree now surfaces under `EQ-…/FOCAS/…`) +- Modify: `docs/plans/2026-06-26-otopcua-fixedtree-equipment-injection-design.md` (status → Implemented) + +**Step: Commit** + +```bash +git add docs/plans/2026-06-25-otopcua-equipment-dataplane-investigation.md \ + docs/deployments/wonder-app-vd03-makino-z-34184.md \ + docs/plans/2026-06-26-otopcua-fixedtree-equipment-injection-design.md +git commit -m "docs(otopcua): record FixedTree-under-Equipment injection feature" +``` + +--- + +## Live deploy (post-plan, with user confirmation) + +Following the symptom-#1 pattern (self-contained publish-overlay → wonder): after the suite is green and docker-dev confirms, **confirm with the user before deploying to the production CNC node**, then deploy and browse `EQ-3686c0272279/FOCAS/Identity/SeriesNumber` + `…/Axes/X/AbsolutePosition` (assert status Good — values may be 0 on the idle machine). Live deploy is explicitly NOT part of the build/test gate. + +## Follow-ups (out of scope, documented) + +- Discovered **alarms** injection; **multi-device-per-driver-instance** equipment mapping; **writable** discovered nodes. +- Reconcile the AdminUI↔driver FOCAS config-format mismatch (series-as-number, scheme-less host) at the AdminUI source. +- Shared `AddZbSerilog` not setting static `Serilog.Log.Logger` (latent across all 3 apps). diff --git a/docs/plans/2026-06-26-otopcua-fixedtree-equipment-injection.md.tasks.json b/docs/plans/2026-06-26-otopcua-fixedtree-equipment-injection.md.tasks.json new file mode 100644 index 00000000..ed4e0648 --- /dev/null +++ b/docs/plans/2026-06-26-otopcua-fixedtree-equipment-injection.md.tasks.json @@ -0,0 +1,23 @@ +{ + "planPath": "docs/plans/2026-06-26-otopcua-fixedtree-equipment-injection.md", + "tasks": [ + {"id": 1, "subject": "Task 1: DiscoveredNode DTO + CapturingAddressSpaceBuilder", "status": "completed"}, + {"id": 2, "subject": "Task 2: DiscoveredNodeMapper + materialize DTOs", "status": "completed", "blockedBy": [1]}, + {"id": 3, "subject": "Task 3: NodeManager RaiseNodesAddedModelChange", "status": "completed"}, + {"id": 4, "subject": "Task 4: AddressSpaceApplier.MaterialiseDiscoveredNodes", "status": "completed", "blockedBy": [2, 3]}, + {"id": 5, "subject": "Task 5: OpcUaPublishActor.MaterialiseDiscoveredNodes message+handler", "status": "completed", "blockedBy": [4]}, + {"id": 6, "subject": "Task 6: DriverInstanceActor post-connect bounded re-discovery", "status": "completed", "blockedBy": [1]}, + {"id": 7, "subject": "Task 7: DriverHostActor inject discovered nodes", "status": "completed", "blockedBy": [2, 5, 6]}, + {"id": 8, "subject": "Task 8: DriverHostActor re-inject after rebuild", "status": "completed", "blockedBy": [7]}, + {"id": 9, "subject": "Task 9: End-to-end discovered-node injection test", "status": "completed", "blockedBy": [7, 8]}, + {"id": 10, "subject": "Task 10: Build + full suite + docker-dev smoke", "status": "completed", "blockedBy": [9]}, + {"id": 11, "subject": "Task 11: Docs", "status": "completed", "blockedBy": [10]} + ], + "nativeTaskIds": { + "1": 21, "2": 22, "3": 23, "4": 24, "5": 25, "6": 26, + "7": 27, "8": 28, "9": 29, "10": 30, "11": 31 + }, + "lastUpdated": "2026-06-26T00:00:00Z", + "status": "offline-complete; live wonder validation pending", + "branch": "feat/focas-fixedtree-equipment-injection" +}