44 KiB
FixedTree → Equipment dynamic-injection Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
Goal: After an ITagDiscovery driver connects, dynamically graft its discovered FixedTree nodes into the served Equipment/UNS OPC UA address space under a driver-named subfolder (EQ-…/FOCAS/…), carrying live values — reusing the existing materialize → subscribe → poll → push pipeline.
Architecture: Treat discovered nodes as "synthetic equipment tags" injected at runtime. A capturing IAddressSpaceBuilder records each driver's DiscoverAsync output (zero driver changes); DriverInstanceActor runs discovery post-connect (bounded retry, since FOCAS's FixedTreeCache populates ~0–2 s after connect) and ships a DiscoveredNodesReady message; DriverHostActor maps the nodes under the equipment, extends _nodeIdByDriverRef + the desired-subscription set, and tells OpcUaPublishActor to incrementally materialize them (idempotent EnsureFolder/EnsureVariable, no full teardown), emitting a GeneralModelChangeEvent. Survives redeploys (re-applied after PushDesiredSubscriptions) and restarts (re-discovered on reconnect).
Tech Stack: .NET 10, Akka.NET (Akka.Hosting, Akka.TestKit.Xunit2), OPC UA (OPCFoundation.NetStandard.Opc.Ua), xUnit v2 + Shouldly.
Design doc: 2026-06-26-otopcua-fixedtree-equipment-injection-design.md. Base branch: fix/focas-poll-io-serialization (this builds on the deployed driver-host bootstrap re-spawn + FOCAS I/O fixes; not yet merged to master).
Key code anchors (verified 2026-06-26):
IAddressSpaceBuilder/IVariableHandle/DriverAttributeInfo—src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/.- Reference capturing builder (flat collector):
src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/BrowseCommand.cs:120(CollectingAddressSpaceBuilder). - NodeId scheme:
src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/EquipmentNodeIds.cs(Variable(equipmentId, folderPath, name)→{parent}/{name};SubFolder→{equipmentId}/{folderPath}). - Materialize pattern:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs:248(MaterialiseEquipmentTags) +SafeEnsureFolder/SafeEnsureVariable. - Node manager:
OtOpcUaNodeManager.EnsureFolder(:1282),EnsureVariable(:1367, seedsBadWaitingForInitialData),BuildNodeShapeChangedEvent(:1525, verbDataTypeChanged— model for aNodeAddedsibling). - Publish actor receive + materialize calls:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs:217(Receive block),HandleRebuild(:275). - Driver value route:
DriverHostActor.ForwardToMux(:525),_nodeIdByDriverRefbuilt inPushDesiredSubscriptions(:1019, sendsSetDesiredSubscriptions:1052),ChildEntry(:203), Receive blocks (:482,:512). - Driver connect hook:
DriverInstanceActor_driverfield (:110),Connected()(:317), transition atInitializeSucceeded(:278);SetDesiredSubscriptionslive re-subscribe path (:340-353). - FOCAS discovery (reused verbatim):
FocasDriver.DiscoverAsync(:408) emitsFOCAS/{deviceHost}/<section>/…; FixedTree leafFullName={deviceHost}/{path}; suppresses FixedTree untilFixedTreeCacheset.
Conventions for every task
- TDD: write the failing test first, run it (confirm the expected failure), implement minimally, run again (green), commit.
- Build:
dotnet build ZB.MOM.WW.OtOpcUa.slnxfrom the repo root. - Run a single test class:
dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~<ClassName>". - Commits: Conventional Commits, on
fix/focas-poll-io-serialization(do NOT touch the pre-existing unrelated working-tree edits:CLAUDE.md,docker-dev/docker-compose.yml,pending.md,stillpending.md,docs/plans/2026-06-19-followups-batch.md.tasks.json—git addonly this feature's files). - No new dependencies, no proto change, no EF migration. All edits are within existing projects.
Task 1: DiscoveredNode DTO + path-tracking CapturingAddressSpaceBuilder
Classification: standard Estimated implement time: ~4 min Parallelizable with: Task 3
Files:
- Create:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNode.cs - Create:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/CapturingAddressSpaceBuilder.cs - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/CapturingAddressSpaceBuilderTests.cs
Unlike the CLI's flat CollectingAddressSpaceBuilder, this one tracks folder nesting so each variable records its full path segments (e.g. ["FOCAS","10.201.31.5:8193","Identity"] + browse SeriesNumber).
Step 1: Write the failing test
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
[Trait("Category", "Unit")]
public sealed class CapturingAddressSpaceBuilderTests
{
[Fact]
public void Records_nested_path_segments_full_reference_and_metadata()
{
var b = new CapturingAddressSpaceBuilder();
var focas = b.Folder("FOCAS", "FOCAS");
var device = focas.Folder("10.0.0.5:8193", "cnc");
var identity = device.Folder("Identity", "Identity");
identity.Variable("SeriesNumber", "SeriesNumber", new DriverAttributeInfo(
FullName: "10.0.0.5:8193/Identity/SeriesNumber",
DriverDataType: DriverDataType.String, IsArray: false, ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly, IsHistorized: false));
b.Nodes.Count.ShouldBe(1);
var n = b.Nodes[0];
n.FolderPathSegments.ShouldBe(new[] { "FOCAS", "10.0.0.5:8193", "Identity" });
n.BrowseName.ShouldBe("SeriesNumber");
n.FullReference.ShouldBe("10.0.0.5:8193/Identity/SeriesNumber");
n.DataType.ShouldBe(DriverDataType.String);
n.Writable.ShouldBeFalse(); // ViewOnly → read-only
}
[Fact]
public void AddProperty_is_ignored_and_alarm_marking_is_a_noop_sink()
{
var b = new CapturingAddressSpaceBuilder();
var f = b.Folder("FOCAS", "FOCAS");
f.AddProperty("Manufacturer", DriverDataType.String, "FANUC"); // ignored, no throw
var h = f.Variable("V", "V", new DriverAttributeInfo("ref", DriverDataType.Int32, false, null,
SecurityClassification.ViewOnly, false, IsAlarm: true));
var sink = h.MarkAsAlarmCondition(new AlarmConditionInfo("src", AlarmSeverity.Low, null));
sink.ShouldNotBeNull(); // no-op sink, alarms out of scope
b.Nodes.Count.ShouldBe(1);
}
}
Step 2: Run to verify it fails — dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~CapturingAddressSpaceBuilderTests" → FAIL (types don't exist).
Step 3: Implement DiscoveredNode.cs
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
/// <summary>
/// A flattened variable captured from a driver's <see cref="ITagDiscovery.DiscoverAsync"/> stream
/// by <see cref="CapturingAddressSpaceBuilder"/>. Folder nesting is preserved in
/// <see cref="FolderPathSegments"/> so the injector can re-root the node under an equipment.
/// </summary>
public sealed record DiscoveredNode(
IReadOnlyList<string> FolderPathSegments,
string BrowseName,
string DisplayName,
string FullReference,
DriverDataType DataType,
bool IsArray,
uint? ArrayDim,
bool Writable,
bool IsHistorized);
Step 3b: Implement CapturingAddressSpaceBuilder.cs
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
/// <summary>
/// An <see cref="IAddressSpaceBuilder"/> that RECORDS the streamed tree instead of creating OPC UA
/// nodes — used to capture an <see cref="ITagDiscovery"/> driver's discovered hierarchy so the
/// runtime can graft it under an equipment node. Folder nesting is tracked (each child builder
/// carries its accumulated path), so every variable records its full <see cref="DiscoveredNode.FolderPathSegments"/>.
/// <para>Value nodes only: <see cref="AddProperty"/> is ignored and alarm marking returns a no-op sink
/// (discovered alarms are out of scope — alarms come via the config path).</para>
/// <para>Single-threaded: a driver's <c>DiscoverAsync</c> streams on one caller; the root and its child
/// builders share one <see cref="List{T}"/>. Not thread-safe by design.</para>
/// </summary>
public sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder
{
private readonly List<DiscoveredNode> _nodes;
private readonly IReadOnlyList<string> _path;
public CapturingAddressSpaceBuilder() : this([], []) { }
private CapturingAddressSpaceBuilder(List<DiscoveredNode> nodes, IReadOnlyList<string> path)
{
_nodes = nodes;
_path = path;
}
/// <summary>All variables captured across the whole tree (shared by the root and every child scope).</summary>
public IReadOnlyList<DiscoveredNode> Nodes => _nodes;
public IAddressSpaceBuilder Folder(string browseName, string displayName)
=> new CapturingAddressSpaceBuilder(_nodes, [.. _path, browseName]);
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
_nodes.Add(new DiscoveredNode(
FolderPathSegments: _path,
BrowseName: browseName,
DisplayName: displayName,
FullReference: attributeInfo.FullName,
DataType: attributeInfo.DriverDataType,
IsArray: attributeInfo.IsArray,
ArrayDim: attributeInfo.ArrayDim,
Writable: attributeInfo.SecurityClass != SecurityClassification.ViewOnly,
IsHistorized: attributeInfo.IsHistorized));
return new NullHandle(attributeInfo.FullName);
}
public void AddProperty(string browseName, DriverDataType dataType, object? value) { /* metadata only — ignored */ }
private sealed class NullHandle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args) { }
}
}
Step 4: Run to verify it passes — same filter → PASS.
Step 5: Commit
git add src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNode.cs \
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/CapturingAddressSpaceBuilder.cs \
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/CapturingAddressSpaceBuilderTests.cs
git commit -m "feat(otopcua): capturing address-space builder for driver discovery"
Task 2: DiscoveredNodeMapper — map discovered nodes under an equipment
Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 3
Files:
- Create:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/DiscoveredInjection.cs(theDiscoveredFolder/DiscoveredVariablematerialize DTOs — placed in OpcUaServer so both the applier and the Runtime mapper can reference them; Runtime already references OpcUaServer) - Create:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNodeMapper.cs - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveredNodeMapperTests.cs
Pure function turning IReadOnlyList<DiscoveredNode> + an equipmentId + the driver's authored-tag refs into folders + variables (NodeIds under the equipment) + routing entries. Rules:
- Device-folder collapse: if every node shares an identical segment at index 1 (the single device folder under the driver root), drop index 1 →
EQ/FOCAS/Identity/…rather thanEQ/FOCAS/<deviceHost>/Identity/…. With ≥2 devices the segments differ → not collapsed (device level retained, degrades gracefully — multi-device equipment mapping itself is a deferred follow-up). - Dedup: skip any node whose
FullReferenceis inauthoredRefs(already a Config-DB equipment tag for this driver — applies to drivers like Galaxy whose discovery refs equal the equipment-tag FullNames; for FOCAS the FixedTree refs never match authored refs, so all FixedTree nodes pass through). - NodeId:
EquipmentNodeIds.Variable(equipmentId, folderPath, name)wherefolderPath= collapsed segments joined by/. Folders deduped, each parented at its prefix. - DataType: convert
DriverDataType→ the OPC-UA-builtin stringOtOpcUaNodeManager.EnsureVariableexpects. Reuse the existing convention — grep for howEquipmentTagPlan.DataTypeis produced fromDriverDataType(e.g. aDriverDataType.ToString()/ a mapping helper) andOtOpcUaNodeManager.ResolveBuiltInDataType; do NOT invent a new mapping. If a helper exists, call it; the switch below is a fallback to align if not. - Writable: from
DiscoveredNode.Writable(FixedTree is read-only).
Step 1: Write the failing test
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
[Trait("Category", "Unit")]
public sealed class DiscoveredNodeMapperTests
{
private static DiscoveredNode Node(string[] path, string name, string fullRef,
DriverDataType dt = DriverDataType.Float64, bool writable = false)
=> new(path, name, name, fullRef, dt, false, null, writable, false);
[Fact]
public void Maps_under_equipment_collapsing_single_device_folder()
{
var nodes = new[]
{
Node(["FOCAS", "10.0.0.5:8193", "Identity"], "SeriesNumber", "10.0.0.5:8193/Identity/SeriesNumber", DriverDataType.String),
Node(["FOCAS", "10.0.0.5:8193", "Axes", "X"], "AbsolutePosition", "10.0.0.5:8193/Axes/X/AbsolutePosition"),
};
var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: []);
result.Variables.Select(v => v.NodeId).ShouldBe(new[]
{
"EQ-1/FOCAS/Identity/SeriesNumber",
"EQ-1/FOCAS/Axes/X/AbsolutePosition",
}, ignoreOrder: true);
// folders: EQ-1/FOCAS, EQ-1/FOCAS/Identity, EQ-1/FOCAS/Axes, EQ-1/FOCAS/Axes/X
result.Folders.Select(f => f.NodeId).ShouldContain("EQ-1/FOCAS/Axes/X");
result.Folders.First(f => f.NodeId == "EQ-1/FOCAS/Axes/X").ParentNodeId.ShouldBe("EQ-1/FOCAS/Axes");
// routing: driverRef → nodeId
result.RoutingByRef["10.0.0.5:8193/Identity/SeriesNumber"].ShouldBe("EQ-1/FOCAS/Identity/SeriesNumber");
result.Variables.First(v => v.NodeId.EndsWith("SeriesNumber")).Writable.ShouldBeFalse();
}
[Fact]
public void Dedups_authored_refs()
{
var nodes = new[]
{
Node(["FOCAS", "10.0.0.5:8193"], "parts-count", "parts-count"), // authored
Node(["FOCAS", "10.0.0.5:8193", "Identity"], "SeriesNumber", "10.0.0.5:8193/Identity/SeriesNumber", DriverDataType.String),
};
var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: new HashSet<string> { "parts-count" });
result.Variables.ShouldHaveSingleItem();
result.Variables[0].NodeId.ShouldBe("EQ-1/FOCAS/Identity/SeriesNumber");
}
[Fact]
public void Does_not_collapse_when_two_devices_present()
{
var nodes = new[]
{
Node(["FOCAS", "10.0.0.5:8193", "Identity"], "SeriesNumber", "a", DriverDataType.String),
Node(["FOCAS", "10.0.0.6:8193", "Identity"], "SeriesNumber", "b", DriverDataType.String),
};
var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: []);
result.Variables.Select(v => v.NodeId).ShouldBe(new[]
{
"EQ-1/FOCAS/10.0.0.5:8193/Identity/SeriesNumber",
"EQ-1/FOCAS/10.0.0.6:8193/Identity/SeriesNumber",
}, ignoreOrder: true);
}
}
Step 2: Run to verify it fails.
Step 3: Implement DiscoveredInjection.cs (DTOs)
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// <summary>A folder to ensure during discovered-node injection (NodeId + parent + display).</summary>
public sealed record DiscoveredFolder(string NodeId, string? ParentNodeId, string DisplayName);
/// <summary>A read-or-write variable to ensure during discovered-node injection.</summary>
public sealed record DiscoveredVariable(
string NodeId, string ParentNodeId, string DisplayName, string DataType, bool Writable, bool IsArray, uint? ArrayLength);
Step 3b: Implement DiscoveredNodeMapper.cs
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
/// <summary>The mapped result of grafting discovered nodes under an equipment.</summary>
public sealed record DiscoveredInjectionPlan(
IReadOnlyList<DiscoveredFolder> Folders,
IReadOnlyList<DiscoveredVariable> Variables,
IReadOnlyDictionary<string, string> RoutingByRef); // driver FullReference → equipment NodeId
/// <summary>
/// Pure mapper: re-roots a driver's captured discovery tree under an equipment node, deduping
/// authored Config-DB refs and collapsing the single device-host folder. See the design doc.
/// </summary>
public static class DiscoveredNodeMapper
{
public static DiscoveredInjectionPlan Map(
string equipmentId, IReadOnlyList<DiscoveredNode> nodes, ISet<string> authoredRefs)
{
var kept = nodes.Where(n => !authoredRefs.Contains(n.FullReference)).ToList();
// Collapse a single shared device-folder level (index 1 under the driver root) when present.
var collapseIndex1 = kept.Count > 0
&& kept.All(n => n.FolderPathSegments.Count >= 2)
&& kept.Select(n => n.FolderPathSegments[1]).Distinct(StringComparer.Ordinal).Count() == 1;
static IReadOnlyList<string> Effective(IReadOnlyList<string> segs, bool collapse)
=> collapse ? [segs[0], .. segs.Skip(2)] : segs;
var folders = new Dictionary<string, DiscoveredFolder>(StringComparer.Ordinal);
var variables = new List<DiscoveredVariable>();
var routing = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var n in kept)
{
var segs = Effective(n.FolderPathSegments, collapseIndex1);
// Ensure every prefix folder EQ/seg0, EQ/seg0/seg1, …
for (var i = 0; i < segs.Count; i++)
{
var folderPath = string.Join('/', segs.Take(i + 1));
var nodeId = EquipmentNodeIds.SubFolder(equipmentId, folderPath);
if (folders.ContainsKey(nodeId)) continue;
var parent = i == 0 ? equipmentId : EquipmentNodeIds.SubFolder(equipmentId, string.Join('/', segs.Take(i)));
folders[nodeId] = new DiscoveredFolder(nodeId, parent, segs[i]);
}
var varFolderPath = string.Join('/', segs);
var varNodeId = EquipmentNodeIds.Variable(equipmentId, varFolderPath, n.BrowseName);
var varParent = EquipmentNodeIds.SubFolder(equipmentId, varFolderPath);
variables.Add(new DiscoveredVariable(
varNodeId, varParent, n.DisplayName, ToBuiltinTypeString(n.DataType), n.Writable, n.IsArray, n.ArrayDim));
routing[n.FullReference] = varNodeId;
}
return new DiscoveredInjectionPlan(folders.Values.ToList(), variables, routing);
}
// Align with the existing DriverDataType → builtin-string convention used by EquipmentTagPlan /
// OtOpcUaNodeManager.ResolveBuiltInDataType. VERIFY against that during implementation.
private static string ToBuiltinTypeString(DriverDataType dt) => dt.ToString();
}
Implementation note: before finalizing
ToBuiltinTypeString, grep howEquipmentTagPlan.DataTypeis produced from aDriverDataTypeand what stringsOtOpcUaNodeManager.ResolveBuiltInDataTypeaccepts (e.g."Float64","String","Int32"). IfDriverDataType.ToString()already matches, keep it; otherwise mirror the existing mapping helper. The mapper test asserts NodeIds/structure, not the exact type string — add a focused assertion once the convention is confirmed.
Step 4: Run to verify it passes.
Step 5: Commit
git add src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/DiscoveredInjection.cs \
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNodeMapper.cs \
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveredNodeMapperTests.cs
git commit -m "feat(otopcua): map discovered nodes under an equipment subfolder"
Task 3: Node-manager RaiseNodesAddedModelChange()
Classification: standard Estimated implement time: ~4 min Parallelizable with: Task 1, Task 2
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs(add a public method nearBuildNodeShapeChangedEvent:1525) - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerModelChangeOnAddTests.cs(model onNodeManagerSurgicalShapeUpdateTests.cs)
Emit a Part 3 GeneralModelChangeEvent with verb NodeAdded so already-connected clients can refresh their browse after a runtime add. Mirror the existing BuildNodeShapeChangedEvent (verb DataTypeChanged) + its Server.ReportEvent seam, but build a NodeAdded change referencing the equipment subfolder root that gained children.
Step 1: Write the failing test — instantiate the node manager as the surgical-shape test does, EnsureFolder + EnsureVariable a couple of nodes, call RaiseNodesAddedModelChange(parentNodeId), and assert it does not throw and (where the harness exposes reported events, as the surgical test does) that a GeneralModelChangeEvent with verb NodeAdded was reported. Reuse the surgical test's harness/setup verbatim.
Step 2: Run to verify it fails (method missing).
Step 3: Implement — add:
/// <summary>
/// Announce that nodes were added at runtime (discovered-node injection) so subscribed clients can
/// refresh their browse. Part 3 §8.7.4: a GeneralModelChangeEvent is emitted by the Server object;
/// verb = NodeAdded, affected = the subfolder root that gained children. Mirrors
/// <see cref="BuildNodeShapeChangedEvent"/>'s ReportEvent seam; tolerant if auditing/eventing is off.
/// </summary>
/// <param name="affectedNodeId">The equipment/subfolder NodeId string under which nodes were added.</param>
public void RaiseNodesAddedModelChange(string affectedNodeId)
{
GeneralModelChangeEventState e;
lock (Lock)
{
// BUILD the event under Lock (consistent snapshot of _folders/_variables), mirroring
// BuildNodeShapeChangedEvent: EventId, SourceNode = ObjectIds.Server, SourceName, Time,
// Severity, a ModelChangeStructureDataType with Affected = new NodeId(affectedNodeId,
// NamespaceIndex) + Verb = (byte)ModelChangeStructureVerbMask.NodeAdded, ClearChangeMasks.
e = BuildNodesAddedModelChange(affectedNodeId);
}
// REPORT OUTSIDE Lock — Server.ReportEvent re-enters the server's own subscription/event path;
// holding Lock across it risks a lock-order inversion (mirror ReportNodeShapeChangedEvent, NOT
// ReportConditionEvent which uses alarm.ReportEvent). Tolerant: eventing off / no monitored items.
try { Server.ReportEvent(SystemContext, e); }
catch (Exception ex)
{
#pragma warning disable CS0618
Utils.LogError(ex, "OtOpcUaNodeManager: failed to report GeneralModelChangeEvent(NodeAdded) for {0}", affectedNodeId);
#pragma warning restore CS0618
}
}
⚠️ Lock discipline (corrected 2026-06-26): BUILD the
GeneralModelChangeEventStateunderlock (Lock)(copy the field-population block fromBuildNodeShapeChangedEvent:1525, changing onlyVerb→NodeAddedandAffected), but REPORTServer.ReportEventOUTSIDE the lock — exactly likeReportNodeShapeChangedEvent/RevertOptimisticWriteIfNeeded.Server.ReportEventre-enters the SDK subscription/event path; holdingLockacross it risks a lock-order-inversion deadlock with a client that has event subscriptions. (An earlier draft of this plan said "keep it insidelock (Lock)" — that was wrong forServer.ReportEvent;ReportConditionEventis not a valid analogue since it usesalarm.ReportEvent, the node's own notifier chain.)
Step 4: Run to verify it passes.
Step 5: Commit
git add src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs \
tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerModelChangeOnAddTests.cs
git commit -m "feat(otopcua): GeneralModelChangeEvent(NodeAdded) for runtime node adds"
Task 4: AddressSpaceApplier.MaterialiseDiscoveredNodes(...)
Classification: standard Estimated implement time: ~4 min Parallelizable with: none (depends on Tasks 2, 3)
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs(add afterMaterialiseEquipmentTags:304) - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs(add cases)
Add an idempotent pass that ensures the mapped folders then variables via the existing SafeEnsureFolder/SafeEnsureVariable, then raises the model-change. Folders MUST be ensured parent-before-child (sort by NodeId depth / segment count).
Step 1: Write the failing test — using the applier test's existing fake sink, call MaterialiseDiscoveredNodes with 2 folders + 2 read-only variables and assert the sink received EnsureFolder/EnsureVariable with the right NodeIds/parents, writable: false, and that a re-apply is a no-op (idempotent — sink early-returns on existing). Assert RaiseNodesAddedModelChange is invoked (extend the fake sink/node-manager double to record it, mirroring how the existing test verifies materialize calls).
Step 2: Run to verify it fails.
Step 3: Implement
/// <summary>
/// Materialise driver-discovered nodes (FixedTree) under an equipment at runtime. Idempotent:
/// re-applies are cheap (the sink's EnsureFolder/EnsureVariable early-return on existing nodes), so
/// this is safely re-run after every address-space rebuild. Folders are ensured parent-first.
/// Emits a NodeAdded model-change so connected clients can refresh.
/// </summary>
public void MaterialiseDiscoveredNodes(
string equipmentRootNodeId,
IReadOnlyList<DiscoveredFolder> folders,
IReadOnlyList<DiscoveredVariable> variables)
{
ArgumentNullException.ThrowIfNull(folders);
ArgumentNullException.ThrowIfNull(variables);
if (folders.Count == 0 && variables.Count == 0) return;
foreach (var f in folders.OrderBy(f => f.NodeId.Count(c => c == '/')))
SafeEnsureFolder(f.NodeId, f.ParentNodeId, f.DisplayName);
foreach (var v in variables)
SafeEnsureVariable(v.NodeId, v.ParentNodeId, v.DisplayName, v.DataType, v.Writable,
historianTagname: null, isArray: v.IsArray, arrayLength: v.ArrayLength);
_sink.RaiseNodesAddedModelChange(equipmentRootNodeId);
_logger.LogInformation(
"AddressSpaceApplier: discovered nodes materialised under {Equipment} (folders={Folders}, vars={Vars})",
equipmentRootNodeId, folders.Count, variables.Count);
}
Confirm
_sink's interface exposesRaiseNodesAddedModelChange(the sink type wrapsOtOpcUaNodeManager); add it to the sink interface if the applier talks to anIAddressSpaceSinkabstraction rather than the concrete manager.
Step 4: Run to verify it passes.
Step 5: Commit
git add src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs \
tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs
git commit -m "feat(otopcua): applier pass to materialise discovered nodes idempotently"
Task 5: OpcUaPublishActor.MaterialiseDiscoveredNodes message + handler
Classification: standard Estimated implement time: ~4 min Parallelizable with: none (depends on Task 4)
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs(message record near the other records;Receive<…>at the block:217; handler nearHandleRebuild) - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs(add a case)
Step 1: Write the failing test — with the publish-actor test harness (fake applier), send MaterialiseDiscoveredNodes(equipmentRoot, folders, variables) and assert the handler forwards to _applier.MaterialiseDiscoveredNodes(...) with the same payload.
Step 2: Run to verify it fails.
Step 3: Implement — add the message + Receive + handler:
/// <summary>Inject driver-discovered nodes (FixedTree) under an equipment at runtime (post-connect).</summary>
public sealed record MaterialiseDiscoveredNodes(
string EquipmentRootNodeId,
IReadOnlyList<DiscoveredFolder> Folders,
IReadOnlyList<DiscoveredVariable> Variables);
In the Receive block (:217, alongside Receive<RebuildAddressSpace>(HandleRebuild)):
Receive<MaterialiseDiscoveredNodes>(HandleMaterialiseDiscovered);
Handler:
private void HandleMaterialiseDiscovered(MaterialiseDiscoveredNodes msg)
=> _applier.MaterialiseDiscoveredNodes(msg.EquipmentRootNodeId, msg.Folders, msg.Variables);
Step 4: Run to verify it passes.
Step 5: Commit
git add src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs \
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs
git commit -m "feat(otopcua): OpcUaPublishActor handles discovered-node materialisation"
Task 6: DriverInstanceActor post-connect bounded re-discovery
Classification: high-risk Estimated implement time: ~5 min Parallelizable with: none (depends on Task 1; touches actor lifecycle)
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs(message records area:60-160;Connected()entry viaInitializeSucceeded:278; new private async discovery method + a self-scheduled retry tick) - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs
On reaching Connected, if _driver is ITagDiscovery, run discovery into a CapturingAddressSpaceBuilder, and Context.Parent.Tell(new DiscoveredNodesReady(_driverInstanceId, nodes)). Because FOCAS suppresses FixedTree until FixedTreeCache populates (~0–2 s), schedule a bounded retry: re-run every ~2 s up to a cap (~30 s / ~15 attempts) or until the node count stops growing (whichever first), then stop. DiscoverAsync reads in-memory cache → cheap. Reset/cancel the schedule on leaving Connected (DisconnectObserved/ForceReconnect) and re-arm on the next Connected entry. Use Akka scheduling (Context.System.Scheduler.ScheduleTellOnce self-tell of an internal RediscoverTick, tracked by an ICancelable so it's cancelled on state exit) — do NOT block the actor thread.
Message records to add (near the other nested records):
/// <summary>Published to the parent (DriverHostActor) after a post-connect discovery pass.</summary>
public sealed record DiscoveredNodesReady(string DriverInstanceId, IReadOnlyList<DiscoveredNode> Nodes);
/// <summary>Internal self-tick driving bounded post-connect re-discovery.</summary>
private sealed record RediscoverTick(int Generation, int Attempt, int LastCount);
Step 1: Write the failing test — drive a DriverInstanceActor with a fake IDriver that also implements ITagDiscovery, whose DiscoverAsync yields 0 nodes on the first ~2 attempts then a non-empty set (simulating FixedTreeCache populating). Bring the actor to Connected (send the same init messages the existing DriverInstanceActorTests use). Use the TestKit parent probe (Context.Parent → the TestKit TestActor via ActorOf under the testkit, or the existing harness's parent-probe pattern in DriverInstanceActorTests) and ExpectMsg<DiscoveredNodesReady> — assert the eventually-delivered message carries the non-empty set, and that re-ticks stop after the set stabilises (no infinite stream). Use the TestKit scheduler / Within to advance.
Step 2: Run to verify it fails.
Step 3: Implement — add the discovery kick at the InitializeSucceeded Connected transition (after ResubscribeDesired()), a Receive<RediscoverTick> in Connected(), and a RunDiscoveryAsync that:
- guards
_driver is ITagDiscovery disc(else no-op), - builds a
CapturingAddressSpaceBuilder, awaitsdisc.DiscoverAsync(builder, ct), Context.Parent.Tell(new DiscoveredNodesReady(_driverInstanceId, builder.Nodes)),- if
attempt < capandbuilder.Nodes.Countstill growing (or zero), schedules the nextRediscoverTick(_initGeneration, attempt+1, builder.Nodes.Count)viaICancelable(store in a field, cancel onDetachSubscription/state exit). - Tag ticks with
_initGenerationand ignore stale-generation ticks (mirrors the existingInitializeSucceeded.Generationguard) so a reconnect cancels the prior loop.
Use
ReceiveAsync<RediscoverTick>(like the other async receives inConnected()), and wrap the discovery call in try/catch → log Info + reschedule (bounded). Mirror the existing cancelable-scheduling pattern already used in the actor (grepScheduler/ICancelablein this file andDriverHostActor).
Step 4: Run to verify it passes.
Step 5: Commit
git add src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs \
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs
git commit -m "feat(otopcua): driver-instance post-connect bounded re-discovery"
Task 7: DriverHostActor — inject discovered nodes (handler + routing + subscribe)
Classification: high-risk Estimated implement time: ~5 min Parallelizable with: none (depends on Tasks 2, 5, 6; touches actor + routing map)
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs(fields near_nodeIdByDriverRef;Receive<…>in BOTH receive states:482and:512; new handler; store_lastCompositioninPushDesiredSubscriptions) - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs
Add Receive<DriverInstanceActor.DiscoveredNodesReady>(HandleDiscoveredNodes) to the two states that already handle AttributeValuePublished (:484, :512). New fields: _lastComposition (set at the end of PushDesiredSubscriptions) and _discoveredByDriver (Dictionary<string, DiscoveredInjectionPlan>). The handler:
- If
_lastCompositionis null → stash nothing / log Debug and return (composition not applied yet; a laterDiscoveredNodesReadyretry will land after apply). - Resolve the equipment:
_lastComposition.EquipmentNodes.Where(e => e.DriverInstanceId == id). 0 → log Info skip; >1 → log Warning skip (multi-device deferred). Else take itsEquipmentId. - Compute
authoredRefs=_lastComposition.EquipmentTags.Where(t => t.DriverInstanceId == id).Select(t => t.FullName)set. var plan = DiscoveredNodeMapper.Map(equipmentId, msg.Nodes, authoredRefs);- If
plan.Variablesempty → return (nothing new yet). _discoveredByDriver[id] = plan;- For each
(ref, nodeId)inplan.RoutingByRef: add to_nodeIdByDriverRef[(id, ref)](the sameHashSetfan-out structure used inPushDesiredSubscriptions:1019). _opcUaPublishActor.Tell(new OpcUaPublishActor.MaterialiseDiscoveredNodes(equipmentId, plan.Folders, plan.Variables));- Merge the discovered refs into the driver's desired set and re-push:
child.Actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(union, SubscriptionPublishingInterval, alarmRefs))whereunion= authored refs already pushed for that driver plusplan.RoutingByRef.Keys. (Keep the alarmRefs as last pushed.) The actor'sConnectedSetDesiredSubscriptionshandler immediately re-subscribes (:340-353).
Step 1: Write the failing test — build a DriverHostActor via its existing test harness (DriverHostActorTests/...WriteRoutingTests show construction with fakes: a fake child/registry, fake OPC publish probe, a composition artifact). Apply a deployment whose composition has one equipment (EQ-1, DriverInstanceId=d1) + one authored tag, so _lastComposition is set and a child d1 exists. Send DriverInstanceActor.DiscoveredNodesReady("d1", <fixedtree nodes>). Assert: (a) the OPC publish probe received MaterialiseDiscoveredNodes with the mapped folders/vars; (b) the child probe received a SetDesiredSubscriptions whose refs include both the authored ref and the FixedTree refs; (c) a subsequent AttributeValuePublished(d1, <fixedtree ref>, value) routes to an AttributeValueUpdate at the mapped NodeId (proves _nodeIdByDriverRef updated).
Step 2: Run to verify it fails.
Step 3: Implement per the steps above. Store _lastComposition = composition; at the end of PushDesiredSubscriptions (after the existing logic). Reuse the exact fan-out add pattern for _nodeIdByDriverRef from :1019-1045.
Step 4: Run to verify it passes.
Step 5: Commit
git add src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs \
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs
git commit -m "feat(otopcua): inject discovered nodes into the equipment projection on connect"
Task 8: DriverHostActor — re-inject discovered nodes after a rebuild
Classification: high-risk Estimated implement time: ~3 min Parallelizable with: none (depends on Task 7; same file)
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs(tail ofPushDesiredSubscriptions) - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs(add a case)
A structural redeploy triggers RebuildAddressSpace (full teardown) and PushDesiredSubscriptions rebuilds _nodeIdByDriverRef from authored tags only — losing the injected FixedTree nodes + mappings. After the existing PushDesiredSubscriptions work, re-apply the cached _discoveredByDriver: for each cached plan, re-add its RoutingByRef to _nodeIdByDriverRef, re-Tell MaterialiseDiscoveredNodes, and re-merge its refs into that driver's pushed SetDesiredSubscriptions.
Step 1: Write the failing test — after Task 7's injection, simulate a second PushDesiredSubscriptions (re-apply the same deployment). Assert the OPC publish probe receives MaterialiseDiscoveredNodes AGAIN and the child's re-pushed SetDesiredSubscriptions still includes the FixedTree refs (i.e. they weren't dropped by the rebuild).
Step 2: Run to verify it fails (today the rebuild drops them).
Step 3: Implement — extract the per-driver merge-and-materialise into a helper reused by both HandleDiscoveredNodes and a new ReapplyDiscovered() call at the tail of PushDesiredSubscriptions (after _lastComposition is set). Guard for the case where the driver no longer exists in _children or the equipment was removed (drop that cache entry).
Step 4: Run to verify it passes.
Step 5: Commit
git add src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs \
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs
git commit -m "feat(otopcua): re-inject discovered nodes after address-space rebuild"
Task 9: Integration test — discovered nodes appear + carry values + survive lifecycle
Classification: standard Estimated implement time: ~5 min Parallelizable with: none (depends on Tasks 7, 8)
Files:
- Create:
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs - (Reuse / extend any existing in-memory
IDrivertest double in the Runtime tests; create aFakeDiscoverableDriver : IDriver, ITagDiscovery, ISubscribableif none fits.)
A focused in-process integration test (no docker, no CNC): wire DriverHostActor + OpcUaPublishActor + a real AddressSpaceApplier/node manager (as the publish-actor rebuild tests do) + a fake discoverable+subscribable driver whose DiscoverAsync exposes a delayed FixedTree set and whose poll returns values for those refs. Assert end-to-end:
- After connect + the discovery delay, the node manager has variables at
EQ-…/FOCAS/…. - A poll value for a FixedTree ref surfaces as a Good
AttributeValueUpdateat the mapped NodeId (no longerBadWaitingForInitialData). - After a simulated rebuild (re-apply), the nodes + values persist.
If a full wiring proves too heavy for one test fixture, split into (9a) host→publish materialisation reaching a real node manager, and (9b) value-route smoke — but keep both in this file. Do NOT silently drop the lifecycle assertion; if you cannot wire a real node manager here, log that limitation in the test summary and cover it in Task 10's docker-dev step instead.
Step 5: Commit
git add tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs
git commit -m "test(otopcua): end-to-end discovered-node injection + value flow"
Task 10: Build + full suite + docker-dev smoke
Classification: small Estimated implement time: ~5 min Parallelizable with: none (depends on all prior)
Files: none (verification only; fix wiring if the build/tests surface gaps)
Steps:
dotnet build ZB.MOM.WW.OtOpcUa.slnx→ 0 errors, 0 warnings.dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~Runtime.Tests"→ green.dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~OpcUaServer.Tests"→ green.dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~FOCAS"→ green (no regression).- docker-dev smoke (optional but recommended): build the docker-dev image, boot
central-1(fused admin+driver), confirm via logs that a connected discoverable driver injects nodes (AddressSpaceApplier: discovered nodes materialised) and that browse showsEQ-…/FOCAS/…. (Mirror the symptom-#1 docker-dev confirmation in the investigation plan.) - Commit any wiring fixes with a
fix(otopcua):message.
Task 11: Docs
Classification: trivial Estimated implement time: ~3 min Parallelizable with: none (depends on Task 10)
Files:
- Modify:
docs/plans/2026-06-25-otopcua-equipment-dataplane-investigation.md(mark symptom #2 / the FixedTree feature done, link this plan + the design doc) - Modify:
docs/deployments/wonder-app-vd03-makino-z-34184.md(note FixedTree now surfaces underEQ-…/FOCAS/…) - Modify:
docs/plans/2026-06-26-otopcua-fixedtree-equipment-injection-design.md(status → Implemented)
Step: Commit
git add docs/plans/2026-06-25-otopcua-equipment-dataplane-investigation.md \
docs/deployments/wonder-app-vd03-makino-z-34184.md \
docs/plans/2026-06-26-otopcua-fixedtree-equipment-injection-design.md
git commit -m "docs(otopcua): record FixedTree-under-Equipment injection feature"
Live deploy (post-plan, with user confirmation)
Following the symptom-#1 pattern (self-contained publish-overlay → wonder): after the suite is green and docker-dev confirms, confirm with the user before deploying to the production CNC node, then deploy and browse EQ-3686c0272279/FOCAS/Identity/SeriesNumber + …/Axes/X/AbsolutePosition (assert status Good — values may be 0 on the idle machine). Live deploy is explicitly NOT part of the build/test gate.
Follow-ups (out of scope, documented)
- Discovered alarms injection; multi-device-per-driver-instance equipment mapping; writable discovered nodes.
- Reconcile the AdminUI↔driver FOCAS config-format mismatch (series-as-number, scheme-less host) at the AdminUI source.
- Shared
AddZbSerilognot setting staticSerilog.Log.Logger(latent across all 3 apps).