Files
lmxopcua/docs/plans/2026-07-15-universal-discovery-browser-implementation.md
T
Joseph Doherty 8af5742406 docs(raw): live /run gate PASSED for the raw-modal picker live config; records Gitea #539
All three legs pass against the patched AbCip fixture: the edit modal and the
manual-entry grid both browse the live nine-tag symbol table, and an unreachable
device degrades to the graceful error chip while the modal still saves.

The gate also found a defect unrelated to this change and filed it as #539: the
AbCip discovery path (LibplctagTagEnumerator, LibplctagTemplateReader) passes
libplctag the bare host, so an authored non-default EtherNet/IP port is dropped
and the browse reaches whatever answers on 44818 — #534's defect surviving in the
discovery half. Discovered because the first negative-leg endpoint differed from
the fixture only by port, which never reached the wire.
2026-08-05 04:32:38 -04:00

1174 lines
57 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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**
```csharp
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`):
```csharp
/// <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):
```xml
<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:
```csharp
((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:
```csharp
/// <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:
```csharp
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`:
```csharp
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`:
```csharp
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:**
```csharp
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:**
```csharp
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 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_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`:
```csharp
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):
```csharp
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:
```csharp
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 `Token`s; 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`:
```csharp
/// <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`:
```csharp
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/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:
```csharp
/// <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):
```csharp
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`):
```csharp
if (!_browsersByType.TryGetValue(driverType, out var browser))
return await OpenUniversalAsync(driverType, configJson, ct).ConfigureAwait(false);
```
And add:
```csharp
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:
```csharp
// 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 `using`s 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:
```csharp
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):
```csharp
/// <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=true` → `BrowserSessionService.CanBrowse`
→ `DiscoveryDriverBrowser.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.
**CLOSED 2026-08-01 — see the second gate below.**
## Second live-gate result (Gitea #468 — docker-dev, 2026-08-01)
The fixture gap was closed by teaching `ab_server` to serve the Symbol Object listing
(`tests/Drivers/…AbCip.IntegrationTests/Docker/patches/`), which was the option #468's own
definition of done offered alongside real hardware. All three criteria ran green against the
patched fixture on `10.100.0.35:44818`:
- ✅ **Populated controller-tag tree** — the AbCip browse rendered all nine seeded tags as leaves
under `AbCip → Device1 → Discovered`, in wire order: `Motor_Run`, `Line1_Count`, `Pump_1_Speed`,
`TestArray`, `TestString`, `TestSINT`, `TestBOOL`, `TestREAL`, `TestDINT`.
- ✅ **Refresh re-captures** — Refresh collapsed to the root and re-expansion returned the same
nine leaves from a fresh capture.
- ✅ **Leaf commit round-trip** — selecting `Pump_1_Speed` + `TestDINT` and committing wrote both
into the raw tree with the CIP tag path in `TagConfig` (`{"tagPath":"TestDINT",…}`) and saved.
**The gate earned its keep — it found a defect no unit test could have.** `CipSymbolObjectDecoder`
advanced to an even byte boundary after each symbol name, but Symbol Object entries are packed with
no padding. `Motor_Run` is 9 bytes, so under the old decoder the browse above would have shown
**`Motor_Run` alone** and silently dropped the other eight. Every existing multi-entry test passed,
because the test builder padded exactly as the decoder did — the pair agreed with each other and
neither agreed with a controller. Fixed, with the layout now pinned by a captured reply
(`CipSymbolObjectWireCaptureTests`) rather than by a restatement of the assumption.
### Follow-ups discovered during the second gate
1. ~~**Browse-commit discards the discovered data type**~~ — **CLOSED 2026-08-01 (Gitea #536).** A
browsed REAL committed with `"dataType":"DInt"`, and AbCip/TwinCAT/**FOCAS** decode off that field
rather than the raw row's `DataType`, so the tag published a plausible wrong number with no error
raised. FOCAS was missed when the issue was filed. Fixed by carrying the driver's own type name on
`DriverAttributeInfo.NativeDataType` → `AttributeInfo.AddressFields["dataType"]` →
`RawBrowseCommitMapper`. **It could not be fixed by reverse-mapping the leaf's driver-agnostic
`DriverDataType`**: `SInt`/`Int`/`DInt`/`USInt`/`UInt` all collapse into `Int32`, so that "fix"
would still bind a 16-bit INT as a 32-bit DINT while looking correct on a REAL.
2. ~~**Test Connect fails against this fixture regardless of the device**~~ — **CLOSED 2026-08-01, and
the stated cause was wrong.** `@raw_cpu_type` is not a libplctag special tag at all (only `@raw`,
`@tags`, `@udt/` are), so it is sent as an ordinary symbolic name and the controller's "tag does not
exist" IS the successful answer. Two real defects hid behind the "fixture limitation" label: the sim
answered CIP **0x03** where a controller answers **0x04** (fixed in vendored patch 0002), and
`AbCipDriverProbe.IsReachableException` matched the native C status spellings (`STATUS_NOT_FOUND`)
that libplctag.NET never emits — it throws with the managed enum name — so the reachable
classification could not fire on the exception path, which is the path every no-authored-tags probe
takes. **Test Connect was therefore failing on real hardware too, not just against the sim.**
Pinned by `AbCip_WithNoAuthoredTags_ReportsTheControllerReachable` plus offline classification tests.
### Follow-ups discovered during the first gate
1. ~~**Full tree-render verification is fixture-blocked.**~~ **CLOSED** by the second gate above.
2. ~~**Driver-page address pickers pass no live config.**~~ — **CLOSED 2026-08-05.** The named
`*DriverPage` shells were deleted by v3 Batch 2 before this was ever actioned, but the defect
pattern survived into the v3 tag-editor hosts (`RawTagModal` / `RawManualTagEntryModal`, both
passing a `"{}"` stub as `GetDriverConfigJson`). Fixed by resolving the merged Driver+Device
config (`IRawTreeService.LoadMergedProbeConfigAsync` — the same blob Test-connect and
`RawBrowseModal` dial) once per open and forwarding a live accessor; fail-soft on resolve
failure. **Live-gated 2026-08-05, all three legs PASSED** against the same patched AbCip fixture
this plan's second gate used. See `2026-08-05-raw-modal-picker-live-config-design.md` for the
design and outcome (including the stale-open race the review found and closed, and Gitea **#539**
— the AbCip discovery path dropping the authored EtherNet/IP port — which that gate uncovered).
## 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).