OtOpcUa v3.0 dual-namespace cutover — rebind Data Connection Layer NodeId/namespace #14

Open
opened 2026-07-16 15:40:19 -04:00 by dohertj2 · 1 comment
Owner

Summary

OtOpcUa v3.0 (merged to master 2026-07-16, lmxopcua PR #472, merge ec6598ce) replaces its single OPC UA namespace + NodeId scheme with a two-subtree, dual-namespace address space. This is the wire contract ScadaBridge's Data Connection Layer binds against, so ScadaBridge needs a cutover pass on its OtOpcUa OPC UA client bindings before it can consume v3.0.

What changed on the OtOpcUa server

Before (≤ v2) After (v3.0)
Namespace single https://zb.com/otopcua/ns two: https://zb.com/otopcua/raw + https://zb.com/otopcua/uns
Raw device tree NodeId (equipment-anchored) ns=<raw>;s=<RawPath> where RawPath = Folder/…/Driver/Device/TagGroup/…/Tag (slash-joined, ordinal, case-sensitive)
UNS / equipment NodeId EquipmentNodeIds = {equipmentId}/{folderPath}/{name} ns=<uns>;s=<Area>/<Line>/<Equipment>/<EffectiveName>
EquipmentNodeIds scheme in use retired

Semantics ScadaBridge can rely on:

  • Every value has one source (the raw tag) fanned to both the raw NodeId and each referencing UNS NodeId with identical value/quality/timestamp — subscribe to whichever tree fits ScadaBridge's model.
  • Each UNS variable Organizes-references its backing raw node (cross-tree link is browseable).
  • Writes route through either NodeId (same WriteOperate role gating).
  • HistoryRead works via both NodeIds under one historian tagname.
  • Native Part 9 alarms: one condition at the raw tag (ConditionId = RawPath) fans (single ReportEvent) to the raw device folder and every referencing equipment folder — exactly one Server-object copy; ack/confirm/shelve key on ConditionId = RawPath.

Impact on ScadaBridge (what to review)

  • Any hardcoded/assumed namespace URI (…/otopcua/ns) — now resolve both …/raw and …/uns from the server NamespaceArray.
  • Any NodeId construction/parsing that assumes the old EquipmentNodeIds ({equipmentId}/{folderPath}/{name}) shape.
  • The browse-path assumptions in the Data Connection Layer (Area→Line→Equipment→signal is now the UNS subtree; the device tree is a separate Raw subtree).
  • A&C alarm model: condition identity is now the RawPath; the same condition surfaces under both the raw device folder and equipment-folder notifiers (dedup to one copy at the Server object).
  • Decide which subtree ScadaBridge ingests (equipment-oriented UNS is the natural fit for the DCL; the raw tree is available if device-level identity is wanted).

References

  • OtOpcUa PR #472 (v3.0): lmxopcua master @ ec6598ce
  • Design: docs/plans/2026-07-15-raw-uns-two-subtree-v3-design.md (OtOpcUa repo)
  • PR description w/ the 7-leg live-gate evidence: docs/plans/2026-07-16-v3-batch4-PR.md (OtOpcUa repo)
  • Umbrella index note: scadaproj/CLAUDE.md OtOpcUa entry (updated 2026-07-16)

Filed as the post-merge cross-repo coordination step from the OtOpcUa v3 program (Batches 1–4 complete).

## Summary OtOpcUa **v3.0** (merged to `master` 2026-07-16, `lmxopcua` PR #472, merge `ec6598ce`) replaces its single OPC UA namespace + NodeId scheme with a **two-subtree, dual-namespace address space**. This is the wire contract ScadaBridge's Data Connection Layer binds against, so ScadaBridge needs a **cutover pass** on its OtOpcUa OPC UA client bindings before it can consume v3.0. ## What changed on the OtOpcUa server | | Before (≤ v2) | After (v3.0) | |---|---|---| | Namespace | single `https://zb.com/otopcua/ns` | **two**: `https://zb.com/otopcua/raw` + `https://zb.com/otopcua/uns` | | Raw device tree NodeId | (equipment-anchored) | `ns=<raw>;s=<RawPath>` where RawPath = `Folder/…/Driver/Device/TagGroup/…/Tag` (slash-joined, ordinal, case-sensitive) | | UNS / equipment NodeId | `EquipmentNodeIds` = `{equipmentId}/{folderPath}/{name}` | `ns=<uns>;s=<Area>/<Line>/<Equipment>/<EffectiveName>` | | `EquipmentNodeIds` scheme | in use | **retired** | Semantics ScadaBridge can rely on: - Every value has **one source** (the raw tag) fanned to both the raw NodeId **and** each referencing UNS NodeId with **identical value/quality/timestamp** — subscribe to whichever tree fits ScadaBridge's model. - Each UNS variable `Organizes`-references its backing raw node (cross-tree link is browseable). - **Writes** route through **either** NodeId (same WriteOperate role gating). - **HistoryRead** works via **both** NodeIds under one historian tagname. - **Native Part 9 alarms**: one condition at the raw tag (ConditionId = RawPath) fans (single `ReportEvent`) to the raw device folder **and** every referencing equipment folder — exactly one Server-object copy; ack/confirm/shelve key on ConditionId = RawPath. ## Impact on ScadaBridge (what to review) - Any hardcoded/assumed namespace URI (`…/otopcua/ns`) — now resolve **both** `…/raw` and `…/uns` from the server `NamespaceArray`. - Any NodeId construction/parsing that assumes the old `EquipmentNodeIds` (`{equipmentId}/{folderPath}/{name}`) shape. - The browse-path assumptions in the Data Connection Layer (Area→Line→Equipment→signal is now the **UNS** subtree; the device tree is a separate **Raw** subtree). - A&C alarm model: condition identity is now the RawPath; the same condition surfaces under both the raw device folder and equipment-folder notifiers (dedup to one copy at the Server object). - Decide which subtree ScadaBridge ingests (equipment-oriented UNS is the natural fit for the DCL; the raw tree is available if device-level identity is wanted). ## References - OtOpcUa PR #472 (v3.0): `lmxopcua` `master @ ec6598ce` - Design: `docs/plans/2026-07-15-raw-uns-two-subtree-v3-design.md` (OtOpcUa repo) - PR description w/ the 7-leg live-gate evidence: `docs/plans/2026-07-16-v3-batch4-PR.md` (OtOpcUa repo) - Umbrella index note: `scadaproj/CLAUDE.md` OtOpcUa entry (updated 2026-07-16) Filed as the post-merge cross-repo coordination step from the OtOpcUa v3 program (Batches 1–4 complete).
Author
Owner

Investigation: the premise above is largely wrong — re-scoping

Investigated both sides before starting. There is no OtOpcUa coupling in ScadaBridge to rebind. Grepping src/, docker/, deploy/, infra/ and all config for otopcua, EquipmentNodeIds, equipment, uns, raw returns zero hits in any source, config or Docker file (only prose in docs/plans/ and one telemetry comment).

The Data Connection Layer is a fully generic OPC UA client: it stores an opaque string and hands it to NodeId.Parse at the SDK boundary (RealOpcUaClient.cs:413/831/851), knowing only spec-fixed ns=0 NodeIds. So three of the five "Impact" bullets above — hardcoded namespace URI, EquipmentNodeIds parsing, browse-path assumptions — describe code that does not exist.

OtOpcUa's own design doc agrees (2026-07-15-raw-uns-two-subtree-v3-design.md:381-395):

"ScadaBridge's Data Connection Layer does not keep its bindings… Every ScadaBridge binding re-binds. The re-bind is a data migration, not a code rewrite."

What is actually true

1. This is a data re-bind, and there is no code to write for it. Every binding bakes ns=2 into a stored string, and ScadaBridge stores no namespace URI anywhere — so there is no code to fix, and equally no code that could detect the rot. Affected columns: TemplateAttribute.DataSourceReference, InstanceConnectionBinding.DataSourceReferenceOverride, TemplateNativeAlarmSource.SourceReference, InstanceNativeAlarmSourceOverride.SourceReferenceOverride, plus OpcUaHeartbeatConfig.TagPath and any exported Transport bundles / CSVs in flight. Re-authoring needs a live v3 rig; the design states there is no reliable automatic old→new mapping (greenfield).

Trap: v3's Raw namespace also lands at ns=2 — the same index v2's single namespace used. Old bindings will not reliably fail loudly.

2. Corrections to the v3 contract as described above. Verified against the OtOpcUa source:

  • The UNS namespace holds three id shapes, not one: Area/Line/Equipment folders use logical ids (ns=3;s=<EquipmentId>); tag references use name paths (ns=3;s=<Area>/<Line>/<Equipment>/<EffectiveName>); VirtualTags keep the old {equipmentId}/{folderPath}/{name} shape with only the namespace moving 2→3 (AddressSpaceApplier.cs:414-427, 645-655, 794).
  • There is one shared root Objects/OtOpcUa (ns=2;s=OtOpcUa) — there is no /Raw and no /UNS top-level folder. The "two subtrees" are realised by namespace index only (OtOpcUaNodeManager.cs:3167-3184, :122-123).
  • BrowseName is the full path string, not the leaf name (:1595, :1689, :3193). DisplayName carries the friendly leaf.
  • Namespace indices are deterministic today (0=UA, 1=ApplicationUri, 2=raw, 3=uns, 4=Diagnostics) but must still be resolved by URI.

3. Native alarms are blocked by a server-side defect, not by NodeId shape. OtOpcUa v3 emits null SourceNode, SourceName and EventType on every condition (OtOpcUaNodeManager.cs:781-809 never assigns them; the SDK's auto-filling BaseEventState.Initialize overload is never called). ScadaBridge selects exactly those fields (RealOpcUaClient.cs:464) and derives identity from SourceName falling back to SourceNode (:749-751), so its key degrades to ".HR200". Filed as lmxopcua#473.

ScadaBridge has its own half of this: it never selects ConditionId, yet ConditionId (= RawPath) is the identity both Part 9 and OtOpcUa's docs/AlarmTracking.md:47 key on. Because the server resolves ConditionId from the condition node itself rather than a populated field, re-keying on ConditionId would fix alarm identity here without waiting for lmxopcua#473 — only condition-type filtering needs the server fix (or an OfType where-clause instead of reading the null EventType field).

Related latent defect, independent of v3: SourceReference is documented as a NodeId (TemplateNativeAlarmSource.cs:22) and parsed as one (RealOpcUaClient.cs:478), but transitions are routed by SourceObjectReference.StartsWith(sourceRef) (DataConnectionActor.cs:1978) against the A&C SourceName — so a NodeId-form source drops every transition. Only the empty-string (Server-object) path is smoke-tested (OpcUaAlarmLiveSmokeTests.cs:64), which makes the prefix match trivially true.

Re-scoped plan

Work Where Status
Null SourceNode/SourceName/EventType on conditions OtOpcUa filed lmxopcua#473
nsu= URI-qualified bindings + resolve URI→index at connect ScadaBridge this issue
Browse emits ExpandedNodeId strings NodeId.Parse cannot re-parse (RealOpcUaClient.cs:1069 vs the correct :1126) ScadaBridge this issue
Re-key native alarms on ConditionId; type filter via OfType ScadaBridge deferred — separate issue, needs the cross-repo identity model settled
Re-author every binding against a v3 rig operational needs a live v3 environment

This issue now tracks the two code items in bold: making bindings namespace-table-proof (nsu=), which is the structural fix both OtOpcUa's design doc and this analysis land on, and the browse round-trip gap.

## Investigation: the premise above is largely wrong — re-scoping Investigated both sides before starting. **There is no OtOpcUa coupling in ScadaBridge to rebind.** Grepping `src/`, `docker/`, `deploy/`, `infra/` and all config for `otopcua`, `EquipmentNodeIds`, `equipment`, `uns`, `raw` returns **zero hits** in any source, config or Docker file (only prose in `docs/plans/` and one telemetry comment). The Data Connection Layer is a fully generic OPC UA client: it stores an opaque string and hands it to `NodeId.Parse` at the SDK boundary (`RealOpcUaClient.cs:413/831/851`), knowing only spec-fixed `ns=0` NodeIds. So three of the five "Impact" bullets above — hardcoded namespace URI, `EquipmentNodeIds` parsing, browse-path assumptions — describe code that does not exist. OtOpcUa's own design doc agrees (`2026-07-15-raw-uns-two-subtree-v3-design.md:381-395`): > "ScadaBridge's Data Connection Layer does **not** keep its bindings… **Every ScadaBridge binding re-binds.** The re-bind is a *data* migration, not a code rewrite." ### What is actually true **1. This is a data re-bind, and there is no code to write for it.** Every binding bakes `ns=2` into a stored string, and ScadaBridge stores no namespace URI anywhere — so there is no code to fix, and equally no code that could detect the rot. Affected columns: `TemplateAttribute.DataSourceReference`, `InstanceConnectionBinding.DataSourceReferenceOverride`, `TemplateNativeAlarmSource.SourceReference`, `InstanceNativeAlarmSourceOverride.SourceReferenceOverride`, plus `OpcUaHeartbeatConfig.TagPath` and any exported Transport bundles / CSVs in flight. Re-authoring needs a live v3 rig; the design states there is no reliable automatic old→new mapping (greenfield). **Trap:** v3's Raw namespace also lands at **ns=2** — the same index v2's single namespace used. Old bindings will not reliably fail loudly. **2. Corrections to the v3 contract as described above.** Verified against the OtOpcUa source: - The UNS namespace holds **three** id shapes, not one: Area/Line/Equipment folders use **logical ids** (`ns=3;s=<EquipmentId>`); tag references use **name paths** (`ns=3;s=<Area>/<Line>/<Equipment>/<EffectiveName>`); VirtualTags keep the old `{equipmentId}/{folderPath}/{name}` shape with only the namespace moving 2→3 (`AddressSpaceApplier.cs:414-427, 645-655, 794`). - There is **one shared root** `Objects/OtOpcUa` (`ns=2;s=OtOpcUa`) — there is **no** `/Raw` and **no** `/UNS` top-level folder. The "two subtrees" are realised by **namespace index only** (`OtOpcUaNodeManager.cs:3167-3184`, `:122-123`). - `BrowseName` is the **full path string**, not the leaf name (`:1595`, `:1689`, `:3193`). `DisplayName` carries the friendly leaf. - Namespace indices are deterministic today (0=UA, 1=ApplicationUri, 2=raw, 3=uns, 4=Diagnostics) but must still be resolved by URI. **3. Native alarms are blocked by a server-side defect, not by NodeId shape.** OtOpcUa v3 emits **null `SourceNode`, `SourceName` and `EventType`** on every condition (`OtOpcUaNodeManager.cs:781-809` never assigns them; the SDK's auto-filling `BaseEventState.Initialize` overload is never called). ScadaBridge selects exactly those fields (`RealOpcUaClient.cs:464`) and derives identity from `SourceName` falling back to `SourceNode` (`:749-751`), so its key degrades to `".HR200"`. Filed as **lmxopcua#473**. ScadaBridge has its own half of this: it **never selects `ConditionId`**, yet `ConditionId` (= RawPath) is the identity both Part 9 and OtOpcUa's `docs/AlarmTracking.md:47` key on. Because the server resolves `ConditionId` from the condition node itself rather than a populated field, **re-keying on `ConditionId` would fix alarm identity here without waiting for lmxopcua#473** — only condition-*type* filtering needs the server fix (or an `OfType` where-clause instead of reading the null `EventType` field). Related latent defect, independent of v3: `SourceReference` is documented as a NodeId (`TemplateNativeAlarmSource.cs:22`) and parsed as one (`RealOpcUaClient.cs:478`), but transitions are routed by `SourceObjectReference.StartsWith(sourceRef)` (`DataConnectionActor.cs:1978`) against the A&C **SourceName** — so a NodeId-form source drops every transition. Only the empty-string (Server-object) path is smoke-tested (`OpcUaAlarmLiveSmokeTests.cs:64`), which makes the prefix match trivially true. ### Re-scoped plan | Work | Where | Status | |---|---|---| | Null `SourceNode`/`SourceName`/`EventType` on conditions | OtOpcUa | filed **lmxopcua#473** | | `nsu=` URI-qualified bindings + resolve URI→index at connect | ScadaBridge | **this issue** | | Browse emits `ExpandedNodeId` strings `NodeId.Parse` cannot re-parse (`RealOpcUaClient.cs:1069` vs the correct `:1126`) | ScadaBridge | **this issue** | | Re-key native alarms on `ConditionId`; type filter via `OfType` | ScadaBridge | deferred — separate issue, needs the cross-repo identity model settled | | Re-author every binding against a v3 rig | operational | needs a live v3 environment | This issue now tracks the two code items in bold: making bindings namespace-table-proof (`nsu=`), which is the structural fix both OtOpcUa's design doc and this analysis land on, and the browse round-trip gap.
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: dohertj2/ScadaBridge#14