docs(live-values): design — equipment-tag live-value delivery (FullName→NodeId router)

Mirror VirtualTagHostActor's _nodeIdByVtag pattern for driver values: a shared
EquipmentNodeIds helper (kills the duplicated formula), DriverInstanceId on
AttributeValuePublished, and a (DriverInstanceId,FullName)->NodeId[] map built +
resolved in DriverHostActor.ForwardToMux. No OpcUaPublishActor change.
Driver-value delivery only; native alarms + historian remain separate.
This commit is contained in:
Joseph Doherty
2026-06-13 06:21:40 -04:00
parent c3c5617266
commit 7e9eb5d17a
@@ -0,0 +1,81 @@
# Equipment-tag live-value delivery — Design
**Date:** 2026-06-13
**Status:** Approved — ready for implementation planning
**Scope:** Deliver a **driver's published value to its materialised equipment-tag OPC UA variable.** This is the load-bearing milestone deferred from the Galaxy-standard-driver work (`docs/plans/2026-06-12-galaxy-standard-driver-design.md`).
## Goal
Make a driver-published value land on the correct equipment-tag variable so it stops showing `BadWaitingForInitialData`. This benefits **every** equipment-tag driver (Modbus/S7/AbCip/…/Galaxy), not just Galaxy.
## The gap (root cause, confirmed in code)
The live-value publish chain is:
```
driver OnDataChange
→ DriverInstanceActor.AttributeValuePublished(FullReference, Value, Quality, Ts) [DriverInstanceActor.cs:65,449]
→ DriverHostActor.ForwardToMux [DriverHostActor.cs:379]
• _dependencyMux?.Tell(msg) (VirtualTag inputs — correct, keep)
• _opcUaPublishActor?.Tell(AttributeValueUpdate(NodeId = msg.FullReference, …)) [DriverHostActor.cs:391]
→ OpcUaPublishActor.HandleAttributeUpdate → _sink.WriteValue(msg.NodeId, …) [OpcUaPublishActor.cs:159,163]
→ OtOpcUaNodeManager.WriteValue → _variables.TryGetValue(nodeId) [OtOpcUaNodeManager.cs:89]
```
But equipment variables are materialised with **folder-scoped NodeIds** (`{parent}/{Name}`, `parent = IsNullOrWhiteSpace(FolderPath) ? EquipmentId : "{EquipmentId}/{FolderPath}"`) — *not* the `FullName` — because a driver wire-ref (e.g. Modbus register 40001) is **not unique across identical machines** (`Phase7Applier.MaterialiseEquipmentTags` :180-192). So `WriteValue(FullReference)` misses `_variables` and (worse) `OtOpcUaNodeManager.WriteValue` **lazily creates an orphan variable** keyed by the raw FullReference (`OtOpcUaNodeManager.cs:97-101`). The value never reaches the real, browseable variable.
`AttributeValuePublished` / `AttributeValueUpdate` also don't carry `DriverInstanceId`, which a `FullName→NodeId` router needs (FullName is unique only *within* a driver instance).
## Chosen approach — mirror the proven VirtualTag pattern
`VirtualTagHostActor` **already solves the identical problem** for VirtualTags: it rebuilds a `vtagId → folder-scoped NodeId` map (`_nodeIdByVtag`) every apply via `NodeIdFor(plan)` and resolves it **before** publishing `AttributeValueUpdate` (`VirtualTagHostActor.cs:42,85-88,121-131,149-153`). VirtualTag values therefore already land correctly. We mirror this for driver equipment-tag values.
### Components
1. **Shared NodeId helper (kills duplication + the drift risk this milestone depends on).**
The `{parent}/{Name}` formula is currently copied in `Phase7Applier.EquipmentSubFolderNodeId`/inline and `VirtualTagHostActor.NodeIdFor`, each with a "MUST match" doc-comment. Extract one helper — e.g. `ZB.MOM.WW.OtOpcUa.Commons.OpcUa.EquipmentNodeIds.Variable(equipmentId, folderPath, name)` + `SubFolder(equipmentId, folderPath)` (Commons is referenced by both OpcUaServer and Runtime). Repoint `Phase7Applier` + `VirtualTagHostActor` to it (byte-identical output). The new driver map uses the **same** helper, guaranteeing the router's NodeId equals the materialised NodeId.
2. **Carry `DriverInstanceId` on the publish message.**
Add `DriverInstanceId` to `DriverInstanceActor.AttributeValuePublished` (the actor already holds `_driverInstanceId`, :77/169) and populate it at the raise site (:449).
3. **Driver `(DriverInstanceId, FullName) → NodeId[]` map + resolution in `DriverHostActor`.**
- Build `_nodeIdByDriverRef` from `composition.EquipmentTags` (each carries `DriverInstanceId`, `FullName`, `EquipmentId`, `FolderPath`, `Name`) using the shared helper. Rebuild it every apply — the natural place is `PushDesiredSubscriptions` (which already iterates `EquipmentTags` grouping by driver, runs after `RebuildAddressSpace` in the apply pass). Mirror `_nodeIdByVtag`'s clear-and-repopulate.
- In `ForwardToMux`: keep `_dependencyMux?.Tell(msg)` unchanged; then resolve `(msg.DriverInstanceId, msg.FullReference) → NodeId[]` and `Tell` an `AttributeValueUpdate(nodeId, …)` **per** resolved NodeId. On no-match, skip the OPC UA push (debug-log) — **do not** create orphan variables.
- Value is `List<NodeId>` to handle the 1→many case (two tags on one driver sharing a FullName).
No change to `OpcUaPublishActor` (the existing `AttributeValueUpdate` is reused exactly as VirtualTags use it).
### Data flow (after)
```
driver value (FullReference) → AttributeValuePublished(DriverInstanceId, FullReference, …)
→ ForwardToMux:
_dependencyMux.Tell(msg) (unchanged)
_nodeIdByDriverRef[(DriverInstanceId, FullReference)] → [folder-scoped NodeId, …]
→ for each: _opcUaPublishActor.Tell(AttributeValueUpdate(nodeId, value, quality, ts))
→ WriteValue(folder-scoped nodeId) → hits the real variable → value displays
```
## Error handling / edge cases
- **No-match** (a published ref not in the map): skip the OPC UA push, debug-log; never lazily create an orphan variable. (Optionally tighten `OtOpcUaNodeManager.WriteValue` to not auto-create on miss — but leave that as-is to avoid touching the alarm/vtag callers; the router simply doesn't call it for unmatched refs.)
- **1→many**: write to every NodeId the `(driver, FullName)` maps to.
- **Rebuild lifecycle**: `_nodeIdByDriverRef` is cleared + repopulated every apply (matches the address-space rebuild that clears `_variables`).
- **Multi-node / redundancy**: the path runs on every node (each materialises its own variables + builds its own map). No Primary-gating — values flow on all nodes, same as today's vtag path.
- **FullName uniqueness**: globally non-unique across identical machines, but unique within a driver instance → keying on `(DriverInstanceId, FullName)` is correct. (Galaxy `tag_name`s are globally unique anyway.)
## Testing (no bUnit)
- **Unit (xUnit + Shouldly):** the shared `EquipmentNodeIds` helper (formula incl. null/empty FolderPath); `Phase7Applier` + `VirtualTagHostActor` produce byte-identical NodeIds after repointing (golden/parity assertion).
- **DriverHostActor resolution (Akka.TestKit):** an apply with a composition containing an equipment tag → a subsequent `AttributeValuePublished(driverId, FullName, …)` results in an `AttributeValueUpdate(folder-scoped NodeId, …)` reaching the publish actor (probe); two equipments sharing one `(driver, FullName)` → both NodeIds written; an unmatched ref → no OPC UA push (still forwarded to the mux). Extract the resolution into a testable method if it eases TestKit assertions.
- **Live docker-dev `/run` (agent-driven; dev login disabled):** author a Modbus (live sim on `10.100.0.35:5020`) equipment tag → confirm its variable shows a **live value** over OPC UA (Client.CLI `read`/`subscribe`), not `BadWaitingForInitialData`. (Galaxy needs a real mxaccessgw to show data, so Modbus is the cleaner live proof; the routing is driver-agnostic.)
## Out of scope (separate milestones)
- **Phase B** — native `IAlarmSource` alarms on the equipment-tag path.
- **Phase C** — server-side `HistoryRead` backend.
- Tightening `OtOpcUaNodeManager.WriteValue`'s lazy-create-on-miss (latent smell; not required here).
## Hard rules
Stage by path; never `git add .`; never stage `sql_login.txt` / `src/Server/.../Host/pki/` / `pending.md` / `current.md`. Never echo the gateway API key / historian SharedSecret into a tracked file. No force-push, no `--no-verify`. No Configuration entity/EF migration change (this is runtime-only). Build on a feature branch off master.