1092 lines
51 KiB
Markdown
1092 lines
51 KiB
Markdown
# 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.
|
||
|
||
---
|
||
|
||
## 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).
|