diff --git a/docs/plans/2026-07-23-otopcua-v3-alarm-bc-live-gate.md b/docs/plans/2026-07-23-otopcua-v3-alarm-bc-live-gate.md new file mode 100644 index 00000000..529181c7 --- /dev/null +++ b/docs/plans/2026-07-23-otopcua-v3-alarm-bc-live-gate.md @@ -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.