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.
7.6 KiB
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
-
Shared NodeId helper (kills duplication + the drift risk this milestone depends on). The
{parent}/{Name}formula is currently copied inPhase7Applier.EquipmentSubFolderNodeId/inline andVirtualTagHostActor.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). RepointPhase7Applier+VirtualTagHostActorto it (byte-identical output). The new driver map uses the same helper, guaranteeing the router's NodeId equals the materialised NodeId. -
Carry
DriverInstanceIdon the publish message. AddDriverInstanceIdtoDriverInstanceActor.AttributeValuePublished(the actor already holds_driverInstanceId, :77/169) and populate it at the raise site (:449). -
Driver
(DriverInstanceId, FullName) → NodeId[]map + resolution inDriverHostActor.- Build
_nodeIdByDriverReffromcomposition.EquipmentTags(each carriesDriverInstanceId,FullName,EquipmentId,FolderPath,Name) using the shared helper. Rebuild it every apply — the natural place isPushDesiredSubscriptions(which already iteratesEquipmentTagsgrouping by driver, runs afterRebuildAddressSpacein the apply pass). Mirror_nodeIdByVtag's clear-and-repopulate. - In
ForwardToMux: keep_dependencyMux?.Tell(msg)unchanged; then resolve(msg.DriverInstanceId, msg.FullReference) → NodeId[]andTellanAttributeValueUpdate(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).
- Build
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.WriteValueto 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:
_nodeIdByDriverRefis 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. (Galaxytag_names are globally unique anyway.)
Testing (no bUnit)
- Unit (xUnit + Shouldly): the shared
EquipmentNodeIdshelper (formula incl. null/empty FolderPath);Phase7Applier+VirtualTagHostActorproduce 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 anAttributeValueUpdate(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 on10.100.0.35:5020) equipment tag → confirm its variable shows a live value over OPC UA (Client.CLIread/subscribe), notBadWaitingForInitialData. (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
IAlarmSourcealarms on the equipment-tag path. - Phase C — server-side
HistoryReadbackend. - 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.