# 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).