Files
lmxopcua/docs/plans/2026-06-26-otopcua-fixedtree-equipment-injection.md
T

44 KiB
Raw Blame History

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 ~02 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 / DriverAttributeInfosrc/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, seeds BadWaitingForInitialData), BuildNodeShapeChangedEvent (:1525, verb DataTypeChanged — model for a NodeAdded sibling).
  • 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), _nodeIdByDriverRef built in PushDesiredSubscriptions (:1019, sends SetDesiredSubscriptions :1052), ChildEntry (:203), Receive blocks (:482, :512).
  • Driver connect hook: DriverInstanceActor _driver field (:110), Connected() (:317), transition at InitializeSucceeded (:278); SetDesiredSubscriptions live re-subscribe path (:340-353).
  • FOCAS discovery (reused verbatim): FocasDriver.DiscoverAsync (:408) emits FOCAS/{deviceHost}/<section>/…; FixedTree leaf FullName = {deviceHost}/{path}; suppresses FixedTree until FixedTreeCache set.

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.slnx from 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.jsongit add only 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 failsdotnet 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 (the DiscoveredFolder/DiscoveredVariable materialize 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 than EQ/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 FullReference is in authoredRefs (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) where folderPath = collapsed segments joined by /. Folders deduped, each parented at its prefix.
  • DataType: convert DriverDataType → the OPC-UA-builtin string OtOpcUaNodeManager.EnsureVariable expects. Reuse the existing convention — grep for how EquipmentTagPlan.DataType is produced from DriverDataType (e.g. a DriverDataType.ToString() / a mapping helper) and OtOpcUaNodeManager.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 how EquipmentTagPlan.DataType is produced from a DriverDataType and what strings OtOpcUaNodeManager.ResolveBuiltInDataType accepts (e.g. "Float64", "String", "Int32"). If DriverDataType.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 near BuildNodeShapeChangedEvent:1525)
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerModelChangeOnAddTests.cs (model on NodeManagerSurgicalShapeUpdateTests.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 GeneralModelChangeEventState under lock (Lock) (copy the field-population block from BuildNodeShapeChangedEvent :1525, changing only VerbNodeAdded and Affected), but REPORT Server.ReportEvent OUTSIDE the lock — exactly like ReportNodeShapeChangedEvent / RevertOptimisticWriteIfNeeded. Server.ReportEvent re-enters the SDK subscription/event path; holding Lock across it risks a lock-order-inversion deadlock with a client that has event subscriptions. (An earlier draft of this plan said "keep it inside lock (Lock)" — that was wrong for Server.ReportEvent; ReportConditionEvent is not a valid analogue since it uses alarm.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 after MaterialiseEquipmentTags: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 exposes RaiseNodesAddedModelChange (the sink type wraps OtOpcUaNodeManager); add it to the sink interface if the applier talks to an IAddressSpaceSink abstraction 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 near HandleRebuild)
  • 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 via InitializeSucceeded: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 (~02 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, awaits disc.DiscoverAsync(builder, ct),
  • Context.Parent.Tell(new DiscoveredNodesReady(_driverInstanceId, builder.Nodes)),
  • if attempt < cap and builder.Nodes.Count still growing (or zero), schedules the next RediscoverTick(_initGeneration, attempt+1, builder.Nodes.Count) via ICancelable (store in a field, cancel on DetachSubscription/state exit).
  • Tag ticks with _initGeneration and ignore stale-generation ticks (mirrors the existing InitializeSucceeded.Generation guard) so a reconnect cancels the prior loop.

Use ReceiveAsync<RediscoverTick> (like the other async receives in Connected()), and wrap the discovery call in try/catch → log Info + reschedule (bounded). Mirror the existing cancelable-scheduling pattern already used in the actor (grep Scheduler/ICancelable in this file and DriverHostActor).

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 :482 and :512; new handler; store _lastComposition in PushDesiredSubscriptions)
  • 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:

  1. If _lastComposition is null → stash nothing / log Debug and return (composition not applied yet; a later DiscoveredNodesReady retry will land after apply).
  2. Resolve the equipment: _lastComposition.EquipmentNodes.Where(e => e.DriverInstanceId == id). 0 → log Info skip; >1 → log Warning skip (multi-device deferred). Else take its EquipmentId.
  3. Compute authoredRefs = _lastComposition.EquipmentTags.Where(t => t.DriverInstanceId == id).Select(t => t.FullName) set.
  4. var plan = DiscoveredNodeMapper.Map(equipmentId, msg.Nodes, authoredRefs);
  5. If plan.Variables empty → return (nothing new yet).
  6. _discoveredByDriver[id] = plan;
  7. For each (ref, nodeId) in plan.RoutingByRef: add to _nodeIdByDriverRef[(id, ref)] (the same HashSet fan-out structure used in PushDesiredSubscriptions:1019).
  8. _opcUaPublishActor.Tell(new OpcUaPublishActor.MaterialiseDiscoveredNodes(equipmentId, plan.Folders, plan.Variables));
  9. Merge the discovered refs into the driver's desired set and re-push: child.Actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(union, SubscriptionPublishingInterval, alarmRefs)) where union = authored refs already pushed for that driver plus plan.RoutingByRef.Keys. (Keep the alarmRefs as last pushed.) The actor's Connected SetDesiredSubscriptions handler 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 of PushDesiredSubscriptions)
  • 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 IDriver test double in the Runtime tests; create a FakeDiscoverableDriver : IDriver, ITagDiscovery, ISubscribable if 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:

  1. After connect + the discovery delay, the node manager has variables at EQ-…/FOCAS/….
  2. A poll value for a FixedTree ref surfaces as a Good AttributeValueUpdate at the mapped NodeId (no longer BadWaitingForInitialData).
  3. 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:

  1. dotnet build ZB.MOM.WW.OtOpcUa.slnx → 0 errors, 0 warnings.
  2. dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~Runtime.Tests" → green.
  3. dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~OpcUaServer.Tests" → green.
  4. dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~FOCAS" → green (no regression).
  5. 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 shows EQ-…/FOCAS/…. (Mirror the symptom-#1 docker-dev confirmation in the investigation plan.)
  6. 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 under EQ-…/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 AddZbSerilog not setting static Serilog.Log.Logger (latent across all 3 apps).