Files
lmxopcua/docs/plans/2026-06-06-equipment-namespace-structure-milestone.md
T
Joseph Doherty c18943f6e1
v2-ci / build (push) Failing after 33s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
docs: task-by-task plan for the Equipment-namespace structure milestone
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
2026-06-06 14:20:36 -04:00

213 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 = <row>.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 13 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/<eq>/<signal>`,
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/<equipment>/<signal>` 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.