diff --git a/docs/plans/2026-07-15-universal-discovery-browser-implementation.md.tasks.json b/docs/plans/2026-07-15-universal-discovery-browser-implementation.md.tasks.json
index ce5d48fd..4c3f0a60 100644
--- a/docs/plans/2026-07-15-universal-discovery-browser-implementation.md.tasks.json
+++ b/docs/plans/2026-07-15-universal-discovery-browser-implementation.md.tasks.json
@@ -15,9 +15,9 @@
{"id": 11, "subject": "Task 11: BrowserSessionService universal fallback + CanBrowse", "status": "completed", "blockedBy": [8], "parallelWith": [9]},
{"id": 12, "subject": "Task 12: DI registration in AddAdminUI", "status": "completed", "blockedBy": [11], "parallelWith": [10]},
{"id": 13, "subject": "Task 13: TagModal -> editor parameter plumbing (DriverType + GetDriverConfigJson)", "status": "completed", "blockedBy": [0], "parallelWith": [1, 2]},
- {"id": 14, "subject": "Task 14: AbCip picker body universal browse mode", "status": "pending", "blockedBy": [10, 12, 13, 3], "parallelWith": [15, 16]},
- {"id": 15, "subject": "Task 15: TwinCAT picker body universal browse mode", "status": "pending", "blockedBy": [10, 12, 13, 4], "parallelWith": [14, 16]},
- {"id": 16, "subject": "Task 16: FOCAS picker body universal browse mode", "status": "pending", "blockedBy": [10, 12, 13, 5], "parallelWith": [14, 15]},
+ {"id": 14, "subject": "Task 14: AbCip picker body universal browse mode", "status": "completed", "blockedBy": [10, 12, 13, 3], "parallelWith": [15, 16]},
+ {"id": 15, "subject": "Task 15: TwinCAT picker body universal browse mode", "status": "completed", "blockedBy": [10, 12, 13, 4], "parallelWith": [14, 16]},
+ {"id": 16, "subject": "Task 16: FOCAS picker body universal browse mode", "status": "completed", "blockedBy": [10, 12, 13, 5], "parallelWith": [14, 15]},
{"id": 17, "subject": "Task 17: Full build/test sweep + doc status flip", "status": "pending", "blockedBy": [14, 15, 16]},
{"id": 18, "subject": "Task 18: Live /run verify on docker-dev (GATE)", "status": "pending", "blockedBy": [17]}
],
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/AbCipAddressPickerBody.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/AbCipAddressPickerBody.razor
index 0492921e..6ac0f423 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/AbCipAddressPickerBody.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/AbCipAddressPickerBody.razor
@@ -1,4 +1,14 @@
-@* Static AB CIP address builder: tag name + optional element index → tag[idx] or tag *@
+@* AB CIP address picker:
+ 1. Static tag name + optional element index → tag[idx] or tag (always available).
+ 2. (DriverOperator-gated, when the driver supports online discovery) Browse the controller
+ symbol tree captured by the universal DiscoveryDriverBrowser. *@
+@implements IAsyncDisposable
+@using Microsoft.AspNetCore.Authorization
+@using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing
+@using ZB.MOM.WW.OtOpcUa.Commons.Browsing
+@inject IBrowserSessionService BrowserService
+@inject AuthenticationStateProvider AuthState
+@inject IAuthorizationService AuthorizationService
+@if (_canBrowse)
+{
+
+ @if (_token == Guid.Empty)
+ {
+
+ }
+ else
+ {
+ Browser open
+
+
+ }
+ @if (_openError is not null) { @TruncatedError() }
+
+
+ @if (_token != Guid.Empty)
+ {
+
+
Snapshot taken at open/refresh time — click Refresh to re-read the controller.
+
+
+ }
+}
+
Result:
@_built
@@ -30,16 +76,63 @@
@code {
[Parameter] public string CurrentAddress { get; set; } = "";
[Parameter] public EventCallback
CurrentAddressChanged { get; set; }
+ /// Driver type used to open a universal browse session (defaults to AbCip).
+ [Parameter] public string DriverType { get; set; } = "AbCip";
+ /// Live accessor for the selected driver's DriverConfig JSON (browse connect config).
+ [Parameter] public Func GetConfigJson { get; set; } = () => "{}";
private string _tagName = "";
private int _elementIndex = 0;
private bool _useIndex = false;
private string _built = "";
- protected override void OnInitialized()
+ private Guid _token = Guid.Empty;
+ private bool _opening;
+ private bool _canBrowse;
+ private string? _openError;
+
+ protected override async Task OnInitializedAsync()
{
+ var auth = await AuthState.GetAuthenticationStateAsync();
+ var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator);
+ // CanBrowse constructs a throwaway driver — evaluate once, not per render.
+ _canBrowse = authResult.Succeeded && BrowserService.CanBrowse(DriverType, GetConfigJson() ?? "{}");
_built = Build();
- _ = CurrentAddressChanged.InvokeAsync(_built);
+ await CurrentAddressChanged.InvokeAsync(_built);
+ }
+
+ private async Task OpenBrowseAsync()
+ {
+ _opening = true; _openError = null; StateHasChanged();
+ try
+ {
+ var json = GetConfigJson() ?? "{}";
+ var result = await BrowserService.OpenAsync(DriverType, json, default);
+ if (result.Ok) _token = result.Token;
+ else _openError = result.Message;
+ }
+ finally { _opening = false; StateHasChanged(); }
+ }
+
+ private async Task RefreshBrowseAsync()
+ {
+ await CloseBrowseAsync();
+ await OpenBrowseAsync();
+ }
+
+ private async Task CloseBrowseAsync()
+ {
+ var t = _token; _token = Guid.Empty; StateHasChanged();
+ if (t != Guid.Empty) await BrowserService.CloseAsync(t);
+ }
+
+ private async Task OnTreeSelectAsync(BrowseNode node)
+ {
+ // Folders fire the same callback — commit only on a leaf (the captured NodeId is the CIP tag path).
+ if (node.Kind != BrowseNodeKind.Leaf) return;
+ _tagName = node.NodeId;
+ _useIndex = false;
+ await OnChangedAsync();
}
private async Task OnChangedAsync()
@@ -54,4 +147,17 @@
return "";
return _useIndex ? $"{_tagName}[{_elementIndex}]" : _tagName;
}
+
+ private string TruncatedError() =>
+ _openError is null ? "" : (_openError.Length > 60 ? _openError[..60] + "…" : _openError);
+
+ public ValueTask DisposeAsync()
+ {
+ if (_token != Guid.Empty)
+ {
+ // Fire-and-forget — don't block circuit teardown on a slow capture.
+ _ = BrowserService.CloseAsync(_token);
+ }
+ return ValueTask.CompletedTask;
+ }
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/FOCASAddressPickerBody.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/FOCASAddressPickerBody.razor
index 81c64ff3..1f1cfe2d 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/FOCASAddressPickerBody.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/FOCASAddressPickerBody.razor
@@ -1,5 +1,17 @@
-@* Static FOCAS address builder: parameter group + parameter ID → axis:5 *@
+@* FOCAS address picker:
+ 1. Static parameter group + parameter ID → axis:5 (always available).
+ 2. (DriverOperator-gated, when the driver supports online discovery) Browse the FixedTree
+ device model captured by the universal DiscoveryDriverBrowser. FOCAS opens take a few
+ seconds longer than other drivers — its FixedTree cache fills post-connect and the browser
+ runs an UntilStable settle loop; the _opening spinner covers the wait. *@
+@implements IAsyncDisposable
+@using Microsoft.AspNetCore.Authorization
+@using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
+@using ZB.MOM.WW.OtOpcUa.Commons.Browsing
+@inject IBrowserSessionService BrowserService
+@inject AuthenticationStateProvider AuthState
+@inject IAuthorizationService AuthorizationService
+@if (_canBrowse)
+{
+
+ @if (_token == Guid.Empty)
+ {
+
+ }
+ else
+ {
+ Browser open
+
+
+ }
+ @if (_openError is not null) { @TruncatedError() }
+
+
+ @if (_token != Guid.Empty)
+ {
+
+
Snapshot taken at open/refresh time — click Refresh to re-read the machine.
+
+
+ }
+}
+
Result:
@_built
@@ -26,15 +74,63 @@
@code {
[Parameter] public string CurrentAddress { get; set; } = "";
[Parameter] public EventCallback
CurrentAddressChanged { get; set; }
+ /// Driver type used to open a universal browse session (defaults to FOCAS).
+ [Parameter] public string DriverType { get; set; } = "FOCAS";
+ /// Live accessor for the selected driver's DriverConfig JSON (browse connect config).
+ [Parameter] public Func GetConfigJson { get; set; } = () => "{}";
private string _group = "axis";
private int _parameterId = 0;
private string _built = "";
- protected override void OnInitialized()
+ private Guid _token = Guid.Empty;
+ private bool _opening;
+ private bool _canBrowse;
+ private string? _openError;
+
+ protected override async Task OnInitializedAsync()
{
+ var auth = await AuthState.GetAuthenticationStateAsync();
+ var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator);
+ // CanBrowse constructs a throwaway driver — evaluate once, not per render.
+ _canBrowse = authResult.Succeeded && BrowserService.CanBrowse(DriverType, GetConfigJson() ?? "{}");
_built = FocasAddressBuilder.Build(_group, _parameterId);
- _ = CurrentAddressChanged.InvokeAsync(_built);
+ await CurrentAddressChanged.InvokeAsync(_built);
+ }
+
+ private async Task OpenBrowseAsync()
+ {
+ _opening = true; _openError = null; StateHasChanged();
+ try
+ {
+ var json = GetConfigJson() ?? "{}";
+ var result = await BrowserService.OpenAsync(DriverType, json, default);
+ if (result.Ok) _token = result.Token;
+ else _openError = result.Message;
+ }
+ finally { _opening = false; StateHasChanged(); }
+ }
+
+ private async Task RefreshBrowseAsync()
+ {
+ await CloseBrowseAsync();
+ await OpenBrowseAsync();
+ }
+
+ private async Task CloseBrowseAsync()
+ {
+ var t = _token; _token = Guid.Empty; StateHasChanged();
+ if (t != Guid.Empty) await BrowserService.CloseAsync(t);
+ }
+
+ private async Task OnTreeSelectAsync(BrowseNode node)
+ {
+ // Folders fire the same callback — commit only on a leaf. The captured NodeId is the
+ // driver FullName (FixedTree address), which the group/id builder cannot reconstruct, so
+ // commit it directly rather than through OnChangedAsync.
+ if (node.Kind != BrowseNodeKind.Leaf) return;
+ _built = node.NodeId;
+ await CurrentAddressChanged.InvokeAsync(_built);
}
private async Task OnChangedAsync()
@@ -42,4 +138,17 @@
_built = FocasAddressBuilder.Build(_group, _parameterId);
await CurrentAddressChanged.InvokeAsync(_built);
}
+
+ private string TruncatedError() =>
+ _openError is null ? "" : (_openError.Length > 60 ? _openError[..60] + "…" : _openError);
+
+ public ValueTask DisposeAsync()
+ {
+ if (_token != Guid.Empty)
+ {
+ // Fire-and-forget — don't block circuit teardown on a slow capture.
+ _ = BrowserService.CloseAsync(_token);
+ }
+ return ValueTask.CompletedTask;
+ }
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/TwinCATAddressPickerBody.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/TwinCATAddressPickerBody.razor
index c2e0e187..5c4152c3 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/TwinCATAddressPickerBody.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/TwinCATAddressPickerBody.razor
@@ -1,4 +1,14 @@
-@* Static TwinCAT address builder: ADS variable name (free text) → verbatim *@
+@* TwinCAT address picker:
+ 1. Static ADS variable name (free text) → verbatim (always available).
+ 2. (DriverOperator-gated, when the driver supports online discovery) Browse the ADS symbol
+ tree captured by the universal DiscoveryDriverBrowser. *@
+@implements IAsyncDisposable
+@using Microsoft.AspNetCore.Authorization
+@using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing
+@using ZB.MOM.WW.OtOpcUa.Commons.Browsing
+@inject IBrowserSessionService BrowserService
+@inject AuthenticationStateProvider AuthState
+@inject IAuthorizationService AuthorizationService
+@if (_canBrowse)
+{
+
+ @if (_token == Guid.Empty)
+ {
+
+ }
+ else
+ {
+ Browser open
+
+
+ }
+ @if (_openError is not null) { @TruncatedError() }
+
+
+ @if (_token != Guid.Empty)
+ {
+
+
Snapshot taken at open/refresh time — click Refresh to re-read the controller.
+
+
+ }
+}
+
Result:
@_built
@@ -19,14 +65,60 @@
@code {
[Parameter] public string CurrentAddress { get; set; } = "";
[Parameter] public EventCallback
CurrentAddressChanged { get; set; }
+ /// Driver type used to open a universal browse session (defaults to TwinCAT).
+ [Parameter] public string DriverType { get; set; } = "TwinCAT";
+ /// Live accessor for the selected driver's DriverConfig JSON (browse connect config).
+ [Parameter] public Func GetConfigJson { get; set; } = () => "{}";
private string _varName = "";
private string _built = "";
- protected override void OnInitialized()
+ private Guid _token = Guid.Empty;
+ private bool _opening;
+ private bool _canBrowse;
+ private string? _openError;
+
+ protected override async Task OnInitializedAsync()
{
+ var auth = await AuthState.GetAuthenticationStateAsync();
+ var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator);
+ // CanBrowse constructs a throwaway driver — evaluate once, not per render.
+ _canBrowse = authResult.Succeeded && BrowserService.CanBrowse(DriverType, GetConfigJson() ?? "{}");
_built = _varName;
- _ = CurrentAddressChanged.InvokeAsync(_built);
+ await CurrentAddressChanged.InvokeAsync(_built);
+ }
+
+ private async Task OpenBrowseAsync()
+ {
+ _opening = true; _openError = null; StateHasChanged();
+ try
+ {
+ var json = GetConfigJson() ?? "{}";
+ var result = await BrowserService.OpenAsync(DriverType, json, default);
+ if (result.Ok) _token = result.Token;
+ else _openError = result.Message;
+ }
+ finally { _opening = false; StateHasChanged(); }
+ }
+
+ private async Task RefreshBrowseAsync()
+ {
+ await CloseBrowseAsync();
+ await OpenBrowseAsync();
+ }
+
+ private async Task CloseBrowseAsync()
+ {
+ var t = _token; _token = Guid.Empty; StateHasChanged();
+ if (t != Guid.Empty) await BrowserService.CloseAsync(t);
+ }
+
+ private async Task OnTreeSelectAsync(BrowseNode node)
+ {
+ // Folders fire the same callback — commit only on a leaf (the captured NodeId is the ADS symbol path).
+ if (node.Kind != BrowseNodeKind.Leaf) return;
+ _varName = node.NodeId;
+ await OnChangedAsync();
}
private async Task OnChangedAsync()
@@ -34,4 +126,17 @@
_built = _varName;
await CurrentAddressChanged.InvokeAsync(_built);
}
+
+ private string TruncatedError() =>
+ _openError is null ? "" : (_openError.Length > 60 ? _openError[..60] + "…" : _openError);
+
+ public ValueTask DisposeAsync()
+ {
+ if (_token != Guid.Empty)
+ {
+ // Fire-and-forget — don't block circuit teardown on a slow capture.
+ _ = BrowserService.CloseAsync(_token);
+ }
+ return ValueTask.CompletedTask;
+ }
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbCipTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbCipTagConfigEditor.razor
index 8c400946..066a1030 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbCipTagConfigEditor.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbCipTagConfigEditor.razor
@@ -34,7 +34,9 @@
CurrentAddress="@_pickerAddress"
OnPickAddress="@OnAddressPicked">
+ CurrentAddressChanged="@((s) => _pickerAddress = s)"
+ DriverType="@DriverType"
+ GetConfigJson="@GetDriverConfigJson" />
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/FocasTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/FocasTagConfigEditor.razor
index bc114cd1..8911b438 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/FocasTagConfigEditor.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/FocasTagConfigEditor.razor
@@ -32,7 +32,9 @@
CurrentAddress="@_pickerAddress"
OnPickAddress="@OnAddressPicked">
+ CurrentAddressChanged="@((s) => _pickerAddress = s)"
+ DriverType="@DriverType"
+ GetConfigJson="@GetDriverConfigJson" />
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/TwinCATTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/TwinCATTagConfigEditor.razor
index cb369480..1334e55f 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/TwinCATTagConfigEditor.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/TwinCATTagConfigEditor.razor
@@ -34,7 +34,9 @@
CurrentAddress="@_pickerAddress"
OnPickAddress="@OnAddressPicked">
+ CurrentAddressChanged="@((s) => _pickerAddress = s)"
+ DriverType="@DriverType"
+ GetConfigJson="@GetDriverConfigJson" />
}