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

14 KiB
Raw Blame History

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 Names 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.