Files
lmxopcua/docs/plans/2026-06-13-equipment-tag-live-values-design.md
T
Joseph Doherty 7e9eb5d17a 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.
2026-06-13 06:21:40 -04:00

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

  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_names 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.