feat(dcl): OtOpcUa v3 cutover — nsu= namespace binding + native-alarm routing (#14, #17) #20

Merged
dohertj2 merged 2 commits from feat/otopcua-v3-cutover into main 2026-07-17 15:07:46 -04:00
Owner

Refs #14. Fixes #17.

What

The OtOpcUa v3.0 cutover work for ScadaBridge's Data Connection Layer. Investigation established that #14 is a data re-bind, not a code cutover — there is zero OtOpcUa coupling in src/ (the DCL is a generic OPC UA client). What the code actually needed were two structural fixes.

#14 — bind by namespace URI, not baked index (d32050da)

A stored ns=<index> reference is only meaningful against one server's namespace table; a server that adds/removes/reorders a namespace silently re-points every binding — worst case to a different namespace with a colliding identifier, resolving to the wrong node with Good quality. ScadaBridge stored no URI anywhere, so nothing could detect it. OtOpcUa v3 makes it concrete: v2's sole custom namespace and v3's raw namespace both sit at index 2.

OpcUaNodeReference is the single translation seam. Resolve() accepts both ns=<index> (existing bindings, unchanged) and the durable nsu=<uri>, mapping the URI to the live index at every parse site (subscribe/read/write/alarm/browse). An unpublished URI throws naming the URI rather than binding to whatever occupies the index; a svr= reference is rejected. The browser emits ToDurable(), so newly-authored bindings are index-proof — which also closes a round-trip gap where browse emitted an ExpandedNodeId string NodeId.Parse couldn't read back.

#17 — route native alarms by binding identity, not event name (846cc5d9)

A native OPC UA alarm SourceReference had to be a NodeId (to open the monitored item) and a name prefix (to route the event) at once — no string is both, so NodeId-form bindings dropped every transition. Since each feed is opened for one binding, the adapter now tags each transition's routing identity with the binding string verbatim (via the pure OpcUaAlarmMapper.BuildIdentity), so routing exact-matches regardless of ns=/nsu= form. MxGateway is untouched; the readable per-condition key is unchanged. Unblocked by lmxopcua#473.

Verification

  • Full solution build: 0 warnings, 0 errors.
  • DataConnectionLayer.Tests: 253 green (12 new nsu= tests + 6 new alarm tests); every new alarm test proven red against the unfixed behaviour, then green.
  • Branch verified standalone off main (without the #15 work) to confirm no hidden dependency.

Still open (not in this PR)

  • The actual binding re-authoring needs a live v3 rig — no reliable automatic old→new mapping exists (greenfield). #14 stays open after merge for this.
  • Native-alarm behaviour is verified by construction (fake client); end-to-end proof needs a v3 rig.
  • Three v3 contract corrections and the full cutover checklist are in the #14 comment thread.
Refs #14. Fixes #17. ## What The OtOpcUa v3.0 cutover work for ScadaBridge's Data Connection Layer. Investigation established that #14 is a **data re-bind, not a code cutover** — there is zero OtOpcUa coupling in `src/` (the DCL is a generic OPC UA client). What the code actually needed were two structural fixes. ### #14 — bind by namespace URI, not baked index (`d32050da`) A stored `ns=<index>` reference is only meaningful against one server's namespace table; a server that adds/removes/reorders a namespace silently re-points every binding — worst case to a *different* namespace with a colliding identifier, resolving to the wrong node with Good quality. ScadaBridge stored no URI anywhere, so nothing could detect it. OtOpcUa v3 makes it concrete: v2's sole custom namespace and v3's `raw` namespace **both sit at index 2**. `OpcUaNodeReference` is the single translation seam. `Resolve()` accepts both `ns=<index>` (existing bindings, unchanged) and the durable `nsu=<uri>`, mapping the URI to the live index at every parse site (subscribe/read/write/alarm/browse). An unpublished URI throws naming the URI rather than binding to whatever occupies the index; a `svr=` reference is rejected. The browser emits `ToDurable()`, so newly-authored bindings are index-proof — which also closes a round-trip gap where browse emitted an `ExpandedNodeId` string `NodeId.Parse` couldn't read back. ### #17 — route native alarms by binding identity, not event name (`846cc5d9`) A native OPC UA alarm `SourceReference` had to be a NodeId (to open the monitored item) *and* a name prefix (to route the event) at once — no string is both, so NodeId-form bindings dropped every transition. Since each feed is opened for one binding, the adapter now tags each transition's routing identity with the binding string verbatim (via the pure `OpcUaAlarmMapper.BuildIdentity`), so routing exact-matches regardless of `ns=`/`nsu=` form. MxGateway is untouched; the readable per-condition key is unchanged. Unblocked by lmxopcua#473. ## Verification - Full solution build: 0 warnings, 0 errors. - `DataConnectionLayer.Tests`: 253 green (12 new nsu= tests + 6 new alarm tests); every new alarm test proven red against the unfixed behaviour, then green. - Branch verified **standalone off `main`** (without the #15 work) to confirm no hidden dependency. ## Still open (not in this PR) - The actual binding re-authoring needs a **live v3 rig** — no reliable automatic old→new mapping exists (greenfield). #14 stays open after merge for this. - Native-alarm behaviour is verified by construction (fake client); **end-to-end proof needs a v3 rig**. - Three v3 contract corrections and the full cutover checklist are in the #14 comment thread.
dohertj2 added 2 commits 2026-07-17 15:04:59 -04:00
A stored ns=<index> reference is only meaningful against one server's namespace
table. A server that adds, removes or reorders a namespace silently re-points
every binding: best case BadNodeIdUnknown, worst case the index now names a
different namespace holding a colliding identifier and the binding resolves to
the wrong node with Good quality. Nothing could detect that, because ScadaBridge
stored no namespace URI anywhere.

OtOpcUa v3.0 makes this concrete: v2's sole custom namespace and v3's raw
namespace both sit at index 2, so v2-era bindings resolve against v3 without
error while meaning something else entirely.

Adds OpcUaNodeReference as the single translation seam between stored references
and the wire. Resolve() accepts both ns=<index>;s=<id> (existing bindings, which
keep working unchanged) and the durable nsu=<uri>;s=<id>, mapping the URI to the
live index at use time; it is wired into every parse site — subscribe, read,
write, alarm-subscribe and browse. An unpublished URI now throws naming the URI
rather than binding to whatever occupies that index, and a svr= reference to
another server is rejected instead of being resolved against the wrong address
space.

The browser emits ToDurable(), so what the picker shows is what gets stored and
newly-authored bindings are index-proof from the start. That also closes a
round-trip gap: browse previously emitted ExpandedNodeId.ToString(), which for a
URI- or server-index-carrying reference produced a string NodeId.Parse could not
read back — the same method already resolved it correctly 57 lines later.

Bindings stored before this change keep their ns= form and keep working; they are
only as durable as the server's namespace order. Re-authoring against the picker
is what makes them durable, and that re-bind still needs a live v3 rig.

Refs: Gitea #14
A native OPC UA alarm source's SourceReference has to be two things at once:
it is parsed as a NodeId to open the monitored item, and matched as a plain-name
prefix against the event's SourceName to route the transition to an instance. No
string is both, so a NodeId-form binding subscribed correctly and then silently
dropped every transition — "pymodbus/plc/HR200".StartsWith("nsu=...;s=pymodbus/plc/HR200")
is false. Only the empty (Server-object) binding worked, because StartsWith("")
matches everything, which is why the sole live smoke test never caught it.

Each OPC UA alarm feed is opened for exactly one binding, so every transition on
it belongs to that binding. The adapter now tags each transition's routing
identity (SourceObjectReference) with the binding string verbatim via the pure
OpcUaAlarmMapper.BuildIdentity, making DataConnectionActor's routing key an exact
match regardless of whether the binding is stored as ns=<index> or the durable
nsu=<uri> form. The Server-object aggregate feed keeps an empty routing identity,
so it reaches only "mirror everything" subscribers and never leaks into a
specific-node binding. The per-condition SourceReference key stays the readable
SourceName.ConditionName, so persistence and display are unchanged, and MxGateway
is untouched — its bindings are names and its mapper already emits matching names.

Unblocked by lmxopcua#473 (OtOpcUa now populates SourceNode/SourceName/EventType
on conditions); SourceName is the RawPath, so the per-condition key is unique.
Live end-to-end verification against native alarms still needs a v3 rig.

Fixes: Gitea #17
dohertj2 merged commit 3e84eee195 into main 2026-07-17 15:07:46 -04:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: dohertj2/ScadaBridge#20