Files
ScadaBridge/docs/plans/2026-07-23-otopcua-v3-dual-namespace-cutover-scope.md
T

141 lines
12 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.
# OtOpcUa v3.0 dual-namespace cutover — scoping (Gitea #14)
**Date:** 2026-07-23 · **Status:** SCOPING (decisions needed — see §6) · **Tracked:** Gitea
[#14](https://gitea.dohertylan.com/dohertj2/ScadaBridge/issues/14) · **Area:** Data Connection
Layer (OPC UA adapter) · **Upstream:** OtOpcUa v3.0 (merged `master` 2026-07-16, PR #472, merge
`ec6598ce`); design `~/Desktop/OtOpcUa/docs/plans/2026-07-15-raw-uns-two-subtree-v3-design.md`.
## 1. Headline
**ScadaBridge is already structurally v3-safe.** A prior refactor made the OPC UA reference a
**namespace-URI-durable** string resolved dynamically against the live server `NamespaceArray`, so
the retirement of OtOpcUa's `EquipmentNodeIds` scheme and the split into two namespaces does **not**
break any parsing or binding logic. There is **no hardcoded `otopcua` namespace URI anywhere in
`src/`** (only test fixtures), and **nothing in ScadaBridge parses the `{EquipmentId}/…` NodeId
shape** — the identifier is opaque.
The genuine cutover is therefore **narrow**: one **data** problem (a namespace-index collision on
legacy bindings), one real **code gap** (native-alarm dedup across the raw+uns notifier fan-out),
and some **UX / validation** polish. Most of it can only be *closed* against a re-seeded live v3
rig, so this is as much a live-gate as a code change.
## 2. The v3 wire contract (what changed upstream)
OtOpcUa replaced its single custom namespace `https://zb.com/otopcua/ns` with **two**:
| Subtree | Namespace URI | NodeId form | Shape |
|---|---|---|---|
| **Raw** (device tree, source of truth) | `https://zb.com/otopcua/raw` | `ns=<raw>;s=<RawPath>` | `Folder/…/Driver/Device/TagGroup/…/Tag` |
| **UNS** (equipment projection) | `https://zb.com/otopcua/uns` | `ns=<uns>;s=<Area>/<Line>/<Equipment>/<EffectiveName>` | Area→Line→Equipment→signal |
Semantics ScadaBridge can rely on (from the v3 design doc):
- Every value has **one source** (the raw tag), fanned to both the raw NodeId and each referencing
UNS NodeId with **identical value/quality/timestamp**. Each UNS variable `Organizes`-references
its raw node (cross-tree link is browseable).
- **Writes** route through **either** NodeId (same `WriteOperate` gating).
- **HistoryRead** works via **both** NodeIds under one historian tagname.
- **Native Part 9 alarms:** one condition instance at the raw tag, `ConditionId`/primary identity =
**RawPath**, fanned by a **single `ReportEvent`** to the raw device folder **and** every
referencing equipment folder. A Server-object subscriber gets **exactly one** copy (the SDK
dedups on the shared `InstanceStateSnapshot`); folder-scoped subscribers in each namespace see the
condition's events.
- The old `EquipmentNodeIds` (`{equipmentId}/{folderPath}/{name}`) scheme is **retired**.
OtOpcUa's own "Cross-repo impact" note sized this for us: **every ScadaBridge binding re-binds**;
it's a *data* migration, not a code rewrite; the fragile part is that stored refs hard-code the
**namespace index** and ScadaBridge stores no URI; the recommended remediation is to **move to
`nsu=`-qualified references** while re-binding.
## 3. Current-state assessment (what is already v3-safe)
Grounded in the DCL code (`src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/`):
- **`Adapters/OpcUaNodeReference.cs` — the single translation seam, already URI-durable.**
`Resolve` maps `nsu=<uri>` to the live index via `ExpandedNodeId.ToNodeId(expanded,
namespaceUris)` against the session's `NamespaceTable`, and **throws** when a URI is absent from
the server's `NamespaceArray` (no silent stale-index binding); rejects `svr=` cross-server refs.
`ToDurable` emits the `nsu=<uri>` form by reading `namespaceUris.GetString(NamespaceIndex)`.
**No change needed — this seam is the reason the cutover is low-risk.**
- **`Adapters/RealOpcUaClient.cs`** — every read/write/subscribe/browse routes NodeIds through the
seam with the live `_session.NamespaceUris`. No hardcoded URI, no assumed index.
- **`Adapters/OpcUaDataConnection.cs`** — passes the configured tag-path string straight to the
client; no path parsing/joining. There is **no browse-path→NodeId translation** anywhere
(bindings are absolute NodeIds), so there is no indirection layer to update.
- **`Commons/Types/DataConnections/OpcUaEndpointConfig.cs`** — carries only `EndpointUrl` + timing/
auth/heartbeat; **no namespace URI, index, or NodeId field**. Namespaces are discovered from the
live server, not configured — inherently v3-tolerant. Nothing to change here.
- **`Adapters/OpcUaAlarmMapper.cs` `BuildIdentity`** — the per-condition key is already
`SourceName (= RawPath post-v3) + "." + ConditionName`, i.e. **already aligned** with v3 keying on
RawPath; the routing identity is the binding string verbatim (namespace-form-independent).
## 4. The genuine cutover work
| # | Item | Kind | Primary files | Confidence |
|---|------|------|---------------|------------|
| A | **Legacy `ns=<index>` bindings collide with v3.** v2's sole custom namespace and v3's `raw` both land at `ns=2`, so a stored `ns=2;s=…` resolves **without error** but now means a raw-tree node. Every stored binding must be re-authored (or migrated) to the new address space, ideally in `nsu=` form. | **Data** | `TemplateAttribute.DataSourceReference`, `InstanceConnectionBinding.DataSourceReferenceOverride` (config DB); heartbeat `TagPath`; Transport bundles | High (inspection) |
| B | **Native-alarm dedup across the raw+uns fan-out.** `RealOpcUaClient.HandleAlarmEvent` keys on RawPath+ConditionName with **no `ConditionId`-based dedup**; it assumes one condition arrives on one feed. If a v3 server delivers the same condition through two notifier paths into the same feed, two `EventFieldList`s each call `onTransition`. Must verify the Server-object aggregate feed collapses to one copy (the v3 doc says the SDK dedups Server-object subscribers, so this *should* hold — but it is the biggest genuine gap and needs a live check; add ConditionId-based dedup only if the live server double-delivers). | **Code + live validation** | `Adapters/RealOpcUaClient.cs` (HandleAlarmEvent), `Adapters/OpcUaAlarmMapper.cs` | Medium (needs live v3) |
| C | **Alarm routing for UNS-bound sources (confirmed by D-1 = ingest both).** `DataConnectionActor` routes transitions by `SourceReference.StartsWith(bindingRef)`, but a v3 condition's `SourceName` is the **RawPath** regardless of subtree — so a **UNS-bound** alarm source won't `StartsWith`-match. Needs a routing change: resolve a UNS-bound source to its backing RawPath (via the browseable UNS→Raw `Organizes` reference) or map identities. Confirmed scope; validate the exact `SourceName`/`SourceNode` payload on the live rig before finalizing the mapping. | **Code + live validation** | `Actors/DataConnectionActor.cs` (alarm routing), `Adapters/RealOpcUaClient.cs` | Medium (needs live v3) |
| D | **Browse / search UX now shows two subtrees.** `BrowseChildrenAsync` / `AddressSpaceSearch.SearchAsync` start at the standard Objects root and will enumerate **both** raw and uns as sibling roots. Functional, not breaking — but the picker shows two roots, the manual-entry placeholder still nudges `ns=2;s=…`, and the deeper raw hierarchy stresses `VisitedNodeCeiling`/`maxDepth`. | **UX** | `CentralUI/Components/Dialogs/NodeBrowserDialog.razor`, `IOpcUaClient` search caps | High (inspection) |
| E | **`nsu=` hardening (optional, recommended).** The picker already emits `nsu=` via `ToDurable`, but the seam still **accepts** bare `ns=<index>`. Decide whether to warn/reject bare `ns=` on save (closes A permanently) or leave it permissive. | **Code** | `OpcUaNodeReference`, binding validation, picker | High (inspection) |
| F | **Doc drift.** `ns=` examples in code comments + `Component-DataConnectionLayer.md`; update to `nsu=`. Update the scadaproj umbrella index (cutover step 4). | **Docs** | doc comments, `docs/requirements/Component-DataConnectionLayer.md`, `../scadaproj/CLAUDE.md` | High |
## 5. Proposed phasing
1. **Decisions** (§6) — resolve D-1..D-3 before code.
2. **`nsu=` hardening + UX** (items D, E, F) — picker emits/enforces `nsu=`, placeholder + search
caps updated, docs swept. Pure ScadaBridge-side, unit-testable, no live server required.
3. **Re-seed a v3 rig** — bring up an OtOpcUa v3.0 server (docker) and re-author a representative
set of bindings across **both** subtrees via the picker (D-1); confirm subscribe/read/write
round-trip and HistoryRead via each subtree, and capture the exact native-alarm `SourceName`/
`SourceNode` payload so item C's UNS→Raw routing mapping is built against real data.
4. **Alarm live-gate** (items B, C) — against the v3 rig, drive a native condition that fans to both
raw + equipment notifiers and confirm ScadaBridge sees **exactly one** transition per state
change, correctly routed to the bound source. Add ConditionId-based dedup / routing fix **only if
the live server double-delivers**.
5. **Data migration decision** (item A) — either force re-authoring (greenfield, no automatic map)
or write a one-shot migration if a deterministic old→new mapping exists for the rig's data.
6. **Umbrella index + issue close.**
## 6. Decisions needed
- **D-1 — Which subtree does ScadaBridge ingest? → DECIDED (2026-07-23): BOTH.** The picker
surfaces both the Raw (`…/raw`, device tree) and UNS (`…/uns`, Area→Line→Equipment) subtrees, and
an operator binds each attribute / native-alarm source against whichever fits — UNS for
equipment-modelled signals, Raw where device-level identity is wanted. For **values** this is
free: a given ScadaBridge attribute binds to one NodeId, and v3 guarantees identical
value/quality/timestamp on both trees, so no per-value dedup arises.
**Consequence for alarms (promotes item C from "validate" to "fix"):** a native alarm condition is
keyed by its **RawPath** and its `SourceName` is the RawPath *regardless of which subtree the
source was bound on*. So a **UNS-bound** alarm source (prefix `nsu=…/uns;s=<Area>/<Line>/…`) will
**not** `StartsWith`-match a condition whose `SourceName` is the raw path. Ingesting both therefore
**requires** a routing change so a UNS-bound source resolves to the RawPath of its backing raw node
(the UNS→Raw `Organizes` reference is browseable and is the link to follow), or an equivalent
identity mapping. This is confirmed scope, gated on the live v3 rig (§5 phase 4).
- **D-2 — Enforce `nsu=` on save?** *Recommendation: yes* — warn (or reject) bare `ns=<index>`
bindings at authoring time so the index-collision class (item A) can never recur. Low effort, high
durability.
- **D-3 — Migrate legacy bindings, or re-author?** Upstream is greenfield (no reliable automatic
old→new mapping), so *re-authoring via the picker is the default*. A migration is only worth
writing if this environment has a deterministic mapping worth automating.
## 7. Risks / what needs a live v3 server
- Items **B** and **C** (alarm dedup + routing) **cannot be fully closed by code inspection** — they
depend on how a live v3 server actually delivers a fanned condition to an aggregate vs
folder-scoped subscription. The plan gates them behind a re-seeded v3 rig.
- No production config data is preserved upstream (greenfield), so there is **no migration
correctness risk** to legacy production rows — only the dev/test rigs need re-authoring.
- The `OpcUaEndpointConfig` has no namespace field, so **no schema/EF change** is anticipated; this
cutover is expected to add **no EF migration** (to be confirmed once D-2's validation surface is
decided).
## 8. Bottom line
The prior namespace-durability refactor did the heavy lifting: ScadaBridge already resolves
`nsu=`-qualified references against the live `NamespaceArray` and never assumes an index or a NodeId
shape. What remains is (1) getting existing bindings onto the new address space (data / re-author),
(2) optionally enforcing `nsu=` so the index-collision class is closed for good, (3) tidying the
two-subtree browse UX, and (4) a **live alarm-fan-out validation** that is the only item carrying
real code risk. Once §6 is decided, phase 2 (hardening + UX + docs) is straightforward ScadaBridge
work; phases 34 need a re-seeded v3 rig.