cf03ca279d
- v3 design finalized: all 7 open items resolved (live historian probe:
255-char tagname limit; SDK multi-notifier spike: native AddNotifier,
never duplicate ReportEvent; NamespaceKind.Simulated retired; CSV column
dictionaries; ScadaBridge re-bind sized; rename-warning scan decided),
reconciled with the pre-v3 universal discovery browser (its new §11
v3 forward note re-targets the browse commit contract).
- Calculation pseudo-driver mini-design (IDependencyConsumer capability,
mux-fed change/timer triggers, Tarjan cycle deploy gate, VT-parity
error semantics).
- Implementation plans for Opus-agent execution, grounded against exact
files/symbols: master plan (batch DAG, worktree-isolated parallel
waves, contract-first fan-out, global gotcha list) + one plan per
batch (1 schema+RawPath identity, 2 /raw UI+Calculation, 3 UNS
reference-only+{{equip}}, 4 dual-namespace address space = v3.0),
each with per-wave file ownership and a mandatory docker-dev live gate.
171 lines
11 KiB
Markdown
171 lines
11 KiB
Markdown
# v3 Batch 4 — dual-namespace address space + raw-only runtime binding (= v3.0)
|
|
|
|
**Branch:** `v3/batch4-address-space` (from master after Batch 3 merges)
|
|
**Design authority:** v3 design §"OPC UA address space + runtime binding" (including the resolved multi-notifier SDK mechanism — the `AddNotifier` code block is normative), §"Error handling / edge cases".
|
|
**End state:** both namespaces live; every value has exactly one source (the raw node's publish path) fanned to both NodeIds; writes, history, alarms, and scripts all work through either namespace. This batch landing = **v3.0**.
|
|
|
|
**This is the batch where the forwarding trap lives.** Every sink-interface change MUST
|
|
forward through `DeferredAddressSpaceSink` and extend
|
|
`tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredSinkForwardingReflectionTests.cs`
|
|
— that is part of WP2's definition of done, not an afterthought.
|
|
|
|
## Namespace + NodeId scheme (contracts commit — coordinator lands first)
|
|
|
|
- Two namespace URIs (constants next to the current
|
|
`OtOpcUaNodeManager.DefaultNamespaceUri = "https://zb.com/otopcua/ns"`, which they
|
|
replace): **`https://zb.com/otopcua/raw`** and **`https://zb.com/otopcua/uns`**.
|
|
- `ns=Raw` NodeIds: `s=<RawPath>`. `ns=UNS` NodeIds:
|
|
`s=<Area>/<Line>/<Equipment>/<EffectiveName>`.
|
|
- New `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/V3NodeIds.cs` (raw + UNS path builders,
|
|
consuming `RawPaths`); `EquipmentNodeIds.cs` (`{EquipmentId}/{FolderPath}/{Name}`
|
|
logical-id scheme) is **retired** — delete it and sweep callers
|
|
(`EquipmentNodeWalker`, composer, tests).
|
|
- Sink discriminator (decided here, used by WP2/WP3): sink methods carry an
|
|
`AddressSpaceRealm` enum (`Raw` | `Uns`) rather than parsing namespace out of NodeId
|
|
strings — explicit beats inferred at every call site.
|
|
|
|
## Work packages
|
|
|
|
### Wave A — composition + node manager (2 agents)
|
|
|
|
**B4-WP1 — composer/planner emit both subtrees**
|
|
|
|
- `OpcUaServer/AddressSpaceComposer.cs` (+ `AddressSpaceComposition`,
|
|
`AddressSpaceChangeClassifier`): un-darken. Compose the Raw subtree
|
|
(Cluster→Folder→Driver→Device→TagGroup→Tag) from the schema — folders/drivers/devices/
|
|
groups as Object/Folder nodes, tags as Variables keyed `(realm=Raw, s=<RawPath>)` —
|
|
and the UNS subtree (Area/Line/Equipment folders; each `UnsTagReference` a Variable
|
|
keyed `(realm=Uns, s=<equipment path>/<effective name>)`) carrying its backing RawPath
|
|
+ an `Organizes` reference UNS→Raw. Native-alarm plans attach at the **raw** tag
|
|
(ConditionId = RawPath) and carry the list of referencing equipment paths.
|
|
- `AddressSpacePlan.cs` (`AddressSpacePlanner.Compute`) diffs per realm; a raw rename
|
|
manifests as remove+add in Raw **and** re-point in UNS (reference row unchanged, backing
|
|
NodeId changed) — pin this in planner tests.
|
|
- Reactivate the Batch-1-skipped harness assertion
|
|
(`EquipmentNamespaceMaterializationTests` skip-reason constant) with the new dual-tree
|
|
expectations.
|
|
|
|
**B4-WP2 — node manager dual-namespace + sink surface (owns the forwarding trap)**
|
|
|
|
- `OpcUaServer/OtOpcUaNodeManager.cs`: register both namespace URIs; node creation/
|
|
`EnsureVariable`/`CreateVariable` take the realm; `_historizedTagnames`,
|
|
`_notifierFolders`, and the write hook wiring become realm-aware.
|
|
- Sink interfaces `Commons/OpcUa/IOpcUaAddressSpaceSink.cs` +
|
|
`ISurgicalAddressSpaceSink.cs`: add the `AddressSpaceRealm` discriminator (or
|
|
realm-qualified node descriptors) to every method that names nodes; update
|
|
`SdkAddressSpaceSink`, **forward every change through `DeferredAddressSpaceSink`**, and
|
|
extend `DeferredSinkForwardingReflectionTests` so an unforwarded method fails CI.
|
|
- HistoryRead access bits: the UNS reference node registers the **same** historian
|
|
tagname in `_historizedTagnames` (both NodeIds → one tagname; the mux
|
|
`HistorizedTagRef` stays single, keyed by RawPath — `AddressSpaceApplier.FeedHistorizedRefs`
|
|
emits raw refs only, no doubles).
|
|
|
|
### Wave B — runtime binding + writes (1 agent; the single most delicate package)
|
|
|
|
**B4-WP3 — raw-only binding, UNS fan-out, write routing**
|
|
|
|
- `Runtime/Drivers/DriverHostActor.cs`: on apply, register the raw NodeId AND every
|
|
referencing UNS NodeId against the same `(DriverInstanceId, RawPath)` in
|
|
`_nodeIdByDriverRef` (the map is already 1:N — this is new entries, not new machinery);
|
|
`_driverRefByNodeId` gains the UNS NodeId → same ref inverse. Alarm maps
|
|
(`_alarmNodeIdByDriverRef`/`_driverRefByAlarmNodeId`) likewise carry the equipment
|
|
notifier NodeIds. `ForwardToMux` unchanged (mux keys stay RawPath-single).
|
|
- Write path: a write to a UNS NodeId resolves through `_driverRefByNodeId` to the same
|
|
driver ref — same `WriteOperate` gating, same `RouteNodeWrite` flow. `OnWriteValue`
|
|
stays fire-and-forget under the node-manager lock. **Write-outcome self-correction
|
|
(#5) must keep working through both NodeIds**: a failed device write reverts BOTH node
|
|
states (they share the stored value only via the fan-out — the revert is a publish, so
|
|
it fans automatically; add the regression test anyway).
|
|
- `AddressSpaceApplier.cs`: apply both realms; surgical updates (the F10b in-place path)
|
|
realm-aware; UNS display-name override changes are surgical (no rebuild), raw renames
|
|
follow WP1's remove+add plan.
|
|
- Tests: `DriverHostActorLiveValueTests`/`WriteRoutingTests`/`NativeAlarmTests` extended
|
|
with dual-NodeId cases; a fan-out drift test (publish once → both node states carry
|
|
identical value/quality/timestamp).
|
|
|
|
### Wave C — alarms + verification hardening (2 agents)
|
|
|
|
**B4-WP4 — multi-notifier alarms**
|
|
|
|
- Materialize each native alarm ONCE at the raw tag (parent = its device/group folder,
|
|
promoted via `EnsureFolderIsEventNotifier` — existing method, line ~1286). For each
|
|
referencing equipment's UNS folder, wire the design's normative pattern:
|
|
`alarm.AddNotifier(ctx, null, isInverse: true, equipFolder)` +
|
|
`equipFolder.AddNotifier(ctx, null, isInverse: false, alarm)` +
|
|
`EnsureFolderIsEventNotifier(equipFolder)`. One `ReportEvent` fans to all roots;
|
|
**never duplicate ReportEvent per root** (distinct EventIds break Server-object dedup
|
|
+ Part 9 ack correlation — design decision, not implementer discretion).
|
|
- **Teardown symmetry (implementation obligation):** track wired notifier pairs like
|
|
`_notifierFolders` and call `RemoveNotifier(..., bidirectional: true)` on
|
|
rebuild/subtree-removal/reference-removal — inverse-notifier entries otherwise leak
|
|
across redeploys.
|
|
- Part 9 ack/confirm/shelve keep routing on **ConditionId = RawPath** (never
|
|
SourceNodeId); the `AlarmAck` role gate and the `alarm-commands` DPS flow are
|
|
unchanged.
|
|
- `Commons/Messages/Alerts/AlarmTransitionEvent.cs` gains the (possibly empty) list of
|
|
referencing equipment paths; `/alerts` (`AlertHub` + `Alerts.razor`) shows one row per
|
|
condition (primary identity RawPath + condition NodeId) with the equipment list as
|
|
display metadata.
|
|
- `NativeAlarmProjector` + scripted-alarm surfaces untouched except NodeId scheme sweeps.
|
|
|
|
**B4-WP5 — integration tests + docs**
|
|
|
|
- 2-node harness: dual-namespace materialization, UNS-write-reaches-driver, alarm event
|
|
at both notifiers, HistoryRead via both NodeIds (NullHistorianDataSource `GoodNoData`
|
|
path suffices offline), redundancy: ServiceLevel + alarm-emit gate unaffected by the
|
|
second namespace (the redundancy-state delivery bugs were rig-only — verify on the
|
|
2-node rig, not just unit tests).
|
|
- Docs in the same PR: `CLAUDE.md` (address-space + tag-concept sections), `docs/Uns.md`,
|
|
`docs/ScriptEditor.md`, `docs/Historian.md`, `docs/ScriptedAlarms.md`,
|
|
`docs/AlarmTracking.md`; the ScadaBridge cutover note + umbrella-index update
|
|
(`../scadaproj/CLAUDE.md`) per the design's cross-repo plan.
|
|
|
|
## Wave schedule
|
|
|
|
| Wave | Packages | Agents | Notes |
|
|
|---|---|---|---|
|
|
| — | Contracts commit (URIs, `V3NodeIds`, `AddressSpaceRealm`) | coordinator | Before any fan-out |
|
|
| A | WP1 ∥ WP2 | 2 | Composer/planner vs node-manager/sinks — disjoint files; both build against the contracts commit |
|
|
| B | WP3 | 1 | Runtime binding integrates A's output — single agent |
|
|
| C | WP4 ∥ WP5 | 2 | Alarms vs tests/docs — WP5 rebases last |
|
|
|
|
## Batch verification gate — the v3.0 live gate (docker-dev + Client.CLI)
|
|
|
|
Rebuild **both** centrals; fixtures up (`modbus standard` at minimum); deploy a config
|
|
with: a Modbus raw tag (historized), a Calculation tag reading it, an equipment
|
|
referencing both (one with a display-name override), a native alarm on the Modbus tag,
|
|
and a VirtualTag using `{{equip}}/<RefName>`.
|
|
|
|
1. **Browse both namespaces** (Client.CLI `browse -r -d 6`): Raw shows
|
|
Folder→Driver→Device→Group→Tag with `s=<RawPath>` NodeIds; UNS shows
|
|
Area/Line/Equipment with effective names; the UNS variable `Organizes`-references its
|
|
raw node.
|
|
2. **Single-source fan-out:** `subscribe` to the raw NodeId and the UNS NodeId
|
|
simultaneously → identical values/timestamps as the fixture changes; the calc tag
|
|
computes; break its script → Bad + script-log row; fix → recovery.
|
|
3. **Writes:** `write` via the UNS NodeId → device changes (read back via raw NodeId);
|
|
repeat via raw NodeId. Role gating: an LDAP user without `WriteOperate` is rejected
|
|
on both. Failed-write revert: enable the modbus `exception_injector` FC06 rule →
|
|
write via UNS NodeId → both NodeIds revert to prior value (no optimistic-Good
|
|
phantom). Remember Client.CLI `write` reads first — target a Good node.
|
|
4. **History:** `historyread` against the raw NodeId and the UNS NodeId returns the same
|
|
series under the same historian tagname (live gateway if VPN available, else
|
|
`GoodNoData` parity from the Null source); provisioning tally logs
|
|
`dispatched=N, failed=0`.
|
|
5. **Alarms:** trip the native alarm → `/alerts` shows ONE row carrying the equipment
|
|
path list; Client.CLI `alarms` subscribed at (a) the Server object — exactly **one**
|
|
event per transition (the shared-snapshot dedup check), (b) the raw device folder,
|
|
(c) the equipment folder — each receives the event; `ack` via the equipment-side
|
|
subscription works (ConditionId routing). Redeploy (rebuild path) then re-trip →
|
|
still exactly one Server-object copy (teardown-symmetry check: no leaked notifier
|
|
duplicates).
|
|
6. **Rename cascade:** rename the raw tag in `/raw` (accept the warnings) + deploy →
|
|
old raw NodeId gone, new present; UNS reference follows automatically; the absolute
|
|
RawPath script literal now fails as documented (`BadNodeIdUnknown` at evaluation);
|
|
the `{{equip}}` VirtualTag keeps working (reference-relative).
|
|
7. **Redundancy sanity** on the 2-node rig: ServiceLevel 240/100 split intact; alarm
|
|
rows single (no double-emit from the second namespace).
|
|
|
|
All seven legs recorded with evidence in the PR. Merge = tag/announce **v3.0**; then the
|
|
cross-repo follow-ups (ScadaBridge re-bind coordination, umbrella index) per WP5.
|