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

12 KiB
Raw Blame History

OtOpcUa v3.0 dual-namespace cutover — scoping (Gitea #14)

Date: 2026-07-23 · Status: SCOPING (decisions needed — see §6) · Tracked: Gitea #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 EventFieldLists 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.