Files
lmxopcua/docs/plans/2026-06-13-equipment-tag-live-values-plan.md
T
Joseph Doherty 891f875f6a docs(live-values): Phase plan — equipment-tag live-value delivery (6 tasks)
Shared EquipmentNodeIds helper → repoint Phase7Applier+VirtualTagHostActor →
DriverInstanceId on AttributeValuePublished → DriverHostActor (DriverInstanceId,
FullName)->NodeId[] map + ForwardToMux resolution + TestKit → live Modbus /run.
Runtime-only, no migration. Co-located .tasks.json.
2026-06-13 06:24:38 -04:00

17 KiB

Equipment-tag Live-Value Delivery — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.

Goal: Route a driver's published value (keyed by FullName) to its materialised equipment-tag OPC UA variable (keyed by a folder-scoped NodeId), so equipment tags stop showing BadWaitingForInitialData.

Architecture: Mirror the proven VirtualTagHostActor._nodeIdByVtag pattern for driver values. Extract one shared EquipmentNodeIds NodeId-formula helper (kills the duplicated formula the router depends on), carry DriverInstanceId on the publish message, and have DriverHostActor build a (DriverInstanceId, FullName) → NodeId[] map each apply and resolve it in ForwardToMux before sending the existing AttributeValueUpdate. No OpcUaPublishActor change. Runtime-only — no EF/Configuration change.

Tech Stack: .NET 10, Akka.NET (+ Akka.TestKit), OPC UA Foundation stack, xUnit + Shouldly. Design: docs/plans/2026-06-13-equipment-tag-live-values-design.md (master 7e9eb5d1).

Branch: feat/equipment-tag-live-values off master 7e9eb5d1.


Hard rules (every task)

  • Stage by path; never git add .. Never stage sql_login.txt, src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/, pending.md, or 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.

Dependency spine

T0 → {T1, T3} (disjoint files, parallel). T2 needs T1. T4 needs T1 + T3. T5 (verify) needs all.

Task Files Class Parallelizable with
T0 branch git trivial
T1 EquipmentNodeIds helper Commons (new) + Commons.Tests small T3
T2 repoint formula + parity test Phase7Applier, VirtualTagHostActor, OpcUaServer.Tests standard T3
T3 AttributeValuePublished + DriverInstanceId DriverInstanceActor small T1, T2
T4 DriverHostActor map + resolve + TestKit DriverHostActor, Runtime.Tests high-risk
T5 verify + live /run none verification

Task 0: Create feature branch

Classification: trivial Estimated implement time: ~1 min Parallelizable with: none

Files: git only

git checkout master && git rev-parse HEAD   # expect 7e9eb5d1...
git checkout -b feat/equipment-tag-live-values

Confirm clean tree (ignore untracked pending.md / current.md / sql_login.txt / pki/).


Task 1: Shared EquipmentNodeIds NodeId-formula helper

Classification: small Estimated implement time: ~4 min Parallelizable with: Task 3

Files:

  • Create: src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/EquipmentNodeIds.cs
  • Test: tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/EquipmentNodeIdsTests.cs

Context: The folder-scoped NodeId formula is currently copied in Phase7Applier and VirtualTagHostActor (each with a "MUST match" warning). This is the single source of truth both — and the new driver router — will use, guaranteeing the router's NodeId equals the materialised NodeId. Confirm the test project name first: ls tests/Core | grep -i Commons (it's ZB.MOM.WW.OtOpcUa.Commons.Tests).

Step 1: Write the failing tests

using Shouldly;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using Xunit;

namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.OpcUa;

public class EquipmentNodeIdsTests
{
    [Fact]
    public void Variable_with_no_folder_is_equipment_slash_name()
        => EquipmentNodeIds.Variable("eq-1", "", "speed").ShouldBe("eq-1/speed");

    [Fact]
    public void Variable_with_null_folder_is_equipment_slash_name()
        => EquipmentNodeIds.Variable("eq-1", null, "speed").ShouldBe("eq-1/speed");

    [Fact]
    public void Variable_with_folder_is_equipment_slash_folder_slash_name()
        => EquipmentNodeIds.Variable("eq-1", "registers", "speed").ShouldBe("eq-1/registers/speed");

    [Fact]
    public void SubFolder_is_equipment_slash_folder()
        => EquipmentNodeIds.SubFolder("eq-1", "registers").ShouldBe("eq-1/registers");
}

Step 2: Run — expect FAIL (EquipmentNodeIds not defined). dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests --filter "FullyQualifiedName~EquipmentNodeIds"

Step 3: Implement

namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;

/// <summary>
///     Single source of truth for equipment-namespace OPC UA NodeId strings. The variable NodeId is
///     FOLDER-SCOPED (<c>{parent}/{Name}</c>), NOT the driver-side FullName — a driver wire ref is not
///     unique across identical machines, so FullName-as-NodeId would collide in the sink. Used by the
///     materialiser (Phase7Applier), the VirtualTag publish map, and the driver live-value router so all
///     three agree on the exact NodeId a variable was placed at.
/// </summary>
public static class EquipmentNodeIds
{
    /// <summary>The sub-folder NodeId under an equipment for a non-empty FolderPath: <c>{equipmentId}/{folderPath}</c>.</summary>
    /// <param name="equipmentId">The owning equipment's NodeId.</param>
    /// <param name="folderPath">The tag/vtag FolderPath (must be non-empty for this to be meaningful).</param>
    /// <returns>The sub-folder NodeId string.</returns>
    public static string SubFolder(string equipmentId, string folderPath) => $"{equipmentId}/{folderPath}";

    /// <summary>
    ///     The folder-scoped variable NodeId: <c>{parent}/{name}</c> where <c>parent = equipmentId</c> when
    ///     <paramref name="folderPath"/> is null/empty, else <see cref="SubFolder"/>.
    /// </summary>
    /// <param name="equipmentId">The owning equipment's NodeId.</param>
    /// <param name="folderPath">The tag/vtag FolderPath, or null/empty for "directly under the equipment".</param>
    /// <param name="name">The tag/vtag Name (the leaf browse segment).</param>
    /// <returns>The folder-scoped variable NodeId string.</returns>
    public static string Variable(string equipmentId, string? folderPath, string name)
    {
        var parent = string.IsNullOrWhiteSpace(folderPath) ? equipmentId : SubFolder(equipmentId, folderPath);
        return $"{parent}/{name}";
    }
}

Step 4: Run — expect PASS.

Step 5: Commit

git add src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/EquipmentNodeIds.cs tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/EquipmentNodeIdsTests.cs
git commit -m "feat(commons): EquipmentNodeIds — single source of truth for folder-scoped equipment NodeIds"

Task 2: Repoint Phase7Applier + VirtualTagHostActor to EquipmentNodeIds (+ parity test)

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 3 blockedBy: Task 1

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs (EquipmentSubFolderNodeId :280; the inline {parent}/{Name} in MaterialiseEquipmentTags :189-191 and MaterialiseEquipmentVirtualTags :236-237)
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs (NodeIdFor :149-153 + the using)
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ (a small parity test asserting both produce EquipmentNodeIds.Variable(...))

Context: Replace the two copied formulas with the shared helper — byte-identical output. Phase7Applier is in OpcUaServer, VirtualTagHostActor in Runtime; both reference Commons. Read each before editing.

Step 1 (test first): Add a parity/golden test (OpcUaServer.Tests) that pins the formula and proves both call sites match the helper — e.g. for an equipment tag with FolderPath=null the materialised NodeId equals EquipmentNodeIds.Variable(eqId, null, name), and with a FolderPath it equals EquipmentNodeIds.Variable(eqId, folder, name). (If an existing materialiser test already asserts concrete NodeIds like eq-1/speed, extend it; otherwise add one that drives MaterialiseEquipmentTags against a recording sink and asserts the variable NodeIds.) Run — should pass before AND after the repoint (the change is behaviour-preserving); its job is to lock the formula against future drift.

Step 2: In Phase7Applier: replace EquipmentSubFolderNodeId(eq, fp) body (or its call sites) with EquipmentNodeIds.SubFolder(eq, fp), and replace the inline var nodeId = $"{parent}/{tag.Name}"; (tags :191 and vtags :237) with var nodeId = EquipmentNodeIds.Variable(tag.EquipmentId, tag.FolderPath, tag.Name); (and the vtag equivalent v.EquipmentId, v.FolderPath, v.Name). You may delete the private EquipmentSubFolderNodeId if all call sites move to the helper, or keep it as a thin forwarder — implementer's choice, but no behaviour change. Add using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;.

Step 3: In VirtualTagHostActor.NodeIdFor, return EquipmentNodeIds.Variable(p.EquipmentId, p.FolderPath, p.Name); add the using. Keep the "MUST match" doc-comment but update it to say the formula now lives in EquipmentNodeIds.

Step 4: Build + test:

dotnet build src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer
dotnet build src/Server/ZB.MOM.WW.OtOpcUa.Runtime
dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~Phase7Applier"

Expect 0 errors; existing applier + vtag tests still green (byte-identical NodeIds).

Step 5: Commit (both files + the test).


Task 3: Carry DriverInstanceId on AttributeValuePublished

Classification: small Estimated implement time: ~3 min Parallelizable with: Task 1, Task 2

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs (record :65; raise site :449)

Context: The router needs to key on (DriverInstanceId, FullName). The actor already holds _driverInstanceId (:77, set :169). Add it as the first parameter so the message self-identifies its source driver.

Step 1: Change the record (:65):

public sealed record AttributeValuePublished(string DriverInstanceId, string FullReference, object? Value, OpcUaQuality Quality, DateTime TimestampUtc);

Step 2: Update the raise site (:449):

Context.Parent.Tell(new AttributeValuePublished(_driverInstanceId, msg.FullReference, msg.Snapshot.Value, quality, ts));

Step 3: Fix every other construction/consumer of AttributeValuePublished so it compiles — grep first: grep -rn "AttributeValuePublished" src tests --include=*.cs | grep -v /obj/ | grep -v /bin/ The known consumer is DriverHostActor.ForwardToMux (Task 4 handles the resolution; here just make it compile — it can pass msg through to _dependencyMux unchanged). Update any test that constructs AttributeValuePublished to pass a DriverInstanceId.

Step 4: Build Runtime: dotnet build src/Server/ZB.MOM.WW.OtOpcUa.Runtime → 0 errors. Run any DriverInstanceActor tests.

Step 5: Commit.


Task 4: DriverHostActor — driver-ref→NodeId map + ForwardToMux resolution

Classification: high-risk Estimated implement time: ~5 min Parallelizable with: none blockedBy: Task 1, Task 3

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs (add _nodeIdByDriverRef field; build it in PushDesiredSubscriptions :558-607; resolve in ForwardToMux :379-392; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;)
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/ (new Akka.TestKit test — find the existing DriverHostActor test file pattern first)

Context: Mirror VirtualTagHostActor. Build the map every apply from composition.EquipmentTags (each carries DriverInstanceId, FullName, EquipmentId, FolderPath, Name) using EquipmentNodeIds.Variable. Value is a List<string> for the 1→many case. Resolve in ForwardToMux and emit one AttributeValueUpdate per NodeId; on no-match, skip the OPC UA push (still forward to _dependencyMux) and debug-log.

Step 1: Write the failing tests (Akka.TestKit). Read an existing DriverHostActor test to copy the harness (how it constructs the actor with opcUaPublishActor + dependencyMux probes and triggers an apply, or how PushDesiredSubscriptions is exercised). Then:

  • Published_driver_value_routes_to_folder_scoped_nodeid: after an apply whose composition has equipment tag {EquipmentId="eq-1", DriverInstanceId="drv-1", FullName="40001", FolderPath=null, Name="speed"}, send the host a DriverInstanceActor.AttributeValuePublished("drv-1", "40001", 42.0, OpcUaQuality.Good, ts); the opcUaPublish probe receives AttributeValueUpdate("eq-1/speed", 42.0, …).
  • Same_ref_on_two_equipments_writes_both: two equipment tags (drv-1, "40001") on eq-1 and eq-2; one publish → probe receives BOTH eq-1/speed and eq-2/speed.
  • Unmatched_ref_no_opcua_push_but_still_to_mux: publish (drv-1, "59999") (not in composition) → opcUaPublish probe gets nothing (ExpectNoMsg), but the dependency-mux probe still receives the raw AttributeValuePublished.

Run — expect FAIL.

Step 2: Implement.

  • Field: private readonly Dictionary<(string DriverInstanceId, string FullName), List<string>> _nodeIdByDriverRef = new();
  • In PushDesiredSubscriptions, right where it has the composition (alongside building refsByDriver), rebuild the map:
_nodeIdByDriverRef.Clear();
foreach (var t in composition.EquipmentTags)
{
    var key = (t.DriverInstanceId, t.FullName);
    var nodeId = EquipmentNodeIds.Variable(t.EquipmentId, t.FolderPath, t.Name);
    if (!_nodeIdByDriverRef.TryGetValue(key, out var list))
        _nodeIdByDriverRef[key] = list = new List<string>();
    if (!list.Contains(nodeId)) list.Add(nodeId);
}
  • In ForwardToMux:
private void ForwardToMux(DriverInstanceActor.AttributeValuePublished msg)
{
    _dependencyMux?.Tell(msg);   // VirtualTag inputs — keyed by FullReference, unchanged

    if (_opcUaPublishActor is null) return;
    if (_nodeIdByDriverRef.TryGetValue((msg.DriverInstanceId, msg.FullReference), out var nodeIds))
    {
        foreach (var nodeId in nodeIds)
            _opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AttributeValueUpdate(
                nodeId, msg.Value, msg.Quality, msg.TimestampUtc));
    }
    else
    {
        _log.Debug("DriverHost {Node}: no equipment-tag NodeId for ({Driver},{Ref}) — value dropped",
            _localNode, msg.DriverInstanceId, msg.FullReference);
    }
}

Update the stale ForwardToMux comment block (the "live values milestone… until then BadWaitingForInitialData" note) to describe the now-wired routing. Add the using.

Step 3: Run the new tests — expect PASS.

Step 4: Build + full Runtime suite:

dotnet build src/Server/ZB.MOM.WW.OtOpcUa.Runtime
dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~DriverHost"

Step 5: Commit (DriverHostActor + the TestKit test).


Task 5: Verify — build, test, agent-driven live /run

Classification: verification Estimated implement time: ~5 min + live Parallelizable with: none blockedBy: all

Files: none

Step 1: Full solution build + the touched suites:

dotnet build ZB.MOM.WW.OtOpcUa.slnx
dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests
dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests
dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests

Expect 0 errors; all green.

Step 2: Live docker-dev /run (agent drives; dev login DISABLED — no sign-in). The routing is driver-agnostic; Modbus is the cleanest live proof (live sim at 10.100.0.35:5020; Galaxy would need a real mxaccessgw).

  • Rebuild central nodes on this branch: docker compose -f docker-dev/docker-compose.yml up -d --build migrator central-1 central-2 (no new migration here, but the rebuild ships the branch code).
  • In the AdminUI (http://localhost:9200): create a ModbusTcp driver under the nw-uns Equipment namespace (endpoint 10.100.0.35:5020), author an equipment tag on an equipment bound to it (a valid Modbus FullName/register), and deploy (POST http://localhost:9200/api/deployments, header X-Api-Key: docker-dev-deploy-key, or the UI deploy).
  • Confirm the tag's OPC UA variable shows a live value (not BadWaitingForInitialData): dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n "ns=2;s=<equipmentId>/<name>" (or subscribe). Browse first (-r -d 4) to find the NodeId.
  • Sanity: the central logs show no WriteValue threw / no-match storms.

Step 3: On green, finish via superpowers-extended-cc:finishing-a-development-branch (intent: merge-to-master + push, confirm).


Out of scope

Native IAlarmSource alarms on the equipment path (Phase B) and the server-side HistoryRead backend (Phase C) — separate milestones in the design doc.