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.
54 KiB
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)
- Work on a feature branch:
feat/universal-discovery-browser(Task 0 creates it). Never commit to master. - Safe parallelization. Tasks marked
Parallelizable with:have disjointFiles:lists and may be dispatched as concurrent subagents. Hard rules:- Parallel subagents MUST NOT run any git command (no add/commit/status). Parallel
git indexaccess 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.
- Parallel subagents MUST NOT run any git command (no add/commit/status). Parallel
- 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)
- Verification gates: run
dotnet build ZB.MOM.WW.OtOpcUa.slnxafter every batch; run the named test project after every test-bearing task. Batch H's Task 18 is a live/rungate — AdminUI Razor changes are NOT trusted on unit tests + review alone (repo rule: no bUnit; live-verify or it didn't happen). - 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). - 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 newAbCipDiscoveryGateTests.csfollowing 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 sharedFakeDiscoveryDriver+FakeDriverFactoryused by Tasks 9–10)
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_InOrderOpenAsync_ShutdownRuns_EvenWhenDiscoverThrows(and the discovery exception — not a shutdown error — surfaces)OpenAsync_UnknownType_ThrowsCleanMessage(factory returns null)OpenAsync_NotDiscoveryCapable_Throws/OpenAsync_FlagFalse_ThrowsPatchForBrowse_MergesAbCipPatch(config{"Host":"1.2.3.4"}+ AbCip ⇒ JSON with bothHostandEnableControllerBrowse:true; unknown type ⇒ unchanged)PatchForBrowse_DeepMergesFocasFixedTree(config{"FixedTree":{"Series":"30i"}}⇒ keepsSeries, addsEnabled:true)CanBrowse_TrueForDiscoveryCapable/CanBrowse_FalseWhenTryCreateReturnsNullCanBrowse_FalseWhenTryCreateThrows_NoExceptionEscapes(design §4.2 hardening)CanBrowse_ShutsDownThrowawayInstance(fake counts ShutdownCalls even on the false path)BoundedShutdown_AbandonsWedgedDriver(fakeShutdownAsync=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 shortshutdownTimeoutvia 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 9–10):
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 withOnce: 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, differentTokens; both serve the same tree content)SameKey_DisposingOneSession_LeavesOtherBrowsableSameKey_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 6–10 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/createBrowserSessionServiceTests.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(fakeIUniversalDriverBrowser)Open_NoBespokeAndCannotBrowse_ReturnsNoBrowserMessage(existing message text unchanged)Open_UniversalThrow_ReturnsOkFalseWithMessage(never throws out)CanBrowse_TrueForBespokeType_TrueForUniversalType_FalseOtherwise- Existing tests still green (no
ToDictionarychange).
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(onlyBuildEditorParameters, ~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: addDriverType="@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 inOnInitializedAsyncinto_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:
CloseandRefresh(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 whennode.Kind == BrowseNodeKind.Leaf(checkDriverBrowseTree'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 existingOnChangedAsync().
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):
- Create/locate an AbCip driver whose DriverConfig points at
10.100.0.35:44818with a device entry (followtests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTestsfixture config for the exact device shape). LeaveEnableControllerBrowseabsent — the browse patch must supply it. - Equipment page → Tags tab → Add tag → select the AbCip driver → typed editor → open the address picker.
- 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.
- 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
CanBrowsechain end-to-end:SupportsOnlineDiscovery=true→BrowserSessionService.CanBrowse→DiscoveryDriverBrowser.CanBrowse(constructs a throwaway driver withPatchForBrowse) → TagModalDriverType/GetDriverConfigJsonparams →AbCipTagConfigEditorpass-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 setEnableControllerBrowseyet the capture attempted the controller@tagswalk (which only runs when that flag is true),PatchForBrowseis 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 (
SupportsOnlineDiscoveryfalse; picker body unchanged). - ⚠️ Not verifiable on this fixture — a populated tree render + leaf-commit. The libplctag
ab_servertest sim returnsErrorUnsupportedfor controller symbol enumeration (it implements tag read/write, not the@tagsSymbol Object walk). This is a fixture limitation, not a code defect — the binding is proven alive.
Follow-ups discovered during the gate
- Full tree-render verification is fixture-blocked.
ab_serverdoesn't implement the@tagscontroller walk. To live-verify a populated tree +TagConfig.FullNameleaf commit, point the universal browser at a symbol-enumeration-capable AB backend (a real ControlLogix, or a sim that serves@tags). - Driver-page address pickers pass no live config.
AbCipDriverPage/TwinCATDriverPage/FOCASDriverPagehost the same*AddressPickerBodybut do not passDriverType/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).