docs(plans): OtOpcUa v3 native-alarm B/C live-gate PASS (#14) — no code needed; item C premise obsolete (Gitea #17)

This commit is contained in:
Joseph Doherty
2026-07-24 04:33:20 -04:00
parent 86d129de6c
commit 9a12826172
@@ -0,0 +1,64 @@
# OtOpcUa v3 native-alarm B/C live-gate (Gitea #14) — PASS
**Date:** 2026-07-23 · **Scope:** items **B** (native-alarm dedup) + **C** (UNS-bound alarm routing) of #14,
against a UNS structure + scripted Part-9 alarm authored on the live `otopcua-dev` v3 cluster (see
`2026-07-23-otopcua-v3-raw-path-live-gate.md` for the connection + `SiteAOnly` substrate). Instrument:
ScadaBridge itself (its DCL A&C client is the thing under test).
## Headline
**Both B and C PASS, and item C's original premise turned out to be already-solved code.** No ScadaBridge
code change is needed for B/C. The scope-doc worry — "a UNS-bound source won't `StartsWith`-match a
condition whose `SourceName` is the RawPath/ScriptedAlarmId" — is **obsolete**: ScadaBridge routes native
alarms by the **subscribed node reference**, not the event `SourceName` (deliberate fix, Gitea #17). The
only remaining #14 work is the item-A data re-author (greenfield, operational).
## Substrate (on OtOpcUa, SITE-A cluster)
- UNS: `area-1/line-1/pump01` → signal `SiteAOnly` (equipment folder node `ns=3;s=EQ-54f7711e160d`,
which has **`EventNotifier=1`**; signal `ns=3;s=area-1/line-1/pump01/SiteAOnly` mirrors the raw value).
- Scripted Part-9 condition `ns=3;s=SA-siteahigh` (a `HasComponent` child of the equipment folder),
`AlarmConditionType`, severity 700, predicate driven by `SiteAOnly` (set always-active for the gate).
- `Server` (i=2253) `EventNotifier=1` (the connection-wide aggregate).
## What ScadaBridge did (bindings on template 2148, instance 98, connection `OtOpcUa-v3-raw`)
Two `TemplateNativeAlarmSource` bindings, deliberately overlapping to stress dedup:
- `ServerAlarms``--source-ref i=2253` (Server aggregate)
- `UnsEquipAlarms``--source-ref nsu=https://zb.com/otopcua/uns;s=EQ-54f7711e160d` (UNS equipment node)
## Findings / checks
| # | Check | Result |
|---|-------|--------|
| C1 | A **UNS-node-scoped** binding receives + routes the native alarm | **PASS**`UnsEquipAlarms` mirrored `SA-siteahigh.SiteA High` active=True, sev 700, `kind=NativeOpcUa` |
| C2 | The condition's `SourceName` = **ScriptedAlarmId** (`SA-siteahigh`), NOT RawPath | **Confirmed** (captured live) — and it does **not** break routing (routing keys off the subscribed node, Gitea #17), only feeds the alarm's identity (`alarmName = SA-siteahigh.SiteA High`) |
| C3 | Item C's original "SourceName mismatch drops UNS-bound transitions" premise | **Obsolete**`OpcUaAlarmMapper.BuildIdentity` stamps `SourceObjectReference` = the subscription ref; `DataConnectionActor` routes on that, not `SourceName` |
| B1 | Fanned condition (Server aggregate + equipment-folder notifier) mirrored **once**, not duplicated | **PASS** — active condition appears exactly once (on the more-specific equipment feed); the Server binding stays an inactive placeholder. Stable across repeated snapshots |
| B2 | Mechanism | ScadaBridge opens **one** OPC UA `Subscription` per connection with one `MonitoredItem` per source-ref; `ConditionRefresh` delivers each retained condition **once per subscription**, to the most-specific monitored notifier (the equipment folder) — so overlapping bindings do not double-mirror the fan-out |
| B3 | Residual double-mirror risk | Latent, **not triggered** here: it needs two bindings whose `SourceReference` strings are literal prefixes of one another AND a delivery matching both (`DataConnectionActor` has no cross-binding, per-ConditionId dedup — `InstanceActor._latestAlarmEvents` is last-writer-wins by `alarmName`). Disjoint node refs (as here) don't collide |
## Captured real payload (what §5 phase 3 wanted)
`kind=NativeOpcUa`, `alarmName='SA-siteahigh.SiteA High'` (`SourceName`=`SA-siteahigh` = ScriptedAlarmId,
`.` + `ConditionName`=`SiteA High`), `alarmTypeName='AlarmConditionType'`, `severity=700`,
`condition.active=True`. A&C state is delivered via events/`ConditionRefresh` (ScadaBridge triggers a
refresh on every subscribe), not a plain attribute read.
## Bottom line for #14
- **A** — raw/UNS binding re-author: validated end-to-end (raw-path gate proved `nsu=` bind→read; this gate
proved UNS bind→alarm). Remaining work is data re-authoring (greenfield), **no code**.
- **B** — dedup: **PASS**, no double-mirror across the v3 fan-out (one subscription + `ConditionRefresh`).
- **C** — UNS-bound routing: **PASS**; the original concern was already solved by Gitea #17.
- **D/E/F** — shipped in phase 2 (`2026-07-23-otopcua-v3-nsu-hardening-and-browse-ux.md`).
**#14 needs no further ScadaBridge code for B/C.** The only shipped code from this whole effort was the
phase-2 `nsu=` hardening + the `RealOpcUaClient` host-rewrite fix (surfaced by the raw gate). #14 can close
once item A's re-author is done in the target environment(s).
## Rig artifacts (removable on next `docker/deploy.sh`)
OtOpcUa ConfigDb: `UnsArea SITEA-area1` / `UnsLine SITEA-line1` / `Equipment EQ-54f7711e160d` /
`UnsTagReference UTR-siteapump01-siteaonly` / `Script SC-siteahigh` / `ScriptedAlarm SA-siteahigh`.
ScadaBridge: template 2148 native-alarm-sources `ServerAlarms` + `UnsEquipAlarms`; instance 98; connection 3044.