Files
lmxopcua/docs/plans/2026-07-15-universal-discovery-browser-implementation.md
T
Joseph Doherty 26ef8131b0 docs(browse): Wave-0 live-gate result + follow-ups (Task 18)
Live-verified on docker-dev: AbCip picker renders Browse button (full CanBrowse chain), clicking
it runs the universal-browser capture in production (central-2 logs) with PatchForBrowse
demonstrably applied (@tags walk ran without the authored config setting EnableControllerBrowse);
error surfaces gracefully. Modbus picker shows NO Browse button (negative). Populated tree-render
is fixture-blocked — ab_server returns ErrorUnsupported for the controller @tags walk (test sim
limitation, not a code defect). Two follow-ups recorded: (1) full tree-render needs a symbol-browse
capable AB backend; (2) driver-page pickers don't pass live config to the picker body.
2026-07-15 18:14:03 -04:00

54 KiB
Raw Blame History

Universal Discovery Browser (Wave 0) Implementation Plan

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

Goal: Implement the universal Discover-backed browser (DiscoveryDriverBrowser) so the AdminUI /uns address picker can browse any driver whose DiscoverAsync enumerates from the device — lighting up AbCip, TwinCAT, and FOCAS pickers with zero per-driver browser code.

Architecture: A CapturingAddressSpaceBuilder records what a driver streams into ITagDiscovery.DiscoverAsync; a CapturedTreeBrowseSession serves that tree through the existing IBrowseSession picker plumbing; a DiscoveryDriverBrowser (separate IUniversalDriverBrowser, NOT one of the injected IDriverBrowser set — the ToDictionary dup-key constraint) orchestrates construct→patch→connect→capture→bounded-shutdown with capture coalescing and a global concurrency cap. BrowserSessionService falls back to it when no bespoke browser matches.

Tech Stack: .NET 10, xUnit + Shouldly, Blazor Server (AdminUI), no new NuGet packages (only Microsoft.Extensions.Logging.Abstractions on Commons if not already transitive).

Spec: docs/plans/2026-07-15-universal-discovery-browser-design.md (the authoritative design — read it FIRST). Umbrella: docs/plans/2026-07-15-driver-expansion-program-design.md §3.1 (cross-cutting rules) + §4.


Orchestration rules for the executing agent (READ BEFORE DISPATCHING)

  1. Work on a feature branch: feat/universal-discovery-browser (Task 0 creates it). Never commit to master.
  2. Safe parallelization. Tasks marked Parallelizable with: have disjoint Files: lists and may be dispatched as concurrent subagents. Hard rules:
    • Parallel subagents MUST NOT run any git command (no add/commit/status). Parallel git index access races and corrupts the index (proven in this repo — see the "parallel implementers race the git index" memory). The orchestrator commits each batch's files sequentially, itself, after all subagents in the batch return.
    • A parallel subagent edits only the files in its task's Files: list. If it believes it needs another file, that is a plan defect — it must return the finding, not expand scope.
    • Never dispatch two tasks concurrently that share a file, even "read-mostly" ones like a .csproj.
  3. Batch order (each batch waits for the previous; tasks inside a batch run in parallel):
    • Batch A: Task 1, Task 2, Task 13
    • Batch B: Task 3, Task 4, Task 5
    • Batch C: Task 6 → then Task 7 (sequential — same area)
    • Batch D: Task 8
    • Batch E: Task 9, Task 11
    • Batch F: Task 10, Task 12
    • Batch G: Task 14, Task 15, Task 16
    • Batch H: Task 17 → Task 18 (sequential)
  4. Verification gates: run dotnet build ZB.MOM.WW.OtOpcUa.slnx after every batch; run the named test project after every test-bearing task. Batch H's Task 18 is a live /run gate — AdminUI Razor changes are NOT trusted on unit tests + review alone (repo rule: no bUnit; live-verify or it didn't happen).
  5. TDD: every code task writes the failing test first, sees it fail, implements, sees it pass. Test projects: tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests (builder/session/browser), tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests (service fallback), tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests (interface default).
  6. Single-test command shape: dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests --filter "FullyQualifiedName~<ClassName>".

Task 0: Branch setup

Classification: trivial Estimated implement time: ~1 min Parallelizable with: none (must run first)

Step 1: git checkout -b feat/universal-discovery-browser (from up-to-date master).


Task 1: ITagDiscovery.SupportsOnlineDiscovery default member

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

Files:

  • Modify: src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs
  • Test: tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/TagDiscoveryDefaultsTests.cs (create)

Step 1: Write the failing test

using Shouldly;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using Xunit;

namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;

public class TagDiscoveryDefaultsTests
{
    private sealed class MinimalDiscovery : ITagDiscovery
    {
        public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct) => Task.CompletedTask;
    }

    [Fact]
    public void SupportsOnlineDiscovery_DefaultsToFalse() =>
        ((ITagDiscovery)new MinimalDiscovery()).SupportsOnlineDiscovery.ShouldBeFalse();

    [Fact]
    public void RediscoverPolicy_DefaultRemainsUntilStable() =>
        ((ITagDiscovery)new MinimalDiscovery()).RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.UntilStable);
}

Step 2: Run it — expect FAIL (SupportsOnlineDiscovery doesn't compile yet): dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests --filter "FullyQualifiedName~TagDiscoveryDefaultsTests"

Step 3: Implement — add to ITagDiscovery directly below the RediscoverPolicy member (ITagDiscovery.cs:32):

    /// <summary>
    ///     True when <see cref="DiscoverAsync"/> enumerates the tag set from the live backend
    ///     (browsable by the universal discovery browser), rather than replaying
    ///     pre-declared/authored tags. Default false — flat-address drivers stay manual-entry.
    ///     See docs/plans/2026-07-15-universal-discovery-browser-design.md §5.
    /// </summary>
    bool SupportsOnlineDiscovery => false;

Step 4: Run test — expect PASS.

Step 5: (Orchestrator commits after Batch A.)


Task 2: Commons project references

Classification: trivial Estimated implement time: ~2 min Parallelizable with: Task 1, Task 13

Files:

  • Modify: src/Core/ZB.MOM.WW.OtOpcUa.Commons/ZB.MOM.WW.OtOpcUa.Commons.csproj

Step 1: Add to the existing <ItemGroup> (Commons currently has NO project references — Core.Abstractions is a leaf with zero ProjectReferences, so this is cycle-free; verified 2026-07-15):

    <ItemGroup>
        <ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
    </ItemGroup>

Also add <PackageReference Include="Microsoft.Extensions.Logging.Abstractions"/> only if dotnet build src/Core/ZB.MOM.WW.OtOpcUa.Commons fails on ILogger<> in Task 8 (Akka may already flow it transitively; the version is centrally pinned in Directory.Packages.props — do NOT add a Version= attribute).

Step 2: dotnet build src/Core/ZB.MOM.WW.OtOpcUa.Commons — expect success.


Task 3: AbCip opt-in (SupportsOnlineDiscovery => true)

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

Files:

  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
  • Test: the existing AbCip unit-test project under tests/Drivers/ (add one fact to an existing test class, or a new AbCipDiscoveryGateTests.cs following that project's driver-construction pattern)

Step 1: Failing test — construct the driver the way the project's existing tests do (fakes, no network) and assert:

((ITagDiscovery)driver).SupportsOnlineDiscovery.ShouldBeTrue();

Step 2: Run → FAIL (default false). Step 3: In AbCipDriver, next to its existing RediscoverPolicy override (or with the other ITagDiscovery members), add:

    /// <summary>Controller enumeration is real (CIP Symbol Object walk) — config-gated on
    /// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time.</summary>
    public bool SupportsOnlineDiscovery => true;

Step 4: Run → PASS. Also run the full AbCip unit suite for the project you touched.


Task 4: TwinCAT opt-in

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

Files:

  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
  • Test: existing TwinCAT unit-test project under tests/Drivers/

Same shape as Task 3 (ADS symbol upload behind EnableControllerBrowse; patch guarantees it).


Task 5: FOCAS opt-in

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

Files:

  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
  • Test: existing FOCAS unit-test project under tests/Drivers/

Same shape as Task 3. Comment must note FOCAS is the UntilStable driver — its FixedTree fills post-connect; the universal browser's settle loop (Task 9) handles it; the FixedTree.Enabled patch turns it on.


Task 6: CapturingAddressSpaceBuilder + captured-tree model

Classification: standard Estimated implement time: ~5 min Parallelizable with: none (Batch C)

Files:

  • Create: src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/CapturedTree.cs
  • Create: src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/CapturingAddressSpaceBuilder.cs
  • Test: tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/CapturingAddressSpaceBuilderTests.cs (create)

Step 1: Failing tests — a fake ITagDiscovery streams a known graph; assert tree shape, ids, cap, alarm mark:

using Shouldly;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using Xunit;

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

public class CapturingAddressSpaceBuilderTests
{
    private static DriverAttributeInfo Attr(string fullName, bool isAlarm = false) =>
        new(fullName, DriverDataType.Float64, false, null, SecurityClassification.Operate, false, IsAlarm: isAlarm);

    [Fact]
    public void CapturesNestedFoldersAndLeaves()
    {
        var b = new CapturingAddressSpaceBuilder(nodeCap: 100);
        var plc = b.Folder("Plc1", "PLC 1");
        plc.Variable("Speed", "Speed", Attr("Program:Main.Speed"));
        var udt = plc.Folder("Motor", "Motor");
        udt.Variable("Amps", "Amps", Attr("Program:Main.Motor.Amps"));

        var tree = b.Build();
        tree.Truncated.ShouldBeFalse();
        tree.Root.Children.Count.ShouldBe(1);
        var plcNode = tree.Root.Children[0];
        plcNode.Id.ShouldBe("/Plc1");
        plcNode.IsLeaf.ShouldBeFalse();
        plcNode.Children.Select(c => c.Id).ShouldBe(new[] { "Program:Main.Speed", "/Plc1/Motor" });
        tree.ById["Program:Main.Motor.Amps"].Attribute!.FullName.ShouldBe("Program:Main.Motor.Amps");
    }

    [Fact]
    public void LeafNodeIdIsTheDriverFullName()
    {
        var b = new CapturingAddressSpaceBuilder(100);
        var handle = b.Variable("T", "T", Attr("Device.Tag1"));
        handle.FullReference.ShouldBe("Device.Tag1");
        b.Build().ById["Device.Tag1"].IsLeaf.ShouldBeTrue();
    }

    [Fact]
    public void NodeCapTruncatesInsteadOfGrowing()
    {
        var b = new CapturingAddressSpaceBuilder(nodeCap: 3);
        for (var i = 0; i < 10; i++) b.Variable($"T{i}", $"T{i}", Attr($"Dev.T{i}"));
        var tree = b.Build();
        tree.Truncated.ShouldBeTrue();
        tree.Count.ShouldBe(3);
    }

    [Fact]
    public void MarkAsAlarmConditionRecordsAlarmAndReturnsNoopSink()
    {
        var b = new CapturingAddressSpaceBuilder(100);
        var h = b.Variable("A", "A", Attr("Dev.Alarm1"));
        var sink = h.MarkAsAlarmCondition(new AlarmConditionInfo("A", AlarmSeverity.High, null));
        sink.ShouldNotBeNull();
        Should.NotThrow(() => sink.OnTransition(null!)); // no-op — browse never delivers transitions
        b.Build().ById["Dev.Alarm1"].IsAlarmMarked.ShouldBeTrue();
    }

    [Fact]
    public void AddPropertyAttachesToCurrentScope()
    {
        var b = new CapturingAddressSpaceBuilder(100);
        var f = b.Folder("Dev", "Dev");
        f.AddProperty("Model", DriverDataType.String, "1756-L83E");
        b.Build().ById["/Dev"].Properties.ShouldContain(p => p.Name == "Model" && (string?)p.Value == "1756-L83E");
    }
}

(If AlarmSeverity.High doesn't exist, use any real member of AlarmSeverity — check src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/.)

Step 2: Run → FAIL (types don't exist).

Step 3: Implement. CapturedTree.cs:

using ZB.MOM.WW.OtOpcUa.Core.Abstractions;

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

/// <summary>One node captured from a driver's DiscoverAsync stream. Folder ids are
/// path-composed ("/parent/child"); leaf ids are the driver FullName (the commit value).</summary>
public sealed class CapturedNode
{
    public required string Id { get; init; }
    public required string Name { get; init; }          // browse name
    public required string DisplayName { get; init; }
    public required bool IsLeaf { get; init; }
    public DriverAttributeInfo? Attribute { get; init; } // leaf only
    public bool IsAlarmMarked { get; set; }
    public List<CapturedNode> Children { get; } = [];
    public List<(string Name, DriverDataType Type, object? Value)> Properties { get; } = [];
}

/// <summary>Immutable-after-Build snapshot of a whole discovery capture. May be shared by
/// multiple coalesced browse sessions — sessions drop their reference on dispose, never mutate.</summary>
public sealed class CapturedTree
{
    public required CapturedNode Root { get; init; }
    public required bool Truncated { get; init; }
    public required int Count { get; init; }
    public required IReadOnlyDictionary<string, CapturedNode> ById { get; init; }
}

CapturingAddressSpaceBuilder.cs:

using ZB.MOM.WW.OtOpcUa.Core.Abstractions;

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

/// <summary>
///     In-memory <see cref="IAddressSpaceBuilder"/> that records the streamed discovery tree
///     for the universal browser instead of materializing OPC UA nodes. Single-threaded like
///     every builder a driver streams into during DiscoverAsync. Enforces a node cap: once
///     exceeded it stops recording (drivers keep streaming harmlessly) and marks the tree
///     truncated. See docs/plans/2026-07-15-universal-discovery-browser-design.md §4.1.
/// </summary>
public sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder
{
    private sealed class CaptureState(int nodeCap)
    {
        public int Count;
        public bool Truncated;
        public readonly int NodeCap = nodeCap;
        public readonly Dictionary<string, CapturedNode> ById = new(StringComparer.Ordinal);
        public bool TryReserve()
        {
            if (Count >= NodeCap) { Truncated = true; return false; }
            Count++;
            return true;
        }
    }

    private readonly CaptureState _state;
    private readonly CapturedNode _scope;

    public CapturingAddressSpaceBuilder(int nodeCap = 50_000)
    {
        _state = new CaptureState(nodeCap);
        _scope = new CapturedNode { Id = "", Name = "", DisplayName = "", IsLeaf = false };
    }

    private CapturingAddressSpaceBuilder(CaptureState state, CapturedNode scope)
    {
        _state = state;
        _scope = scope;
    }

    public IAddressSpaceBuilder Folder(string browseName, string displayName)
    {
        var node = new CapturedNode
        {
            Id = _scope.Id + "/" + browseName,
            Name = browseName,
            DisplayName = displayName,
            IsLeaf = false,
        };
        if (_state.TryReserve())
        {
            _scope.Children.Add(node);
            _state.ById.TryAdd(node.Id, node);
        }
        // Over cap: the child builder writes into a detached node — recording stops, streaming doesn't break.
        return new CapturingAddressSpaceBuilder(_state, node);
    }

    public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
    {
        var node = new CapturedNode
        {
            Id = attributeInfo.FullName,
            Name = browseName,
            DisplayName = displayName,
            IsLeaf = true,
            Attribute = attributeInfo,
        };
        if (_state.TryReserve())
        {
            _scope.Children.Add(node);
            _state.ById.TryAdd(node.Id, node);
        }
        return new CapturedVariableHandle(node);
    }

    public void AddProperty(string browseName, DriverDataType dataType, object? value) =>
        _scope.Properties.Add((browseName, dataType, value));

    public CapturedTree Build() => new()
    {
        Root = _scope,
        Truncated = _state.Truncated,
        Count = _state.Count,
        ById = _state.ById,
    };

    private sealed class CapturedVariableHandle(CapturedNode node) : IVariableHandle
    {
        public string FullReference => node.Id;
        public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
        {
            node.IsAlarmMarked = true;
            return NoopAlarmSink.Instance;
        }
    }

    private sealed class NoopAlarmSink : IAlarmConditionSink
    {
        public static readonly NoopAlarmSink Instance = new();
        public void OnTransition(AlarmEventArgs args) { } // browse never delivers live transitions
    }
}

Build() is only valid on the root builder (the one the browser constructed) — acceptable; the browser owns it.

Step 4: Run the test class → PASS. Step 5: dotnet build ZB.MOM.WW.OtOpcUa.slnx.


Task 7: CapturedTreeBrowseSession

Classification: standard Estimated implement time: ~4 min Parallelizable with: none (Batch C, after Task 6)

Files:

  • Create: src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/CapturedTreeBrowseSession.cs
  • Test: tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/CapturedTreeBrowseSessionTests.cs (create)

Step 1: Failing tests:

public class CapturedTreeBrowseSessionTests
{
    private static CapturedTree SampleTree(bool truncated = false) { /* build via CapturingAddressSpaceBuilder like Task 6, pass truncated via tiny cap when true */ }

    [Fact] public async Task RootReturnsTopLevelNodes() { /* RootAsync → BrowseNode list; folder Kind=Folder w/ HasChildrenHint, leaf Kind=Leaf, leaf NodeId == FullName */ }
    [Fact] public async Task ExpandReturnsCapturedChildren() { }
    [Fact] public async Task ExpandUnknownNodeReturnsEmpty() { }
    [Fact] public async Task AttributesForLeafMapsDriverAttributeInfo() { /* Name/DriverDataType.ToString()/IsArray/SecurityClass.ToString()/IsAlarm (incl. IsAlarmMarked OR attr.IsAlarm) */ }
    [Fact] public async Task TruncatedTreeAppendsMarkerFolderAtRoot() { /* last root node: Kind=Folder, HasChildrenHint=false, DisplayName contains "truncated" */ }
    [Fact] public async Task CallsRefreshLastUsedUtc() { }
    [Fact] public async Task DisposedSessionThrowsObjectDisposed() { }
    [Fact] public async Task TwoSessionsOverSameTreeAreIndependent() { /* dispose one; the other still serves — shared immutable tree, §4.3 */ }
}

Step 2: FAIL. Step 3: Implement:

using ZB.MOM.WW.OtOpcUa.Core.Abstractions;

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

/// <summary>
///     Serves a captured discovery snapshot through the standard picker session interface.
///     No I/O after open; the tree is point-in-time (picker offers Refresh = close + re-open).
///     The tree may be shared by coalesced sessions — dispose drops only this session's
///     reference. See design §4.3.
/// </summary>
public sealed class CapturedTreeBrowseSession(CapturedTree tree) : IBrowseSession
{
    private CapturedTree? _tree = tree;

    public Guid Token { get; } = Guid.NewGuid();
    public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow;

    public Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken ct)
    {
        var t = Tree();
        var nodes = t.Root.Children.Select(Map).ToList();
        if (t.Truncated)
            nodes.Add(new BrowseNode("__truncated__",
                "⚠ Results truncated — narrow the driver config or use manual entry",
                BrowseNodeKind.Folder, HasChildrenHint: false));
        LastUsedUtc = DateTime.UtcNow;
        return Task.FromResult<IReadOnlyList<BrowseNode>>(nodes);
    }

    public Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken ct)
    {
        var t = Tree();
        LastUsedUtc = DateTime.UtcNow;
        return Task.FromResult<IReadOnlyList<BrowseNode>>(
            t.ById.TryGetValue(nodeId, out var n) ? n.Children.Select(Map).ToList() : []);
    }

    public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken ct)
    {
        var t = Tree();
        LastUsedUtc = DateTime.UtcNow;
        if (!t.ById.TryGetValue(nodeId, out var n))
            return Task.FromResult<IReadOnlyList<AttributeInfo>>([]);
        var attrs = new List<AttributeInfo>();
        if (n.Attribute is { } a)
            attrs.Add(new AttributeInfo(n.Name, a.DriverDataType.ToString(), a.IsArray,
                a.SecurityClass.ToString(), a.IsAlarm || n.IsAlarmMarked));
        attrs.AddRange(n.Properties.Select(p =>
            new AttributeInfo(p.Name, p.Type.ToString(), false, "", false)));
        return Task.FromResult<IReadOnlyList<AttributeInfo>>(attrs);
    }

    public ValueTask DisposeAsync()
    {
        _tree = null; // drop this session's reference only — tree may be shared (coalescing)
        return ValueTask.CompletedTask;
    }

    private CapturedTree Tree() => _tree ?? throw new ObjectDisposedException(nameof(CapturedTreeBrowseSession));

    private static BrowseNode Map(CapturedNode n) => n.IsLeaf
        ? new BrowseNode(n.Id, n.DisplayName, BrowseNodeKind.Leaf, false)
        : new BrowseNode(n.Id, n.DisplayName, BrowseNodeKind.Folder, n.Children.Count > 0);
}

Step 4: PASS. Step 5: build the slnx.


Task 8: IUniversalDriverBrowser + DiscoveryDriverBrowser core open path

Classification: high-risk (driver lifecycle orchestration) Estimated implement time: ~5 min Parallelizable with: none (Batch D)

Files:

  • Create: src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/IUniversalDriverBrowser.cs
  • Create: src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/DiscoveryDriverBrowser.cs
  • Test: tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserTests.cs (create; includes the shared FakeDiscoveryDriver + FakeDriverFactory used by Tasks 910)

Step 1: Failing tests (the fake driver implements IDriver + ITagDiscovery with all IDriver members trivial — read src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs for the full member list; expose counters InitializeCalls/DiscoverCalls/ShutdownCalls and injectable Func<IAddressSpaceBuilder, Task> discover behavior):

  • OpenAsync_RunsInitializeDiscoverShutdown_InOrder
  • OpenAsync_ShutdownRuns_EvenWhenDiscoverThrows (and the discovery exception — not a shutdown error — surfaces)
  • OpenAsync_UnknownType_ThrowsCleanMessage (factory returns null)
  • OpenAsync_NotDiscoveryCapable_Throws / OpenAsync_FlagFalse_Throws
  • PatchForBrowse_MergesAbCipPatch (config {"Host":"1.2.3.4"} + AbCip ⇒ JSON with both Host and EnableControllerBrowse:true; unknown type ⇒ unchanged)
  • PatchForBrowse_DeepMergesFocasFixedTree (config {"FixedTree":{"Series":"30i"}} ⇒ keeps Series, adds Enabled:true)
  • CanBrowse_TrueForDiscoveryCapable / CanBrowse_FalseWhenTryCreateReturnsNull
  • CanBrowse_FalseWhenTryCreateThrows_NoExceptionEscapes (design §4.2 hardening)
  • CanBrowse_ShutsDownThrowawayInstance (fake counts ShutdownCalls even on the false path)
  • BoundedShutdown_AbandonsWedgedDriver (fake ShutdownAsync = Task.Delay(Timeout.Infinite, ct) ignoring nothing — but also a variant that ignores the token entirely; open still returns on the discovery outcome; use a short shutdownTimeout via the test-visible ctor)

Step 2: FAIL. Step 3: Implement.

IUniversalDriverBrowser.cs:

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

/// <summary>
///     Universal fallback browser: captures any discovery-capable driver's DiscoverAsync
///     output and serves it as a browse session. Resolved by BrowserSessionService only
///     when no bespoke IDriverBrowser matches the driver type (bespoke-first). A separate
///     interface — NOT an IDriverBrowser — because BrowserSessionService indexes those
///     ToDictionary-by-DriverType and the universal browser has no single type.
/// </summary>
public interface IUniversalDriverBrowser
{
    /// <summary>Cheap gate (construct + inspect, no connect): can this driver type + config be
    /// browsed? Never throws. Drives the picker's Browse-button visibility.</summary>
    bool CanBrowse(string driverType, string configJson);

    /// <summary>Construct the driver (with the per-type browse patch), connect, capture one
    /// full discovery pass (with UntilStable settle), shut the driver down (bounded), and
    /// return a session over the captured snapshot. Throws on failure — the caller
    /// (BrowserSessionService) converts to BrowseOpenResult.</summary>
    Task<IBrowseSession> OpenAsync(string driverType, string configJson, CancellationToken ct);
}

DiscoveryDriverBrowser.cs (Task 8 version — settle and coalescing arrive in Tasks 910):

using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;

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

/// <summary>See docs/plans/2026-07-15-universal-discovery-browser-design.md §4.2.</summary>
public sealed class DiscoveryDriverBrowser : IUniversalDriverBrowser
{
    /// <summary>Whole-open bound: construct + connect + discover (+ settle). Separate from the
    /// 20 s per-call browse timeout, which only covers serving from memory.</summary>
    public static readonly TimeSpan OpenTimeout = TimeSpan.FromSeconds(60);

    /// <summary>Bound on the cleanup ShutdownAsync (R2-01: no unbounded waits — a driver wedged
    /// in discovery may be equally wedged in shutdown).</summary>
    public static readonly TimeSpan ShutdownTimeout = TimeSpan.FromSeconds(10);

    public const int NodeCap = 50_000;

    /// <summary>Per-type declarative config patch guaranteeing browse mode at open time (§5.1).
    /// A dictionary entry per config-gated driver — not code.</summary>
    internal static readonly IReadOnlyDictionary<string, string> BrowsePatches =
        new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
        {
            ["AbCip"]   = """{ "EnableControllerBrowse": true }""",
            ["TwinCAT"] = """{ "EnableControllerBrowse": true }""",
            ["FOCAS"]   = """{ "FixedTree": { "Enabled": true } }""",
            // OpcUaClient/BACnet/MTConnect: no patch — their discovery is unconditional.
        };

    private readonly IDriverFactory _driverFactory;
    private readonly ILogger<DiscoveryDriverBrowser> _logger;
    private readonly TimeSpan _openTimeout;
    private readonly TimeSpan _shutdownTimeout;

    public DiscoveryDriverBrowser(IDriverFactory driverFactory, ILogger<DiscoveryDriverBrowser> logger)
        : this(driverFactory, logger, OpenTimeout, ShutdownTimeout) { }

    // Test seam: short timeouts.
    internal DiscoveryDriverBrowser(IDriverFactory driverFactory, ILogger<DiscoveryDriverBrowser> logger,
        TimeSpan openTimeout, TimeSpan shutdownTimeout)
    {
        _driverFactory = driverFactory;
        _logger = logger;
        _openTimeout = openTimeout;
        _shutdownTimeout = shutdownTimeout;
    }

    public bool CanBrowse(string driverType, string configJson)
    {
        IDriver? probe = null;
        try
        {
            probe = _driverFactory.TryCreate(driverType, $"browse-can-{Guid.NewGuid():N}",
                PatchForBrowse(driverType, configJson));
            return probe is ITagDiscovery { SupportsOnlineDiscovery: true };
        }
        catch
        {
            // TryCreate parses configJson and can throw on mid-authoring JSON. UI affordance
            // gate — must never throw into the page (§4.2).
            return false;
        }
        finally
        {
            if (probe is not null) _ = BoundedShutdownAsync(probe, driverType); // best-effort, never leak
        }
    }

    public async Task<IBrowseSession> OpenAsync(string driverType, string configJson, CancellationToken ct)
    {
        var tree = await CaptureAsync(driverType, configJson, ct).ConfigureAwait(false);
        return new CapturedTreeBrowseSession(tree);
    }

    private async Task<CapturedTree> CaptureAsync(string driverType, string configJson, CancellationToken ct)
    {
        var patched = PatchForBrowse(driverType, configJson);
        var driver = _driverFactory.TryCreate(driverType, $"browse-{Guid.NewGuid():N}", patched)
            ?? throw new InvalidOperationException(
                $"Driver type '{driverType}' is not available in this host.");
        try
        {
            if (driver is not ITagDiscovery disc || !disc.SupportsOnlineDiscovery)
                throw new InvalidOperationException(
                    $"Driver type '{driverType}' has no online discovery — author tags manually.");

            using var openCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
            openCts.CancelAfter(_openTimeout);

            await driver.InitializeAsync(patched, openCts.Token).ConfigureAwait(false);
            return await DiscoverTreeAsync(disc, openCts.Token).ConfigureAwait(false);
        }
        finally
        {
            await BoundedShutdownAsync(driver, driverType).ConfigureAwait(false);
        }
    }

    // Task 9 replaces this with the UntilStable settle version.
    private static async Task<CapturedTree> DiscoverTreeAsync(ITagDiscovery disc, CancellationToken ct)
    {
        var builder = new CapturingAddressSpaceBuilder(NodeCap);
        await disc.DiscoverAsync(builder, ct).ConfigureAwait(false);
        return builder.Build();
    }

    private async Task BoundedShutdownAsync(IDriver driver, string driverType)
    {
        try
        {
            using var cts = new CancellationTokenSource(_shutdownTimeout);
            // WaitAsync: a token-ignoring wedged driver is ABANDONED (dropped), never awaited forever.
            await driver.ShutdownAsync(cts.Token).WaitAsync(cts.Token).ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex,
                "Browse-capture shutdown for {DriverType} failed/timed out; instance abandoned", driverType);
        }
    }

    internal static string PatchForBrowse(string driverType, string configJson)
    {
        if (!BrowsePatches.TryGetValue(driverType, out var patch)) return configJson;
        var config = JsonNode.Parse(string.IsNullOrWhiteSpace(configJson) ? "{}" : configJson)
            ?.AsObject() ?? new JsonObject();
        Merge(config, JsonNode.Parse(patch)!.AsObject());
        return config.ToJsonString();
    }

    private static void Merge(JsonObject target, JsonObject patch)
    {
        foreach (var (key, value) in patch)
        {
            if (value is JsonObject po && target[key] is JsonObject to) Merge(to, po);
            else target[key] = value?.DeepClone();
        }
    }
}

Step 4: PASS + slnx build. (If ILogger<> fails to resolve in Commons, apply Task 2's conditional PackageReference now.)


Task 9: UntilStable settle loop (FOCAS)

Classification: standard Estimated implement time: ~4 min Parallelizable with: Task 11

Files:

  • Modify: src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/DiscoveryDriverBrowser.cs
  • Test: tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserSettleTests.cs (create)

Why: FOCAS's FixedTree cache fills a couple of seconds after connect (FocasDriver.cs — comment above DiscoverAsync, background FixedTreeLoopAsync). A one-shot capture right after Initialize returns an empty tree. Design §4.2 step 4.

Step 1: Failing tests — fake driver with RediscoverPolicy = UntilStable whose discover streams nothing on pass 1, three nodes on passes 2+:

  • UntilStable_RerunsUntilNonEmptyAndStable (final tree has 3 nodes; ≥3 discover calls: empty→3→3-stable)
  • UntilStable_NeverStable_ReturnsLastNonEmptyAtTimeout (node count grows every pass; short openTimeout via internal ctor; returns the last capture + logs — NOT an exception)
  • UntilStable_NeverAnyNodes_FailsAtOpenTimeout (all passes empty ⇒ OperationCanceledException/timeout error surfaces)
  • OncePolicy_SingleDiscoverPass (fake with Once: exactly 1 discover call)

Step 2: FAIL. Step 3: Replace DiscoverTreeAsync with an instance method:

    private static readonly TimeSpan SettleInterval = TimeSpan.FromSeconds(1);

    private async Task<CapturedTree> DiscoverTreeAsync(ITagDiscovery disc, string driverType, CancellationToken ct)
    {
        if (disc.RediscoverPolicy != DiscoveryRediscoverPolicy.UntilStable)
        {
            var builder = new CapturingAddressSpaceBuilder(NodeCap);
            await disc.DiscoverAsync(builder, ct).ConfigureAwait(false);
            return builder.Build();
        }

        // UntilStable (FOCAS): the discovered shape fills in asynchronously after connect —
        // re-capture until non-empty and stable across two consecutive passes, bounded by the
        // open-timeout. Mirrors the deploy-time contract in DriverInstanceActor.
        CapturedTree? previous = null;
        while (true)
        {
            var builder = new CapturingAddressSpaceBuilder(NodeCap);
            try
            {
                await disc.DiscoverAsync(builder, ct).ConfigureAwait(false);
                var current = builder.Build();
                if (current.Count > 0 && previous?.Count == current.Count) return current;
                previous = current;
                await Task.Delay(SettleInterval, ct).ConfigureAwait(false);
            }
            catch (OperationCanceledException) when (previous is { Count: > 0 })
            {
                _logger.LogWarning(
                    "UntilStable settle for {DriverType} hit the open-timeout before stabilising; returning the last capture ({Count} nodes)",
                    driverType, previous.Count);
                return previous; // best effort: a fresh-but-unsettled tree beats a failed open
            }
        }
    }

Update the call site to pass driverType. Step 4: PASS.


Task 10: Capture coalescing + MaxConcurrentCaptures cap

Classification: high-risk (concurrency) Estimated implement time: ~5 min Parallelizable with: Task 12

Files:

  • Modify: src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/DiscoveryDriverBrowser.cs
  • Test: tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserConcurrencyTests.cs (create)

Step 1: Failing tests (gate fake drivers on TaskCompletionSource signals — no Task.Delay races):

  • SameKey_TwoConcurrentOpens_OneCapture_DistinctTokens (fake counts Initialize calls == 1; two sessions, different Tokens; both serve the same tree content)
  • SameKey_DisposingOneSession_LeavesOtherBrowsable
  • SameKey_FailedCapture_FailsBothAwaiters (same exception surfaces to both)
  • SameKey_OpenAfterCompletion_RunsFreshCapture (in-flight-only map — Initialize calls == 2; this is the Refresh path)
  • DistinctKeys_CapLimitsInflightCaptures (6 opens on 6 distinct keys, MaxConcurrentCaptures = 4: assert ≤4 fakes have entered Initialize while gated; release → all 6 complete)
  • QueuedOpen_ObservesCallerCancellation (a queued caller's ct cancels → its open throws OCE; the in-flight captures are unaffected)
  • CoalescedAwaiter_CancellationDoesNotCancelSharedCapture (first caller cancels; second caller still gets the tree)

Step 2: FAIL. Step 3: Implement — add to DiscoveryDriverBrowser:

    /// <summary>Global cap on simultaneous captures (§4.2). Coalesced awaiters don't hold a slot.</summary>
    public const int MaxConcurrentCaptures = 4;

    private readonly ConcurrentDictionary<string, Lazy<Task<CapturedTree>>> _inflight = new();
    private readonly SemaphoreSlim _captureSlots = new(MaxConcurrentCaptures, MaxConcurrentCaptures);

Rewrite OpenAsync:

    public async Task<IBrowseSession> OpenAsync(string driverType, string configJson, CancellationToken ct)
    {
        var key = CaptureKey(driverType, configJson);
        // Lazy: exactly one RunCaptureAsync per key even under GetOrAdd races.
        var capture = _inflight.GetOrAdd(key,
            _ => new Lazy<Task<CapturedTree>>(() => RunCaptureAsync(key, driverType, configJson)));
        // The shared capture runs independent of any single caller (its own OpenTimeout bounds it);
        // each caller — including a queued one — observes ITS OWN ct via WaitAsync.
        var tree = await capture.Value.WaitAsync(ct).ConfigureAwait(false);
        return new CapturedTreeBrowseSession(tree);
    }

    private async Task<CapturedTree> RunCaptureAsync(string key, string driverType, string configJson)
    {
        try
        {
            await _captureSlots.WaitAsync().ConfigureAwait(false); // FIFO-ish queue for a slot
            try
            {
                return await CaptureAsync(driverType, configJson, CancellationToken.None).ConfigureAwait(false);
            }
            finally
            {
                _captureSlots.Release();
            }
        }
        finally
        {
            _inflight.TryRemove(key, out _); // in-flight only — no result caching (Refresh = fresh capture)
        }
    }

    internal static string CaptureKey(string driverType, string configJson) =>
        driverType.ToLowerInvariant() + ":" +
        Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(
            System.Text.Encoding.UTF8.GetBytes(configJson ?? "")));

(CaptureAsync gets CancellationToken.None deliberately: the capture is shared and internally bounded by _openTimeout; per-caller cancellation must not kill another caller's capture. If every caller abandons, the capture still completes and is dropped — bounded waste, by design.) Add using System.Collections.Concurrent;.

Step 4: PASS. Run the WHOLE Commons.Tests project (Tasks 610 together).


Task 11: BrowserSessionService universal fallback + CanBrowse on the service

Classification: standard Estimated implement time: ~4 min Parallelizable with: Task 9

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/IBrowserSessionService.cs
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ — extend/create BrowserSessionServiceTests.cs (follow the project's existing test style if a class exists)

Step 1: Failing tests:

  • Open_BespokeBrowserWinsOverUniversal (both available for the type ⇒ bespoke's session)
  • Open_FallsBackToUniversal_WhenNoBespokeAndCanBrowse (fake IUniversalDriverBrowser)
  • Open_NoBespokeAndCannotBrowse_ReturnsNoBrowserMessage (existing message text unchanged)
  • Open_UniversalThrow_ReturnsOkFalseWithMessage (never throws out)
  • CanBrowse_TrueForBespokeType_TrueForUniversalType_FalseOtherwise
  • Existing tests still green (no ToDictionary change).

Step 2: FAIL. Step 3: Implement.

IBrowserSessionService — add:

    /// <summary>True when a browse affordance exists for this driver type: a bespoke browser
    /// is registered, or the universal browser can serve it. Cheap (no connect); never throws.
    /// Drives the picker's Browse-button visibility.</summary>
    bool CanBrowse(string driverType, string configJson);

BrowserSessionService — add the ctor dependency (keep the primary-constructor style):

public sealed class BrowserSessionService(
    IEnumerable<IDriverBrowser> browsers,
    BrowseSessionRegistry registry,
    ILogger<BrowserSessionService> logger,
    IUniversalDriverBrowser universalBrowser) : IBrowserSessionService

Replace the OpenAsync miss branch (currently BrowserSessionService.cs:27-28):

        if (!_browsersByType.TryGetValue(driverType, out var browser))
            return await OpenUniversalAsync(driverType, configJson, ct).ConfigureAwait(false);

And add:

    public bool CanBrowse(string driverType, string configJson) =>
        _browsersByType.ContainsKey(driverType) || universalBrowser.CanBrowse(driverType, configJson);

    private async Task<BrowseOpenResult> OpenUniversalAsync(string driverType, string configJson, CancellationToken ct)
    {
        if (!universalBrowser.CanBrowse(driverType, configJson))
            return new(false, $"No browser registered for driver type '{driverType}'.", Guid.Empty);
        try
        {
            var session = await universalBrowser.OpenAsync(driverType, configJson, ct).ConfigureAwait(false);
            registry.Register(session);
            return new(true, null, session.Token);
        }
        catch (Exception ex)
        {
            logger.LogInformation(ex,
                "Universal browse open failed for driverType={DriverType}: {Message}", driverType, ex.Message);
            return new(false, ex.Message, Guid.Empty);
        }
    }

Step 4: PASS: dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.


Task 12: DI registration in AddAdminUI

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

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs

Step 1: In AddAdminUI, after the two bespoke IDriverBrowser lines (EndpointRouteBuilderExtensions.cs:49-50), add:

        // Universal Discover-backed fallback browser (Wave 0). IDriverFactory is bound by the
        // fused Host's DriverFactoryBootstrap; a standalone AdminUI has none → NullDriverFactory
        // → CanBrowse false → pickers gracefully stay manual-entry (design §6).
        services.AddSingleton<IUniversalDriverBrowser>(sp => new DiscoveryDriverBrowser(
            sp.GetService<ZB.MOM.WW.OtOpcUa.Core.Abstractions.IDriverFactory>() ?? ZB.MOM.WW.OtOpcUa.Core.Abstractions.NullDriverFactory.Instance,
            sp.GetRequiredService<Microsoft.Extensions.Logging.ILogger<DiscoveryDriverBrowser>>()));

(Hoist the namespaces into usings per file style.)

Step 2: dotnet build ZB.MOM.WW.OtOpcUa.slnx — success. The Host smoke: dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests still green.


Task 13: TagModal → editor parameter plumbing (DriverType + GetDriverConfigJson)

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 1, Task 2

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor (only BuildEditorParameters, ~line 352)
  • Modify: all 7 editors in src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/ (AbCipTagConfigEditor.razor, TwinCATTagConfigEditor.razor, FocasTagConfigEditor.razor, ModbusTagConfigEditor.razor, S7TagConfigEditor.razor, AbLegacyTagConfigEditor.razor, OpcUaClientTagConfigEditor.razor)

Why: typed editors today receive only ConfigJson/ConfigJsonChanged — the browse-capable picker bodies need the selected driver's type + DriverConfig JSON to open a session. DynamicComponent throws on unmatched parameters, so every editor must declare the new params (unused ones just ignore them).

Step 1: TagModal.razor BuildEditorParameters() becomes:

    private IDictionary<string, object> BuildEditorParameters() => new Dictionary<string, object>
    {
        ["ConfigJson"] = _form.TagConfig,
        ["ConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v => _form.TagConfig = v),
        ["DriverType"] = SelectedDriverType ?? "",
        ["GetDriverConfigJson"] = (Func<string>)(() => SelectedDriverConfig),
    };

Step 2: Each of the 7 editors adds (next to the existing ConfigJson parameters):

    /// <summary>DriverType of the selected driver — lets browse-capable pickers open a universal session.</summary>
    [Parameter] public string DriverType { get; set; } = "";
    /// <summary>Live accessor for the selected driver's DriverConfig JSON (browse connect config).</summary>
    [Parameter] public Func<string> GetDriverConfigJson { get; set; } = () => "{}";

Step 3: dotnet build ZB.MOM.WW.OtOpcUa.slnx — success. (No unit test — AdminUI has no bUnit; Task 18 live-verifies.)


Task 14: AbCip picker body — universal browse mode

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 15, Task 16

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/AbCipAddressPickerBody.razor
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbCipTagConfigEditor.razor (pass-through only: add DriverType="@DriverType" GetConfigJson="@GetDriverConfigJson" to the <AbCipAddressPickerBody …> element, ~line 36)

Step 1: Rework AbCipAddressPickerBody.razor following the exact pattern of OpcUaClientAddressPickerBody.razor (same directory — read it first; it is the house template: IBrowserSessionService + AuthenticationStateProvider/IAuthorizationService DriverOperator gate + DriverBrowseTree + fire-and-forget close in DisposeAsync). Keep the existing manual tag-name/element-index builder markup unchanged at the top; append the browse affordance below it:

  • New params: [Parameter] public string DriverType { get; set; } = "AbCip"; and [Parameter] public Func<string> GetConfigJson { get; set; } = () => "{}";
  • Browse-button visibility: _canOperate && BrowserService.CanBrowse(DriverType, GetConfigJson()) — evaluate once in OnInitializedAsync into _canBrowse (CanBrowse constructs a throwaway driver; don't call it per-render).
  • Open: await BrowserService.OpenAsync(DriverType, GetConfigJson(), default) — NOT a hardcoded type string.
  • While open, show two buttons: Close and Refresh (Refresh = await CloseBrowseAsync(); await OpenBrowseAsync(); — a fresh capture per design §4.3). Label the tree header "snapshot taken at open/refresh time".
  • OnTreeSelectAsync(BrowseNode node): only when node.Kind == BrowseNodeKind.Leaf (check DriverBrowseTree's callback contract — if it already filters to leaves, match it): set _tagName = node.NodeId (the captured leaf NodeId IS the driver FullName / CIP tag path), clear _useIndex, and fire the existing OnChangedAsync().

Step 2: dotnet build ZB.MOM.WW.OtOpcUa.slnx. Step 3: visual sanity deferred to Task 18 (live gate).


Task 15: TwinCAT picker body — universal browse mode

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 14, Task 16

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/TwinCATAddressPickerBody.razor
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/TwinCATTagConfigEditor.razor (pass-through)

Identical shape to Task 14 with DriverType = "TwinCAT" (confirm the exact registered DriverType string via grep -rn "DriverTypeName" src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ — use what Register() uses, casing matters only for display since lookups are OrdinalIgnoreCase). Tree leaf NodeId = ADS symbol path → the editor's address field.


Task 16: FOCAS picker body — universal browse mode

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 14, Task 15

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/FOCASAddressPickerBody.razor
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/FocasTagConfigEditor.razor (pass-through)

Identical shape (confirm DriverType string from the FOCAS Register()). Note in a comment: FOCAS opens take a few seconds longer (UntilStable settle, Task 9) — the existing _opening spinner covers it.


Task 17: Full sweep + doc status flip

Classification: small Estimated implement time: ~4 min Parallelizable with: none

Files:

  • Modify: docs/plans/2026-07-15-universal-discovery-browser-design.md (header only: **Status:** draft**Status:** P1 implemented <date>; P2/P3 pending)
  • Modify: docs/plans/2026-07-15-driver-expansion-program-design.md (§9 Status: note Wave 0 code landed)

Step 1: dotnet build ZB.MOM.WW.OtOpcUa.slnx — zero warnings-as-errors failures. Step 2: dotnet test ZB.MOM.WW.OtOpcUa.slnx — full suite. Known pre-existing failures are possible in unrelated suites; compare against a master baseline run if anything unrelated is red — do not chase unrelated rot, report it. DB-backed suites need the central SQL Server reachable (10.100.0.35,14330); integration suites (*.IntegrationTests) need their fixtures — if offline, scope to: Core.Abstractions.Tests, Commons.Tests, AdminUI.Tests, plus the three driver unit projects touched. Step 3: Doc edits. Step 4: Commit.


Task 18: Live /run verify on docker-dev (GATE — do not skip)

Classification: high-risk (the repo's Razor-inertness trap: binding bugs pass unit tests + review) Estimated implement time: ~15 min wall (mostly container builds) Parallelizable with: none

Files: none (verification only)

Step 1: Bring the AbCip fixture up on the docker host: lmxopcua-fix up abcip controllogix (helper in ~/bin; the ControlLogix sim listens on 10.100.0.35:44818). Step 2: Rebuild BOTH docker-dev centrals (:9200 round-robins central-1/central-2 — rebuilding one gives flapping results; durable repo fact): docker compose -f docker-dev/docker-compose.yml up -d --build (or the repo's documented docker-dev rebuild recipe — check docker-dev/README / docs/v2/dev-environment.md). Step 3: AdminUI at http://localhost:9200 (login DISABLED — auto-admin; do this yourself, do not defer to the user). Using the Chrome browser tools (or playwright-skill):

  1. Create/locate an AbCip driver whose DriverConfig points at 10.100.0.35:44818 with a device entry (follow tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests fixture config for the exact device shape). Leave EnableControllerBrowse absent — the browse patch must supply it.
  2. Equipment page → Tags tab → Add tag → select the AbCip driver → typed editor → open the address picker.
  3. Assert: Browse button visible (CanBrowse=true) → click → tree of controller tags renders → Refresh works → pick a leaf → the address field holds the tag path → save the tag → TagConfig JSON contains it.
  4. Negative: select a Modbus driver → its picker shows NO Browse button (SupportsOnlineDiscovery=false ⇒ CanBrowse=false). Step 4: If any leg fails: debug, fix, re-run. This gate finding a dead binding is EXPECTED value, not failure of the plan. Step 5: Record the result (screenshots/notes) in the final report; commit any fixes.

Live-gate result (Task 18 — docker-dev, 2026-07-15)

Executed against the local docker-dev rig (both centrals rebuilt from feat/universal-discovery-browser) with the ControlLogix ab_server fixture up on 10.100.0.35:44818. An AbCip driver (device ab://10.100.0.35:44818/1,0, EnableControllerBrowse deliberately left off) was authored under an Equipment-kind namespace and bound to an equipment; a tag was added on the Tags-tab TagModal.

  • Positive binding — the AbCip address picker renders the "Browse controller" button. This exercises the whole CanBrowse chain end-to-end: SupportsOnlineDiscovery=trueBrowserSessionService.CanBrowseDiscoveryDriverBrowser.CanBrowse (constructs a throwaway driver with PatchForBrowse) → TagModal DriverType/GetDriverConfigJson params → AbCipTagConfigEditor pass-through → AbCipAddressPickerBody._canBrowse.
  • Browse invocation runs in production — clicking Browse drove the full capture path; central-2 logged BrowserSessionService.OpenUniversalAsync → DiscoveryDriverBrowser.OpenAsync → RunCaptureAsync → CaptureAsync → DiscoverTreeAsync. Because the driver's authored config did not set EnableControllerBrowse yet the capture attempted the controller @tags walk (which only runs when that flag is true), PatchForBrowse is demonstrably applied end-to-end. The error surfaced as a graceful UI chip — no dead binding, no crash, no hang; bounded shutdown ran.
  • Negative binding — the Modbus picker shows only the manual Register/Offset/Length builder, no Browse button (SupportsOnlineDiscovery false; picker body unchanged).
  • ⚠️ Not verifiable on this fixture — a populated tree render + leaf-commit. The libplctag ab_server test sim returns ErrorUnsupported for controller symbol enumeration (it implements tag read/write, not the @tags Symbol Object walk). This is a fixture limitation, not a code defect — the binding is proven alive.

Follow-ups discovered during the gate

  1. Full tree-render verification is fixture-blocked. ab_server doesn't implement the @tags controller walk. To live-verify a populated tree + TagConfig.FullName leaf commit, point the universal browser at a symbol-enumeration-capable AB backend (a real ControlLogix, or a sim that serves @tags).
  2. Driver-page address pickers pass no live config. AbCipDriverPage/TwinCATDriverPage/FOCASDriverPage host the same *AddressPickerBody but do not pass DriverType/GetConfigJson, so their (now-visible) Browse button would capture against "{}" (no device). Out of Task 14's scope (which listed only the TagEditor pass-through); wire the live form config through there too, or hide the Browse affordance on the driver-config picker.

Out of scope (explicitly)

  • TwinCAT/FOCAS live-fixture verification (no shipped fixtures assumed reachable; AbCip is the gate).
  • The fixture-gated integration test (design §9) — follow-up once the AbCip fixture harness pattern is settled.
  • v3 Raw-tree re-targeting (design §11) — v3 consumes this unchanged.
  • Bespoke lazy browsers (P3), MTConnect/BACnet opt-ins (P2 — ride their driver builds).