From c18943f6e18835d000d1a247aeb9f7e0e852eeb4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 6 Jun 2026 14:20:36 -0400 Subject: [PATCH] docs: task-by-task plan for the Equipment-namespace structure milestone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements WS-1/WS-2/WS-4 (+ tests) of the materialization scope: carry Equipment-namespace tags through the composition/artifact, add a sink-based MaterialiseEquipmentTags pass to the live rebuild, browse folders by friendly Name (NodeId stays the logical Id). 6 tasks with dependencies; structure-only (BadWaitingForInitialData leaves) — live values are the next milestone. Resume via /executing-plans docs/plans/2026-06-06-equipment-namespace-structure-milestone.md --- ...equipment-namespace-structure-milestone.md | 212 ++++++++++++++++++ ...amespace-structure-milestone.md.tasks.json | 12 + 2 files changed, 224 insertions(+) create mode 100644 docs/plans/2026-06-06-equipment-namespace-structure-milestone.md create mode 100644 docs/plans/2026-06-06-equipment-namespace-structure-milestone.md.tasks.json diff --git a/docs/plans/2026-06-06-equipment-namespace-structure-milestone.md b/docs/plans/2026-06-06-equipment-namespace-structure-milestone.md new file mode 100644 index 00000000..167032cc --- /dev/null +++ b/docs/plans/2026-06-06-equipment-namespace-structure-milestone.md @@ -0,0 +1,212 @@ +# Equipment-Namespace Structure Materialization — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. + +**Goal:** A deploy that includes an `Equipment`-kind namespace materialises its full +`Area / Line / Equipment / Signal` browse tree into the live OPC UA address space on `:4840`, +with friendly-`Name` browse names and `BadWaitingForInitialData` leaf values. (Live **values** are +a separate later milestone and are explicitly out of scope here.) + +**Architecture:** The live rebuild (`OpcUaPublishActor.HandleRebuild`) is **sink-based** — it drives +`Phase7Applier` against an `IOpcUaAddressSpaceSink`, materialising the Area/Line/Equipment folder +skeleton (`MaterialiseHierarchy`) and SystemPlatform/Galaxy variables (`MaterialiseGalaxyTags`). +Today there is **no equipment-signal pass**: `Equipment`-bound `Tag`/`VirtualTag`/`ScriptedAlarm` +rows never become variables. This plan adds that pass, mirroring `MaterialiseGalaxyTags`, fed by +equipment data carried in the deployment composition. It also makes the UNS folders browse by their +friendly `Name`. + +**Tech Stack:** .NET 10, Akka.NET actors, EF Core (SQL Server), OPC UA SDK. Build/test from the repo +root: `dotnet build`, `dotnet test`. Per-task tests live under `tests/Server/…` and `tests/Core/…`. + +**Background (read first):** `docs/plans/2026-06-06-equipment-namespace-materialization-scope.md` — +this plan implements its WS-1, WS-2, WS-4 (+ tests). The reference implementation to mirror is the +Galaxy path: `Phase7Applier.MaterialiseGalaxyTags` + `OpcUaPublishActor.HandleRebuild`. + +--- + +## Architecture decisions (resolve before/while implementing) + +These are surfaced from the investigation; Task 0 records the chosen answers in the plan/code. + +1. **Reuse `EquipmentNodeWalker` vs add a sink pass.** `EquipmentNodeWalker.Walk` is fully built + + unit-tested but writes to an `IAddressSpaceBuilder` (the driver-discovery API), whereas the + rebuild path writes to `IOpcUaAddressSpaceSink`. Two ways to bridge: + - **(A, recommended) Add `Phase7Applier.MaterialiseEquipmentTags(composition)`** — sink-based, + a near-copy of `MaterialiseGalaxyTags`, iterating equipment tags and calling + `_sink.EnsureFolder` / `_sink.EnsureVariable`. Consistent with the rest of the rebuild; no + adapter. Downside: re-expresses some grouping logic the walker already has. + - **(B) Adapt `EquipmentNodeWalker` via a sink-backed `IAddressSpaceBuilder`.** Check for an + existing capturing builder (`GenericDriverNodeManager.CapturingBuilder`, + `src/Core/…/Core/OpcUa/GenericDriverNodeManager.cs`); if one cleanly wraps the sink, call + `EquipmentNodeWalker.Walk(capturingBuilder, content)` and reuse the tested logic. Downside: + couples the rebuild to the driver-builder API + that adapter. + **Recommendation:** spend the first 20 min of Task 2 confirming whether a sink→builder adapter + exists and is cheap. If yes → B (reuse the tested walker). If not → A. This plan is written for + **A** (lower coupling, self-contained); swap the Task 2 body for B if the adapter is clean. + +2. **Where equipment data comes from at rebuild: artifact vs live DB.** `MaterialiseGalaxyTags` uses + the sealed-artifact composition. For consistency and snapshot-correctness, carry equipment data + in the composition too (Task 1). A pragmatic alternative with precedent (the `b1b3f3f` SubscribeBulk + pass queries the live DB) is to load `EquipmentNamespaceContent` directly from the DB in the + rebuild — simpler, but live-DB-vs-sealed-artifact can diverge. **This plan carries it in the + composition (the correct, consistent choice).** + +3. **Folder NodeId vs BrowseName.** Keep the existing scheme: **NodeId = logical Id** + (`UnsAreaId`/`UnsLineId`/`EquipmentId`) so browse-path resolution + ACLs are unaffected; set the + **BrowseName/DisplayName = friendly `Name`** (Task 3). `MaterialiseHierarchy` already keys NodeId + on the Id and displays `DisplayName`; the bug is that `DisplayName` is currently populated with + the Id. The fix is in the composer (Task 3), not the applier. + +4. **No double-materialisation.** `MaterialiseHierarchy` already creates the Area/Line/Equipment + folders. The new equipment-tag pass must only add the **variables** under existing equipment + folders (and any per-tag `FolderPath` sub-folders) — it must NOT re-create the equipment folders. + +--- + +## Task 0: Confirm signatures + record the architecture decisions + +**Classification:** trivial +**Estimated implement time:** ~3 min +**Parallelizable with:** none (do first) + +**Files:** +- Read: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs` (`MaterialiseGalaxyTags`, `MaterialiseHierarchy`, `SafeEnsureFolder`, the `_sink` API) +- Read: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IOpcUaAddressSpaceSink.cs` (exact `EnsureFolder`/`EnsureVariable` signatures) +- Read: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs` (`Phase7CompositionResult`, `Compose`, how `EquipmentNode.DisplayName` + galaxy tags are built) +- Read: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs` (`ParseComposition`, `BuildGalaxyTagPlans`) +- Read: `src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs` (the tested logic to mirror: `AddTagVariable`, identifier properties) + +**Step 1:** Decide A vs B (decision #1) — grep for `CapturingBuilder` / `IAddressSpaceBuilder` +implementations that wrap `IOpcUaAddressSpaceSink`. If a clean adapter exists, note "Task 2 uses B". +**Step 2:** Confirm the sink's `EnsureVariable` signature (NodeId, parent, displayName, +`DriverAttributeInfo` incl. `FullName` + `DataType`) — `MaterialiseGalaxyTags` is the template. +**Step 3:** Record the confirmed decisions as a comment block at the top of the new +`MaterialiseEquipmentTags` (created in Task 2). No code/test change in this task. + +--- + +## Task 1: Carry equipment signals in the deployment composition + artifact + +**Classification:** high-risk +**Estimated implement time:** ~5 min +**Parallelizable with:** none (Task 2 depends on it) + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs` — add an `EquipmentTagPlan` list to `Phase7CompositionResult`; populate it in `Compose` from `Tag` rows where `EquipmentId != null` AND the tag's driver's namespace `Kind == Equipment` (the inverse of the galaxy filter). Set `DisplayName = Name` on Area/Line/Equipment records (decision #3 / Task 3 overlaps — do the field plumbing here). +- Modify: the artifact serializer that writes `ArtifactBlob` (find via `grep -rn "ArtifactBlob\|RevisionHash\|Serialize" src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/ConfigComposer.cs`) — emit the equipment tags (with `EquipmentId`, `FolderPath`, `Name`, `DataType`, `DriverInstanceId`, `TagConfig.FullName`) into the `Tags` array (they are likely already there) and ensure Area/Line/Equipment friendly `Name`s are serialised. +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs` — add `BuildEquipmentTagPlans(root, drivers)`: the mirror of `BuildGalaxyTagPlans` that KEEPS `EquipmentId != null` tags whose namespace `Kind == Equipment`, reading `FullName` from `TagConfig`. Wire it into `ParseComposition`. +- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs` (or the existing composition test file). + +**Step 1 — failing test:** add a test that round-trips an artifact containing one Equipment +namespace + one equipment `Tag` and asserts `ParseComposition(...).EquipmentTags` contains it with +the right `EquipmentId`, `FullName`, `DataType`. Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter EquipmentTag` → FAIL (member missing). +**Step 2 — implement** the `EquipmentTagPlan` record + populate in composer + parse in artifact. +**Step 3 — run** the test → PASS, plus the full `Runtime.Tests` + `OpcUaServer.Tests` suites green. +**Step 4 — commit:** `feat(opcua): carry Equipment-namespace tags through the deployment composition`. + +**Design note:** `EquipmentNamespaceContent` (the `EquipmentNodeWalker` input) uses full entity +types. If Task 2 chooses option B, `EquipmentTagPlan` should carry enough to reconstruct the +`Tag`/`Equipment` fields the walker reads (`Name`, `FolderPath`, `EquipmentId`, `DataType`, +`FullName`). For option A, a flat `EquipmentTagPlan(EquipmentId, DriverInstanceId, FolderPath, Name, DataType, FullName)` is enough. + +--- + +## Task 2: Materialise equipment signals in the live rebuild + +**Classification:** high-risk +**Estimated implement time:** ~5 min +**Parallelizable with:** none (depends on Task 1) + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs` — add `MaterialiseEquipmentTags(Phase7CompositionResult composition)`, a sink-based near-copy of `MaterialiseGalaxyTags`: for each `EquipmentTagPlan`, ensure its `FolderPath` sub-folder (if any) **under the existing equipment folder** (`parentNodeId = EquipmentId` or the sub-folder), then `EnsureVariable(nodeId: FullName, parent, displayName: Name, attributeInfo: new DriverAttributeInfo(FullName, DataType, …))`. Log `equipment tags materialised (tags=N, equipment=M)`. +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs:HandleRebuild` — after `MaterialiseGalaxyTags(composition)`, call `_applier.MaterialiseEquipmentTags(composition)`. +- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs` (sibling to the existing hierarchy test, which mocks `IOpcUaAddressSpaceSink`). + +**Step 1 — failing test:** with a fake sink, call `MaterialiseEquipmentTags` on a composition with +one equipment tag and assert one `EnsureVariable(nodeId == FullName, parent == EquipmentId, displayName == Name)` call landed. Run filtered test → FAIL (method missing). +**Step 2 — implement** `MaterialiseEquipmentTags` (mirror `MaterialiseGalaxyTags`; reuse +`SafeEnsureFolder`; idempotent via the same dedupe the galaxy pass uses) **and** the +`HandleRebuild` wire-up. +**Step 3 — run** the new test + `OpcUaServer.Tests` + `Runtime.Tests` → PASS. +**Step 4 — commit:** `feat(opcua): materialise Equipment-namespace tags in the live rebuild`. + +**If Task 0 chose option B:** instead of a new method, build `EquipmentNamespaceContent` from the +composition, obtain the sink-backed `IAddressSpaceBuilder`, and call `EquipmentNodeWalker.Walk`. +Keep the same `HandleRebuild` call site + test assertions. + +--- + +## Task 3: Friendly browse names for UNS folders + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** none (verify after Task 1, which plumbs `DisplayName`) + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs` — ensure the composition's + Area/Line/Equipment records carry `DisplayName = .Name` (not the logical Id). `MaterialiseHierarchy` + already passes `DisplayName` to the sink as the folder browse name, so this is the only change needed. +- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs` — assert + `SafeEnsureFolder` is called with `displayName == "filling"` (Name) while `nodeId == "nw-area-filling"` (Id). + +**Step 1 — failing test** asserting DisplayName == Name, NodeId == Id. Run → FAIL (currently DisplayName == Id). +**Step 2 — implement** the composer change. +**Step 3 — run** → PASS. +**Step 4 — commit:** `fix(opcua): UNS folders browse by friendly Name, NodeId stays the logical Id`. + +--- + +## Task 4: Idempotency + restart-safety + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** none (after Task 2) + +**Files:** +- Read/verify: `OpcUaPublishActor.HandleRebuild` runs on both the apply path and the + `DriverHostActor.RestoreApplied` bootstrap path (added in `b1b3f3f`) — so the new pass is already + restart-covered. Confirm by inspection; no code change expected. +- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs` — call + `MaterialiseEquipmentTags` twice with the same composition and assert no duplicate `EnsureVariable` + (idempotent), matching the galaxy pass's dedupe behaviour. + +**Step 1 — failing test** (double-apply → single variable). **Step 2 — fix** dedupe if needed. +**Step 3 — run** → PASS. **Step 4 — commit:** `test(opcua): equipment-tag materialisation is idempotent`. + +--- + +## Task 5: docker-dev integration verification + tool support + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** none (last; needs Tasks 1–3 deployed) + +**Files:** +- Create: `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/EquipmentNamespaceMaterializationTests.cs` + (model on the existing `DriverReconnectE2eTests.cs` / phase-7 smoke) — seed a 1-area/1-line/1-equipment/1-tag + Equipment namespace + a Modbus FK driver, apply a deployment, browse `…/filling/line-1//`, + assert the variable exists with `BadWaitingForInitialData` (structure-only). +- Modify (in the `scadaproj` repo, not OtOpcUa): `scadaproj/otopcua-uns-loader/otopcua_uns.py` — + add a `verify` branch that browses the Equipment tree (friendly names) and asserts the leaf count + matches the loaded equipment tags. (Tracked here for completeness; commit in scadaproj.) + +**Step 1 — write** the integration test (skip-guarded if it needs live infra, per the repo's other +integration tests). **Step 2 — run** it against docker-dev (`docs/v2/implementation/phase-7-e2e-smoke.md` +has the harness). **Step 3 — manual confirm** via the AdminUI Deploy at `:9200` + an asyncua browse. +**Step 4 — commit:** `test(opcua): e2e Equipment-namespace structure materialisation`. + +--- + +## Verification (whole milestone) + +After all tasks: deploy an Equipment namespace via `scadaproj/otopcua-uns-loader` (extend it to emit +Equipment rows) + the AdminUI Deploy, then browse `:4840`: +- `OtOpcUa/filling/line-1//` exists, folders browse-named `filling` / `line-1` / … +- leaf variables read `BadWaitingForInitialData` (values are the next milestone). +- A node restart auto-restores the tree (via `RestoreApplied`) with no re-deploy. + +## Out of scope (explicit) +- **Live values** for equipment signals (driver subscribe / VirtualTag engine / OpcUaClient factory) — + the next milestone (scope doc §5 WS-3). +- The Galaxy/SystemPlatform path (works). +- The AdminUI UNS editor. diff --git a/docs/plans/2026-06-06-equipment-namespace-structure-milestone.md.tasks.json b/docs/plans/2026-06-06-equipment-namespace-structure-milestone.md.tasks.json new file mode 100644 index 00000000..c1351ee5 --- /dev/null +++ b/docs/plans/2026-06-06-equipment-namespace-structure-milestone.md.tasks.json @@ -0,0 +1,12 @@ +{ + "planPath": "docs/plans/2026-06-06-equipment-namespace-structure-milestone.md", + "tasks": [ + {"id": 103, "subject": "Task 0: Confirm signatures + record architecture decisions", "status": "pending"}, + {"id": 104, "subject": "Task 1: Carry equipment signals in the composition + artifact", "status": "pending", "blockedBy": [103]}, + {"id": 105, "subject": "Task 2: Materialise equipment signals in the live rebuild", "status": "pending", "blockedBy": [104]}, + {"id": 106, "subject": "Task 3: Friendly browse names for UNS folders", "status": "pending", "blockedBy": [104]}, + {"id": 107, "subject": "Task 4: Idempotency + restart-safety", "status": "pending", "blockedBy": [105]}, + {"id": 108, "subject": "Task 5: docker-dev integration verification + tool support", "status": "pending", "blockedBy": [105, 106]} + ], + "lastUpdated": "2026-06-06" +}