12 KiB
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
WriteOperategating). - 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 singleReportEventto the raw device folder and every referencing equipment folder. A Server-object subscriber gets exactly one copy (the SDK dedups on the sharedInstanceStateSnapshot); 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.Resolvemapsnsu=<uri>to the live index viaExpandedNodeId.ToNodeId(expanded, namespaceUris)against the session'sNamespaceTable, and throws when a URI is absent from the server'sNamespaceArray(no silent stale-index binding); rejectssvr=cross-server refs.ToDurableemits thensu=<uri>form by readingnamespaceUris.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 onlyEndpointUrl+ 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.csBuildIdentity— the per-condition key is alreadySourceName (= 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
- Decisions (§6) — resolve D-1..D-3 before code.
nsu=hardening + UX (items D, E, F) — picker emits/enforcesnsu=, placeholder + search caps updated, docs swept. Pure ScadaBridge-side, unit-testable, no live server required.- 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/SourceNodepayload so item C's UNS→Raw routing mapping is built against real data. - 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.
- 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.
- 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 itsSourceNameis the RawPath regardless of which subtree the source was bound on. So a UNS-bound alarm source (prefixnsu=…/uns;s=<Area>/<Line>/…) will notStartsWith-match a condition whoseSourceNameis 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→RawOrganizesreference 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) barens=<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
OpcUaEndpointConfighas 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 3–4 need a re-seeded v3 rig.