@* WP6 "Browse device…" re-target for the v3 /raw tree. Opens a live driver-browse session against the merged Driver+Device config (endpoint lives in DeviceConfig in v3), renders the shared DriverBrowseTree in multi-select mode, and commits the selected leaves as raw Tag rows under the target Device/TagGroup via IRawTreeService.ImportTagsAsync. Each selected leaf's driver reference is written into the driver-typed TagConfig as an address field (NOT identity — identity is the RawPath). An opt-in toggle mirrors the captured browse-folder nesting onto nested TagGroups. Visibility is @bind-Visible; a successful commit raises OnSaved so the host refreshes the tree. Two-tier browse gate (reused from BrowserSessionService): a bespoke IDriverBrowser (OpcUaClient, Galaxy) wins; otherwise the universal DiscoveryDriverBrowser serves any driver whose ITagDiscovery reports SupportsOnlineDiscovery (AbCip/TwinCAT/FOCAS); otherwise browse is unavailable and the modal grays out with a tooltip. *@ @implements IAsyncDisposable @using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers @using ZB.MOM.WW.OtOpcUa.AdminUI.Uns @using ZB.MOM.WW.OtOpcUa.Commons.Browsing @inject IBrowserSessionService BrowserService @inject IRawTreeService Svc @inject RawTagCsvExportReader ExportReader @if (Visible) { } @code { /// Fallback OPC-UA built-in type for browse-committed leaves whose browser cannot report a /// type (the OPC UA Client tree). Driver-typed browsers (AbCip/TwinCAT/FOCAS) report the real type. private const string DefaultDataType = "Double"; /// Whether the modal is shown. Two-way (@bind-Visible) so the modal can self-close. [Parameter] public bool Visible { get; set; } /// Raised when changes (self-close). Completes the @bind-Visible contract. [Parameter] public EventCallback VisibleChanged { get; set; } /// The device the browsed tags are committed under. [Parameter] public string DeviceId { get; set; } = ""; /// The target tag group (its device-relative path is prepended to every committed tag), or null /// to commit directly under the device. [Parameter] public string? TagGroupId { get; set; } /// The owning device's driver type (informational header; the live gate + config come from the /// merged probe config). [Parameter] public string DriverType { get; set; } = ""; /// Raised after a successful commit so the host can refresh the affected subtree. [Parameter] public EventCallback OnSaved { get; set; } private bool _open; // guards re-seeding on unrelated re-renders private bool _resolving; private bool _canBrowse; private string? _disabledReason; private string _driverType = ""; private string _mergedConfig = "{}"; private string _effectiveDeviceId = ""; private string? _groupPrefix; private Guid _token = Guid.Empty; private bool _opening; private string? _openError; private bool _createGroups; private bool _busy; private List _commitErrors = new(); private readonly Dictionary _selected = new(StringComparer.Ordinal); private readonly HashSet _selectedIds = new(StringComparer.Ordinal); protected override async Task OnParametersSetAsync() { if (!Visible) { _open = false; return; } if (_open) { return; } _open = true; await ResetAndResolveAsync(); } private async Task ResetAndResolveAsync() { _token = Guid.Empty; _opening = false; _openError = null; _createGroups = false; _busy = false; _commitErrors = new(); _selected.Clear(); _selectedIds.Clear(); _driverType = DriverType; _mergedConfig = "{}"; _effectiveDeviceId = DeviceId; _groupPrefix = null; _canBrowse = false; _disabledReason = null; _resolving = true; StateHasChanged(); try { // Merged Driver+Device config — endpoint lives in DeviceConfig in v3, so the browser must dial // the merged blob (same merge the driver-probe/test-connect path uses). var merged = await Svc.LoadMergedProbeConfigAsync(DeviceId); if (merged is { } m) { _driverType = m.DriverType; _mergedConfig = m.MergedConfigJson; } // Resolve the target group's device-relative path prefix (prepended to every committed tag). if (!string.IsNullOrEmpty(TagGroupId)) { var ctx = await ExportReader.ResolveGroupContextAsync(TagGroupId!); if (ctx is { } c) { _groupPrefix = c.GroupPath; if (string.IsNullOrEmpty(_effectiveDeviceId)) { _effectiveDeviceId = c.DeviceId; } } } // Two-tier gate: bespoke browser OR universal discovery browser can serve this driver+config. _canBrowse = BrowserService.CanBrowse(_driverType, _mergedConfig); if (!_canBrowse) { _disabledReason = $"The {_driverType} driver has no online discovery — author tags manually (Add tags ▸ Manual entry)."; } } catch (Exception ex) { _canBrowse = false; _disabledReason = $"Could not load device config: {ex.Message}"; } finally { _resolving = false; StateHasChanged(); } } private async Task OpenBrowseAsync() { _opening = true; _openError = null; StateHasChanged(); try { var result = await BrowserService.OpenAsync(_driverType, _mergedConfig, default); if (result.Ok) { _token = result.Token; } else { _openError = result.Message; } } finally { _opening = false; StateHasChanged(); } } private async Task OnLeafToggledAsync(BrowseLeafSelection sel) { var nodeId = sel.Leaf.NodeId; if (_selectedIds.Contains(nodeId)) { _selectedIds.Remove(nodeId); _selected.Remove(nodeId); return; } // Resolve the leaf's browse name + driver data type from the attribute side-channel. Universal // (AbCip/TwinCAT/FOCAS) reports both; the OPC UA Client tree reports nothing (empty) — fall back to // the display name + the default data type. string name = sel.Leaf.DisplayName; string? driverDataType = null; try { var attrs = await BrowserService.AttributesAsync(_token, nodeId, default); if (attrs.Count > 0) { name = string.IsNullOrWhiteSpace(attrs[0].Name) ? sel.Leaf.DisplayName : attrs[0].Name; driverDataType = attrs[0].DriverDataType; } } catch { // Attribute lookup is best-effort — a leaf is still selectable with display-name + default type. } _selectedIds.Add(nodeId); _selected[nodeId] = new SelectedLeaf(nodeId, name, driverDataType, sel.FolderPath); } private async Task CommitAsync() { _busy = true; _commitErrors = new(); StateHasChanged(); try { var rows = _selected.Values .Select(s => RawBrowseCommitMapper.MapLeaf( _driverType, s.NodeId, s.Name, s.DriverDataType, DefaultDataType, _groupPrefix, s.FolderPath, _createGroups)) .ToList(); var outcome = await Svc.ImportTagsAsync(_effectiveDeviceId, rows); if (outcome.Errors.Count > 0) { _commitErrors = outcome.Errors.ToList(); return; } await OnSaved.InvokeAsync(); await CloseAsync(); } finally { _busy = false; } } private async Task CloseAsync() { var t = _token; _token = Guid.Empty; Visible = false; _open = false; await VisibleChanged.InvokeAsync(false); if (t != Guid.Empty) { await BrowserService.CloseAsync(t); } } private string TargetLabel() => string.IsNullOrEmpty(_groupPrefix) ? "(device root)" : _groupPrefix!; private static string Truncate(string s, int max) => s.Length > max ? s[..max] + "…" : s; public ValueTask DisposeAsync() { if (_token != Guid.Empty) { // Fire-and-forget — don't block circuit teardown on a slow remote. _ = BrowserService.CloseAsync(_token); } return ValueTask.CompletedTask; } private sealed record SelectedLeaf(string NodeId, string Name, string? DriverDataType, IReadOnlyList FolderPath); }