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
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
213 lines
14 KiB
Markdown
213 lines
14 KiB
Markdown
# 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 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/<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.
|