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.
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 stagesql_login.txt,src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/,pending.md, orcurrent.md. - Never echo the gateway API key / historian
SharedSecretinto 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}inMaterialiseEquipmentTags:189-191 andMaterialiseEquipmentVirtualTags:236-237) - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs(NodeIdFor:149-153 + theusing) - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/(a small parity test asserting both produceEquipmentNodeIds.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_nodeIdByDriverReffield; build it inPushDesiredSubscriptions:558-607; resolve inForwardToMux: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 aDriverInstanceActor.AttributeValuePublished("drv-1", "40001", 42.0, OpcUaQuality.Good, ts); the opcUaPublish probe receivesAttributeValueUpdate("eq-1/speed", 42.0, …).Same_ref_on_two_equipments_writes_both: two equipment tags(drv-1, "40001")oneq-1andeq-2; one publish → probe receives BOTHeq-1/speedandeq-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 rawAttributeValuePublished.
Run — expect FAIL.
Step 2: Implement.
- Field:
private readonly Dictionary<(string DriverInstanceId, string FullName), List<string>> _nodeIdByDriverRef = new(); - In
PushDesiredSubscriptions, right where it has thecomposition(alongside buildingrefsByDriver), 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 aModbusTcpdriver under thenw-unsEquipment namespace (endpoint10.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, headerX-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>"(orsubscribe). 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.