From 85776cf650adb101c598d1d1e4e03901ad9e6eaf Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 17:15:32 -0400 Subject: [PATCH 1/9] =?UTF-8?q?feat(browse):=20Wave-0=20Batch=20A=20?= =?UTF-8?q?=E2=80=94=20SupportsOnlineDiscovery=20gate,=20Commons=20ref,=20?= =?UTF-8?q?TagModal=20editor=20plumbing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 1: ITagDiscovery.SupportsOnlineDiscovery default member (=> false) Task 2: Commons -> Core.Abstractions ProjectReference Task 13: TagModal BuildEditorParameters passes DriverType + GetDriverConfigJson; all 7 typed tag editors declare the two new [Parameter]s (DynamicComponent throws on unmatched params, so every editor must accept them). --- .../ZB.MOM.WW.OtOpcUa.Commons.csproj | 4 ++++ .../ITagDiscovery.cs | 8 +++++++ .../Uns/TagEditors/AbCipTagConfigEditor.razor | 4 ++++ .../TagEditors/AbLegacyTagConfigEditor.razor | 4 ++++ .../Uns/TagEditors/FocasTagConfigEditor.razor | 4 ++++ .../TagEditors/ModbusTagConfigEditor.razor | 4 ++++ .../OpcUaClientTagConfigEditor.razor | 4 ++++ .../Uns/TagEditors/S7TagConfigEditor.razor | 4 ++++ .../TagEditors/TwinCATTagConfigEditor.razor | 4 ++++ .../Components/Shared/Uns/TagModal.razor | 2 ++ .../TagDiscoveryDefaultsTests.cs | 21 +++++++++++++++++++ 11 files changed, 63 insertions(+) create mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/TagDiscoveryDefaultsTests.cs diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/ZB.MOM.WW.OtOpcUa.Commons.csproj b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/ZB.MOM.WW.OtOpcUa.Commons.csproj index f90281ea..55b52d43 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/ZB.MOM.WW.OtOpcUa.Commons.csproj +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/ZB.MOM.WW.OtOpcUa.Commons.csproj @@ -10,4 +10,8 @@ + + + + diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs index c63b5268..cc713a1b 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs @@ -30,4 +30,12 @@ public interface ITagDiscovery /// Post-connect re-discovery policy. Default preserves the original retry-until-stable behavior. DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.UntilStable; + + /// + /// True when 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. + /// + bool SupportsOnlineDiscovery => false; } 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 27328ab8..8c400946 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 @@ -41,6 +41,10 @@ @code { [Parameter] public string? ConfigJson { get; set; } [Parameter] public EventCallback ConfigJsonChanged { get; set; } + /// DriverType of the selected driver — lets browse-capable pickers open a universal session. + [Parameter] public string DriverType { get; set; } = ""; + /// Live accessor for the selected driver's DriverConfig JSON (browse connect config). + [Parameter] public Func GetDriverConfigJson { get; set; } = () => "{}"; private AbCipTagConfigModel _m = new(); private string? _lastConfigJson; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbLegacyTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbLegacyTagConfigEditor.razor index 8f770103..9a657d1a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbLegacyTagConfigEditor.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbLegacyTagConfigEditor.razor @@ -41,6 +41,10 @@ @code { [Parameter] public string? ConfigJson { get; set; } [Parameter] public EventCallback ConfigJsonChanged { get; set; } + /// DriverType of the selected driver — lets browse-capable pickers open a universal session. + [Parameter] public string DriverType { get; set; } = ""; + /// Live accessor for the selected driver's DriverConfig JSON (browse connect config). + [Parameter] public Func GetDriverConfigJson { get; set; } = () => "{}"; private AbLegacyTagConfigModel _m = new(); private string? _lastConfigJson; 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 5eec1f55..bc114cd1 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 @@ -39,6 +39,10 @@ @code { [Parameter] public string? ConfigJson { get; set; } [Parameter] public EventCallback ConfigJsonChanged { get; set; } + /// DriverType of the selected driver — lets browse-capable pickers open a universal session. + [Parameter] public string DriverType { get; set; } = ""; + /// Live accessor for the selected driver's DriverConfig JSON (browse connect config). + [Parameter] public Func GetDriverConfigJson { get; set; } = () => "{}"; private FocasTagConfigModel _m = new(); private string? _lastConfigJson; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/ModbusTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/ModbusTagConfigEditor.razor index b9449866..116f9bd5 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/ModbusTagConfigEditor.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/ModbusTagConfigEditor.razor @@ -49,6 +49,10 @@ @code { [Parameter] public string? ConfigJson { get; set; } [Parameter] public EventCallback ConfigJsonChanged { get; set; } + /// DriverType of the selected driver — lets browse-capable pickers open a universal session. + [Parameter] public string DriverType { get; set; } = ""; + /// Live accessor for the selected driver's DriverConfig JSON (browse connect config). + [Parameter] public Func GetDriverConfigJson { get; set; } = () => "{}"; private ModbusTagConfigModel _m = new(); private string? _lastConfigJson; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/OpcUaClientTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/OpcUaClientTagConfigEditor.razor index 58ea6ade..ef4c9d34 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/OpcUaClientTagConfigEditor.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/OpcUaClientTagConfigEditor.razor @@ -11,6 +11,10 @@ @code { [Parameter] public string? ConfigJson { get; set; } [Parameter] public EventCallback ConfigJsonChanged { get; set; } + /// DriverType of the selected driver — lets browse-capable pickers open a universal session. + [Parameter] public string DriverType { get; set; } = ""; + /// Live accessor for the selected driver's DriverConfig JSON (browse connect config). + [Parameter] public Func GetDriverConfigJson { get; set; } = () => "{}"; private OpcUaClientTagConfigModel _m = new(); private string? _lastConfigJson; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/S7TagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/S7TagConfigEditor.razor index aba5047a..7d95c364 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/S7TagConfigEditor.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/S7TagConfigEditor.razor @@ -41,6 +41,10 @@ @code { [Parameter] public string? ConfigJson { get; set; } [Parameter] public EventCallback ConfigJsonChanged { get; set; } + /// DriverType of the selected driver — lets browse-capable pickers open a universal session. + [Parameter] public string DriverType { get; set; } = ""; + /// Live accessor for the selected driver's DriverConfig JSON (browse connect config). + [Parameter] public Func GetDriverConfigJson { get; set; } = () => "{}"; private S7TagConfigModel _m = new(); private string? _lastConfigJson; 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 143fdee0..cb369480 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 @@ -41,6 +41,10 @@ @code { [Parameter] public string? ConfigJson { get; set; } [Parameter] public EventCallback ConfigJsonChanged { get; set; } + /// DriverType of the selected driver — lets browse-capable pickers open a universal session. + [Parameter] public string DriverType { get; set; } = ""; + /// Live accessor for the selected driver's DriverConfig JSON (browse connect config). + [Parameter] public Func GetDriverConfigJson { get; set; } = () => "{}"; private TwinCATTagConfigModel _m = new(); private string? _lastConfigJson; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor index 677656b9..a91a79be 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor @@ -353,6 +353,8 @@ { ["ConfigJson"] = _form.TagConfig, ["ConfigJsonChanged"] = EventCallback.Factory.Create(this, v => _form.TagConfig = v), + ["DriverType"] = SelectedDriverType ?? "", + ["GetDriverConfigJson"] = (Func)(() => SelectedDriverConfig), }; // Cache a single NativeAlarmModel parse keyed on the current TagConfig string, so the two computed diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/TagDiscoveryDefaultsTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/TagDiscoveryDefaultsTests.cs new file mode 100644 index 00000000..dbe114b7 --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/TagDiscoveryDefaultsTests.cs @@ -0,0 +1,21 @@ +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); +} From 679484ae78eae618eb51888fd2518f6c70b33fa1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 17:18:02 -0400 Subject: [PATCH 2/9] =?UTF-8?q?feat(browse):=20Wave-0=20Batch=20B=20?= =?UTF-8?q?=E2=80=94=20AbCip/TwinCAT/FOCAS=20opt=20into=20online=20discove?= =?UTF-8?q?ry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 3/4/5: SupportsOnlineDiscovery => true on AbCipDriver, TwinCATDriver, FocasDriver (their device enumeration is real, config-gated on EnableControllerBrowse / FixedTree.Enabled which PatchForBrowse guarantees at open). One gate fact per driver test project. --- ...overy-browser-implementation.md.tasks.json | 14 ++++++------- .../AbCipDriver.cs | 4 ++++ .../FocasDriver.cs | 6 ++++++ .../TwinCATDriver.cs | 4 ++++ .../AbCipDiscoveryGateTests.cs | 19 ++++++++++++++++++ .../FocasDiscoveryGateTests.cs | 20 +++++++++++++++++++ .../TwinCATDiscoveryGateTests.cs | 19 ++++++++++++++++++ 7 files changed, 79 insertions(+), 7 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDiscoveryGateTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDiscoveryGateTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATDiscoveryGateTests.cs 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 741e7914..0758de6c 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 @@ -1,12 +1,12 @@ { "planPath": "docs/plans/2026-07-15-universal-discovery-browser-implementation.md", "tasks": [ - {"id": 0, "subject": "Task 0: Branch setup (feat/universal-discovery-browser)", "status": "pending"}, - {"id": 1, "subject": "Task 1: ITagDiscovery.SupportsOnlineDiscovery default member", "status": "pending", "blockedBy": [0], "parallelWith": [2, 13]}, - {"id": 2, "subject": "Task 2: Commons project references (Core.Abstractions)", "status": "pending", "blockedBy": [0], "parallelWith": [1, 13]}, - {"id": 3, "subject": "Task 3: AbCip opt-in SupportsOnlineDiscovery=true", "status": "pending", "blockedBy": [1], "parallelWith": [4, 5]}, - {"id": 4, "subject": "Task 4: TwinCAT opt-in SupportsOnlineDiscovery=true", "status": "pending", "blockedBy": [1], "parallelWith": [3, 5]}, - {"id": 5, "subject": "Task 5: FOCAS opt-in SupportsOnlineDiscovery=true", "status": "pending", "blockedBy": [1], "parallelWith": [3, 4]}, + {"id": 0, "subject": "Task 0: Branch setup (feat/universal-discovery-browser)", "status": "completed"}, + {"id": 1, "subject": "Task 1: ITagDiscovery.SupportsOnlineDiscovery default member", "status": "completed", "blockedBy": [0], "parallelWith": [2, 13]}, + {"id": 2, "subject": "Task 2: Commons project references (Core.Abstractions)", "status": "completed", "blockedBy": [0], "parallelWith": [1, 13]}, + {"id": 3, "subject": "Task 3: AbCip opt-in SupportsOnlineDiscovery=true", "status": "completed", "blockedBy": [1], "parallelWith": [4, 5]}, + {"id": 4, "subject": "Task 4: TwinCAT opt-in SupportsOnlineDiscovery=true", "status": "completed", "blockedBy": [1], "parallelWith": [3, 5]}, + {"id": 5, "subject": "Task 5: FOCAS opt-in SupportsOnlineDiscovery=true", "status": "completed", "blockedBy": [1], "parallelWith": [3, 4]}, {"id": 6, "subject": "Task 6: CapturingAddressSpaceBuilder + captured-tree model", "status": "pending", "blockedBy": [1, 2]}, {"id": 7, "subject": "Task 7: CapturedTreeBrowseSession", "status": "pending", "blockedBy": [6]}, {"id": 8, "subject": "Task 8: IUniversalDriverBrowser + DiscoveryDriverBrowser core open path", "status": "pending", "blockedBy": [7]}, @@ -14,7 +14,7 @@ {"id": 10, "subject": "Task 10: Capture coalescing + MaxConcurrentCaptures cap", "status": "pending", "blockedBy": [9], "parallelWith": [12]}, {"id": 11, "subject": "Task 11: BrowserSessionService universal fallback + CanBrowse", "status": "pending", "blockedBy": [8], "parallelWith": [9]}, {"id": 12, "subject": "Task 12: DI registration in AddAdminUI", "status": "pending", "blockedBy": [11], "parallelWith": [10]}, - {"id": 13, "subject": "Task 13: TagModal -> editor parameter plumbing (DriverType + GetDriverConfigJson)", "status": "pending", "blockedBy": [0], "parallelWith": [1, 2]}, + {"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]}, diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index ca7e045f..8b2cd78f 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -999,6 +999,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once; + /// Controller enumeration is real (CIP Symbol Object walk) — config-gated on + /// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time. + public bool SupportsOnlineDiscovery => true; + /// public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs index 9fca75f6..9df8851c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs @@ -427,6 +427,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, // node set is non-empty and stable. public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.UntilStable; + /// The FixedTree device folder is real enumeration — config-gated on FixedTree.Enabled, + /// which the universal browser's PatchForBrowse turns on. FOCAS is the UntilStable driver: the + /// FixedTree cache fills a couple of seconds post-connect, so the browser's settle loop re-captures + /// until the node set is non-empty and stable. + public bool SupportsOnlineDiscovery => true; + /// public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs index 7420e6a7..1460d0dc 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs @@ -380,6 +380,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery // discovery pass is sufficient. public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once; + /// ADS symbol upload is real device enumeration — config-gated on + /// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time. + public bool SupportsOnlineDiscovery => true; + /// public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDiscoveryGateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDiscoveryGateTests.cs new file mode 100644 index 00000000..e832f236 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDiscoveryGateTests.cs @@ -0,0 +1,19 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +[Trait("Category", "Unit")] +public sealed class AbCipDiscoveryGateTests +{ + /// AbCip opts into the universal discovery browser — its CIP Symbol Object walk is real + /// device enumeration (config-gated on EnableControllerBrowse, guaranteed by PatchForBrowse at open). + [Fact] + public void SupportsOnlineDiscovery_IsTrue() + { + var drv = new AbCipDriver(new AbCipDriverOptions(), "drv-1"); + ((ITagDiscovery)drv).SupportsOnlineDiscovery.ShouldBeTrue(); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDiscoveryGateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDiscoveryGateTests.cs new file mode 100644 index 00000000..a920d596 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDiscoveryGateTests.cs @@ -0,0 +1,20 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; + +[Trait("Category", "Unit")] +public sealed class FocasDiscoveryGateTests +{ + /// FOCAS opts into the universal discovery browser — its FixedTree device folder is real + /// enumeration (config-gated on FixedTree.Enabled, turned on by PatchForBrowse). FOCAS is the + /// UntilStable driver: the browser's settle loop re-captures until the FixedTree cache fills. + [Fact] + public void SupportsOnlineDiscovery_IsTrue() + { + var drv = new FocasDriver(new FocasDriverOptions(), "drv-1"); + ((ITagDiscovery)drv).SupportsOnlineDiscovery.ShouldBeTrue(); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATDiscoveryGateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATDiscoveryGateTests.cs new file mode 100644 index 00000000..97da1f2c --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATDiscoveryGateTests.cs @@ -0,0 +1,19 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; + +namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests; + +[Trait("Category", "Unit")] +public sealed class TwinCATDiscoveryGateTests +{ + /// TwinCAT opts into the universal discovery browser — its ADS symbol upload is real + /// device enumeration (config-gated on EnableControllerBrowse, guaranteed by PatchForBrowse at open). + [Fact] + public void SupportsOnlineDiscovery_IsTrue() + { + var drv = new TwinCATDriver(new TwinCATDriverOptions(), "drv-1"); + ((ITagDiscovery)drv).SupportsOnlineDiscovery.ShouldBeTrue(); + } +} From 15da8d4f0d95cfb179f6f97e05327932a3afa6f5 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 17:22:00 -0400 Subject: [PATCH 3/9] =?UTF-8?q?feat(browse):=20Wave-0=20Batch=20C=20?= =?UTF-8?q?=E2=80=94=20CapturingAddressSpaceBuilder=20+=20CapturedTreeBrow?= =?UTF-8?q?seSession?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 6: CapturedTree/CapturedNode model + CapturingAddressSpaceBuilder — an in-memory IAddressSpaceBuilder that records a driver's DiscoverAsync stream (folder nesting, leaf FullName as node id, alarm mark, node-cap truncation, no-op alarm sink). Task 7: CapturedTreeBrowseSession — serves the captured snapshot through IBrowseSession (Root/Expand/Attributes from memory, truncation marker, shared-immutable-tree dispose). 13 unit tests green. --- ...overy-browser-implementation.md.tasks.json | 4 +- .../Browsing/CapturedTree.cs | 49 ++++++++ .../Browsing/CapturedTreeBrowseSession.cs | 71 +++++++++++ .../Browsing/CapturingAddressSpaceBuilder.cs | 112 +++++++++++++++++ .../CapturedTreeBrowseSessionTests.cs | 115 ++++++++++++++++++ .../CapturingAddressSpaceBuilderTests.cs | 70 +++++++++++ 6 files changed, 419 insertions(+), 2 deletions(-) create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/CapturedTree.cs create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/CapturedTreeBrowseSession.cs create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/CapturingAddressSpaceBuilder.cs create mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/CapturedTreeBrowseSessionTests.cs create mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/CapturingAddressSpaceBuilderTests.cs 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 0758de6c..76fef22c 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 @@ -7,8 +7,8 @@ {"id": 3, "subject": "Task 3: AbCip opt-in SupportsOnlineDiscovery=true", "status": "completed", "blockedBy": [1], "parallelWith": [4, 5]}, {"id": 4, "subject": "Task 4: TwinCAT opt-in SupportsOnlineDiscovery=true", "status": "completed", "blockedBy": [1], "parallelWith": [3, 5]}, {"id": 5, "subject": "Task 5: FOCAS opt-in SupportsOnlineDiscovery=true", "status": "completed", "blockedBy": [1], "parallelWith": [3, 4]}, - {"id": 6, "subject": "Task 6: CapturingAddressSpaceBuilder + captured-tree model", "status": "pending", "blockedBy": [1, 2]}, - {"id": 7, "subject": "Task 7: CapturedTreeBrowseSession", "status": "pending", "blockedBy": [6]}, + {"id": 6, "subject": "Task 6: CapturingAddressSpaceBuilder + captured-tree model", "status": "completed", "blockedBy": [1, 2]}, + {"id": 7, "subject": "Task 7: CapturedTreeBrowseSession", "status": "completed", "blockedBy": [6]}, {"id": 8, "subject": "Task 8: IUniversalDriverBrowser + DiscoveryDriverBrowser core open path", "status": "pending", "blockedBy": [7]}, {"id": 9, "subject": "Task 9: UntilStable settle loop (FOCAS)", "status": "pending", "blockedBy": [8], "parallelWith": [11]}, {"id": 10, "subject": "Task 10: Capture coalescing + MaxConcurrentCaptures cap", "status": "pending", "blockedBy": [9], "parallelWith": [12]}, diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/CapturedTree.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/CapturedTree.cs new file mode 100644 index 00000000..f2fa8cc2 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/CapturedTree.cs @@ -0,0 +1,49 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing; + +/// 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). +public sealed class CapturedNode +{ + /// Stable identifier. Folder = path-composed ("/parent/child"); leaf = driver FullName. + public required string Id { get; init; } + + /// Browse name (the path segment under the parent). + public required string Name { get; init; } + + /// Human-readable display name. + public required string DisplayName { get; init; } + + /// True for a variable (terminal); false for a folder. + public required bool IsLeaf { get; init; } + + /// Driver-side attribute metadata; set only on leaves. + public DriverAttributeInfo? Attribute { get; init; } + + /// True when the driver marked this leaf as an alarm condition during discovery. + public bool IsAlarmMarked { get; set; } + + /// Captured child nodes (folders first-or-interleaved in stream order). + public List Children { get; } = []; + + /// Captured static properties attached to this node via AddProperty. + public List<(string Name, DriverDataType Type, object? Value)> Properties { get; } = []; +} + +/// 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. +public sealed class CapturedTree +{ + /// Synthetic root; its Children are the top-level captured nodes. + public required CapturedNode Root { get; init; } + + /// True when the node cap was hit and recording stopped short. + public required bool Truncated { get; init; } + + /// Number of nodes actually recorded (bounded by the node cap). + public required int Count { get; init; } + + /// Flat lookup from node id to node (for O(1) Expand/Attributes serving). + public required IReadOnlyDictionary ById { get; init; } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/CapturedTreeBrowseSession.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/CapturedTreeBrowseSession.cs new file mode 100644 index 00000000..47ac6fdd --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/CapturedTreeBrowseSession.cs @@ -0,0 +1,71 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing; + +/// +/// 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. +/// +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> RootAsync(CancellationToken cancellationToken) + { + 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>(nodes); + } + + /// + public Task> ExpandAsync(string nodeId, CancellationToken cancellationToken) + { + var t = Tree(); + LastUsedUtc = DateTime.UtcNow; + return Task.FromResult>( + t.ById.TryGetValue(nodeId, out var n) ? n.Children.Select(Map).ToList() : []); + } + + /// + public Task> AttributesAsync(string nodeId, CancellationToken cancellationToken) + { + var t = Tree(); + LastUsedUtc = DateTime.UtcNow; + if (!t.ById.TryGetValue(nodeId, out var n)) + return Task.FromResult>([]); + var attrs = new List(); + 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>(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); +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/CapturingAddressSpaceBuilder.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/CapturingAddressSpaceBuilder.cs new file mode 100644 index 00000000..ceae69df --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/CapturingAddressSpaceBuilder.cs @@ -0,0 +1,112 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing; + +/// +/// In-memory 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. +/// +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 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; + + /// Create a root capturing builder with the given node cap (default 50 000). + /// Maximum number of nodes recorded before the tree is marked truncated. + 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)); + + /// Snapshot the captured tree. Only valid on the root builder (the one the browser constructed). + /// An immutable-after-build . + 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 + } +} diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/CapturedTreeBrowseSessionTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/CapturedTreeBrowseSessionTests.cs new file mode 100644 index 00000000..9aa3a433 --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/CapturedTreeBrowseSessionTests.cs @@ -0,0 +1,115 @@ +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 CapturedTreeBrowseSessionTests +{ + private static DriverAttributeInfo Attr(string fullName, bool isAlarm = false) => + new(fullName, DriverDataType.Float64, false, null, SecurityClassification.Operate, false, IsAlarm: isAlarm); + + // A small captured graph: a folder Plc1 with a Speed leaf and an alarm leaf, plus a root-level leaf. + // truncated=true builds with a tiny cap so recording stops short and Truncated flips. + private static CapturedTree SampleTree(bool truncated = false) + { + var b = new CapturingAddressSpaceBuilder(nodeCap: truncated ? 2 : 100); + var plc = b.Folder("Plc1", "PLC 1"); + plc.Variable("Speed", "Speed", Attr("Plc1.Speed")); + plc.Variable("Alarm", "Alarm", Attr("Plc1.Alarm")).MarkAsAlarmCondition( + new AlarmConditionInfo("Alarm", AlarmSeverity.High, null)); + b.Variable("Direct", "Direct", Attr("Root.Direct")); + return b.Build(); + } + + [Fact] + public async Task RootReturnsTopLevelNodes() + { + await using var s = new CapturedTreeBrowseSession(SampleTree()); + var root = await s.RootAsync(CancellationToken.None); + + var folder = root.Single(n => n.NodeId == "/Plc1"); + folder.Kind.ShouldBe(BrowseNodeKind.Folder); + folder.HasChildrenHint.ShouldBeTrue(); + + var leaf = root.Single(n => n.NodeId == "Root.Direct"); + leaf.Kind.ShouldBe(BrowseNodeKind.Leaf); + leaf.NodeId.ShouldBe("Root.Direct"); // leaf NodeId IS the driver FullName + } + + [Fact] + public async Task ExpandReturnsCapturedChildren() + { + await using var s = new CapturedTreeBrowseSession(SampleTree()); + var kids = await s.ExpandAsync("/Plc1", CancellationToken.None); + kids.Select(n => n.NodeId).ShouldBe(new[] { "Plc1.Speed", "Plc1.Alarm" }); + } + + [Fact] + public async Task ExpandUnknownNodeReturnsEmpty() + { + await using var s = new CapturedTreeBrowseSession(SampleTree()); + (await s.ExpandAsync("/nope", CancellationToken.None)).ShouldBeEmpty(); + } + + [Fact] + public async Task AttributesForLeafMapsDriverAttributeInfo() + { + await using var s = new CapturedTreeBrowseSession(SampleTree()); + var attrs = await s.AttributesAsync("Plc1.Speed", CancellationToken.None); + var a = attrs.Single(); + a.Name.ShouldBe("Speed"); + a.DriverDataType.ShouldBe(DriverDataType.Float64.ToString()); + a.IsArray.ShouldBeFalse(); + a.SecurityClass.ShouldBe(SecurityClassification.Operate.ToString()); + a.IsAlarm.ShouldBeFalse(); + + // Alarm marked during discovery surfaces IsAlarm=true even though attr.IsAlarm is false. + var alarmAttrs = await s.AttributesAsync("Plc1.Alarm", CancellationToken.None); + alarmAttrs.Single().IsAlarm.ShouldBeTrue(); + } + + [Fact] + public async Task TruncatedTreeAppendsMarkerFolderAtRoot() + { + await using var s = new CapturedTreeBrowseSession(SampleTree(truncated: true)); + var root = await s.RootAsync(CancellationToken.None); + var marker = root[^1]; + marker.Kind.ShouldBe(BrowseNodeKind.Folder); + marker.HasChildrenHint.ShouldBeFalse(); + marker.DisplayName.ToLowerInvariant().ShouldContain("truncated"); + } + + [Fact] + public async Task CallsRefreshLastUsedUtc() + { + await using var s = new CapturedTreeBrowseSession(SampleTree()); + var before = s.LastUsedUtc; + await Task.Delay(5, TestContext.Current.CancellationToken); + await s.RootAsync(CancellationToken.None); + s.LastUsedUtc.ShouldBeGreaterThan(before); + } + + [Fact] + public async Task DisposedSessionThrowsObjectDisposed() + { + var s = new CapturedTreeBrowseSession(SampleTree()); + await s.DisposeAsync(); + await Should.ThrowAsync(() => s.RootAsync(CancellationToken.None)); + } + + [Fact] + public async Task TwoSessionsOverSameTreeAreIndependent() + { + var tree = SampleTree(); + var s1 = new CapturedTreeBrowseSession(tree); + var s2 = new CapturedTreeBrowseSession(tree); + s1.Token.ShouldNotBe(s2.Token); + + await s1.DisposeAsync(); + // Disposing s1 must not affect s2 — the tree is immutable and shared. + (await s2.RootAsync(CancellationToken.None)).ShouldNotBeEmpty(); + await s2.DisposeAsync(); + } +} diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/CapturingAddressSpaceBuilderTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/CapturingAddressSpaceBuilderTests.cs new file mode 100644 index 00000000..7c7018e9 --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/CapturingAddressSpaceBuilderTests.cs @@ -0,0 +1,70 @@ +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"); + } +} From fa339a55654880a49a6222bc6e00009b1e7a3938 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 17:27:04 -0400 Subject: [PATCH 4/9] =?UTF-8?q?feat(browse):=20Wave-0=20Batch=20D=20?= =?UTF-8?q?=E2=80=94=20IUniversalDriverBrowser=20+=20DiscoveryDriverBrowse?= =?UTF-8?q?r=20core?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 8: IUniversalDriverBrowser + DiscoveryDriverBrowser open path — construct(+PatchForBrowse) -> connect -> one-shot capture -> bounded (10s) shutdown; CanBrowse gate (catch-throwing TryCreate, defensive teardown of the throwaway instance); deep-merge browse patch. Commons gains Microsoft.Extensions.Logging.Abstractions (ILogger doesn't flow transitively) + InternalsVisibleTo(Commons.Tests). OTOPCUA0001 suppressed on the deliberate one-shot browse-capture DiscoverAsync (not a runtime dispatch path). 12 unit tests green. --- ...overy-browser-implementation.md.tasks.json | 2 +- .../Browsing/DiscoveryDriverBrowser.cs | 148 ++++++++++++++++++ .../Browsing/IUniversalDriverBrowser.cs | 28 ++++ .../ZB.MOM.WW.OtOpcUa.Commons.csproj | 5 + .../Browsing/DiscoveryDriverBrowserFakes.cs | 118 ++++++++++++++ .../Browsing/DiscoveryDriverBrowserTests.cs | 145 +++++++++++++++++ 6 files changed, 445 insertions(+), 1 deletion(-) create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/DiscoveryDriverBrowser.cs create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/IUniversalDriverBrowser.cs create mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserFakes.cs create mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserTests.cs 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 76fef22c..42c5a951 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 @@ -9,7 +9,7 @@ {"id": 5, "subject": "Task 5: FOCAS opt-in SupportsOnlineDiscovery=true", "status": "completed", "blockedBy": [1], "parallelWith": [3, 4]}, {"id": 6, "subject": "Task 6: CapturingAddressSpaceBuilder + captured-tree model", "status": "completed", "blockedBy": [1, 2]}, {"id": 7, "subject": "Task 7: CapturedTreeBrowseSession", "status": "completed", "blockedBy": [6]}, - {"id": 8, "subject": "Task 8: IUniversalDriverBrowser + DiscoveryDriverBrowser core open path", "status": "pending", "blockedBy": [7]}, + {"id": 8, "subject": "Task 8: IUniversalDriverBrowser + DiscoveryDriverBrowser core open path", "status": "completed", "blockedBy": [7]}, {"id": 9, "subject": "Task 9: UntilStable settle loop (FOCAS)", "status": "pending", "blockedBy": [8], "parallelWith": [11]}, {"id": 10, "subject": "Task 10: Capture coalescing + MaxConcurrentCaptures cap", "status": "pending", "blockedBy": [9], "parallelWith": [12]}, {"id": 11, "subject": "Task 11: BrowserSessionService universal fallback + CanBrowse", "status": "pending", "blockedBy": [8], "parallelWith": [9]}, diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/DiscoveryDriverBrowser.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/DiscoveryDriverBrowser.cs new file mode 100644 index 00000000..9f50bb3e --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/DiscoveryDriverBrowser.cs @@ -0,0 +1,148 @@ +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing; + +/// See docs/plans/2026-07-15-universal-discovery-browser-design.md §4.2. +public sealed class DiscoveryDriverBrowser : IUniversalDriverBrowser +{ + /// Whole-open bound: construct + connect + discover (+ settle). Separate from the + /// 20 s per-call browse timeout, which only covers serving from memory. + public static readonly TimeSpan OpenTimeout = TimeSpan.FromSeconds(60); + + /// Bound on the cleanup ShutdownAsync (R2-01: no unbounded waits — a driver wedged + /// in discovery may be equally wedged in shutdown). + public static readonly TimeSpan ShutdownTimeout = TimeSpan.FromSeconds(10); + + /// Node-count cap the capturing builder enforces before marking the tree truncated. + public const int NodeCap = 50_000; + + /// Per-type declarative config patch guaranteeing browse mode at open time (§5.1). + /// A dictionary entry per config-gated driver — not code. + internal static readonly IReadOnlyDictionary BrowsePatches = + new Dictionary(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 _logger; + private readonly TimeSpan _openTimeout; + private readonly TimeSpan _shutdownTimeout; + + /// Production constructor: standard open + shutdown bounds. + /// Factory used to construct throwaway/capture driver instances. + /// Logger for shutdown-timeout warnings. + public DiscoveryDriverBrowser(IDriverFactory driverFactory, ILogger logger) + : this(driverFactory, logger, OpenTimeout, ShutdownTimeout) { } + + // Test seam: short timeouts. + internal DiscoveryDriverBrowser(IDriverFactory driverFactory, ILogger 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 OpenAsync(string driverType, string configJson, CancellationToken ct) + { + var tree = await CaptureAsync(driverType, configJson, ct).ConfigureAwait(false); + return new CapturedTreeBrowseSession(tree); + } + + private async Task 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 DiscoverTreeAsync(ITagDiscovery disc, CancellationToken ct) + { + var builder = new CapturingAddressSpaceBuilder(NodeCap); +#pragma warning disable OTOPCUA0001 // One-shot browse capture, NOT a runtime dispatch path: the driver is a throwaway instance we construct/connect/discover/discard for the picker. CapabilityInvoker (retry/breaker/telemetry) governs live polling, not this admin-side capture. + await disc.DiscoverAsync(builder, ct).ConfigureAwait(false); +#pragma warning restore OTOPCUA0001 + 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(); + } + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/IUniversalDriverBrowser.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/IUniversalDriverBrowser.cs new file mode 100644 index 00000000..ed87b1a4 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/IUniversalDriverBrowser.cs @@ -0,0 +1,28 @@ +namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing; + +/// +/// 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. +/// +public interface IUniversalDriverBrowser +{ + /// Cheap gate (construct + inspect, no connect): can this driver type + config be + /// browsed? Never throws. Drives the picker's Browse-button visibility. + /// The driver type name. + /// The authoring DriverConfig JSON (may be mid-edit / malformed). + /// True when a discovery-capable driver can be constructed for this type + config. + bool CanBrowse(string driverType, string configJson); + + /// 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. + /// The driver type name. + /// The DriverConfig JSON to connect with. + /// Cancellation token for the caller's open request. + /// A browse session over the captured discovery snapshot. + Task OpenAsync(string driverType, string configJson, CancellationToken ct); +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/ZB.MOM.WW.OtOpcUa.Commons.csproj b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/ZB.MOM.WW.OtOpcUa.Commons.csproj index 55b52d43..686d3f42 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/ZB.MOM.WW.OtOpcUa.Commons.csproj +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/ZB.MOM.WW.OtOpcUa.Commons.csproj @@ -7,6 +7,7 @@ + @@ -14,4 +15,8 @@ + + + + diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserFakes.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserFakes.cs new file mode 100644 index 00000000..78f45228 --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserFakes.cs @@ -0,0 +1,118 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Browsing; + +/// Shared test doubles for the DiscoveryDriverBrowser suites (Tasks 8–10). +internal sealed class FakeDiscoveryDriver : IDriver, ITagDiscovery +{ + public int InitializeCalls; + public int DiscoverCalls; + public int ShutdownCalls; + + /// Ordered lifecycle trace ("init"/"discover"/"shutdown"). + public List Trace { get; } = []; + + /// Completes once ShutdownAsync has finished (even the fire-and-forget CanBrowse path). + public TaskCompletionSource ShutdownDone { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public bool SupportsOnlineDiscovery { get; init; } = true; + public DiscoveryRediscoverPolicy RediscoverPolicy { get; init; } = DiscoveryRediscoverPolicy.Once; + + /// Discovery behavior: (builder, 1-based call index) → task. Default streams one leaf. + public Func? OnDiscover { get; init; } + public Func? OnInitialize { get; init; } + public Func? OnShutdown { get; init; } + + public string DriverInstanceId { get; } + public string DriverType { get; } + public string? LastConfigJson { get; private set; } + + public FakeDiscoveryDriver(string driverType = "Fake", string id = "fake-1") + { + DriverType = driverType; + DriverInstanceId = id; + } + + public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) + { + LastConfigJson = driverConfigJson; + Interlocked.Increment(ref InitializeCalls); + lock (Trace) Trace.Add("init"); + if (OnInitialize is not null) await OnInitialize(cancellationToken).ConfigureAwait(false); + } + + public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) + { + var idx = Interlocked.Increment(ref DiscoverCalls); + lock (Trace) Trace.Add("discover"); + if (OnDiscover is not null) + { + await OnDiscover(builder, idx).ConfigureAwait(false); + return; + } + // Default: stream one leaf so the captured tree is non-empty. + builder.Variable("Tag1", "Tag1", + new DriverAttributeInfo("Dev.Tag1", DriverDataType.Float64, false, null, + SecurityClassification.Operate, false)); + } + + public async Task ShutdownAsync(CancellationToken cancellationToken) + { + Interlocked.Increment(ref ShutdownCalls); + lock (Trace) Trace.Add("shutdown"); + try + { + if (OnShutdown is not null) await OnShutdown(cancellationToken).ConfigureAwait(false); + } + finally + { + ShutdownDone.TrySetResult(); + } + } + + public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask; + public DriverHealth GetHealth() => new(DriverState.Healthy, null, null); + public long GetMemoryFootprint() => 0; + public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} + +/// An IDriver that is NOT ITagDiscovery — exercises the not-discovery-capable gate. +internal sealed class FakeNonDiscoveryDriver(string driverType = "Flat", string id = "flat-1") : IDriver +{ + public int ShutdownCalls; + public TaskCompletionSource ShutdownDone { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public string DriverInstanceId { get; } = id; + public string DriverType { get; } = driverType; + public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask; + public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask; + public Task ShutdownAsync(CancellationToken cancellationToken) + { + Interlocked.Increment(ref ShutdownCalls); + ShutdownDone.TrySetResult(); + return Task.CompletedTask; + } + public DriverHealth GetHealth() => new(DriverState.Healthy, null, null); + public long GetMemoryFootprint() => 0; + public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} + +/// Configurable IDriverFactory: delegates construction to a supplied func and records what it made. +internal sealed class FakeDriverFactory(Func create) : IDriverFactory +{ + public int CreateCalls; + public List Created { get; } = []; + + public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) + { + Interlocked.Increment(ref CreateCalls); + var d = create(driverType, driverInstanceId, driverConfigJson); + if (d is not null) lock (Created) Created.Add(d); + return d; + } + + public IReadOnlyCollection SupportedTypes { get; } = Array.Empty(); + + /// Factory that always returns a fresh default FakeDiscoveryDriver. + public static FakeDriverFactory Of(Func make) => + new((_, _, _) => make()); +} diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserTests.cs new file mode 100644 index 00000000..14dec972 --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserTests.cs @@ -0,0 +1,145 @@ +using Microsoft.Extensions.Logging.Abstractions; +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 DiscoveryDriverBrowserTests +{ + private static readonly TimeSpan ShortOpen = TimeSpan.FromSeconds(5); + private static readonly TimeSpan ShortShutdown = TimeSpan.FromMilliseconds(200); + + private static DiscoveryDriverBrowser Browser(IDriverFactory factory) => + new(factory, NullLogger.Instance, ShortOpen, ShortShutdown); + + [Fact] + public async Task OpenAsync_RunsInitializeDiscoverShutdown_InOrder() + { + var driver = new FakeDiscoveryDriver(); + var browser = Browser(FakeDriverFactory.Of(() => driver)); + + var session = await browser.OpenAsync("Fake", "{}", CancellationToken.None); + + session.ShouldNotBeNull(); + driver.Trace.ShouldBe(new[] { "init", "discover", "shutdown" }); + driver.InitializeCalls.ShouldBe(1); + driver.ShutdownCalls.ShouldBe(1); + } + + [Fact] + public async Task OpenAsync_ShutdownRuns_EvenWhenDiscoverThrows() + { + var driver = new FakeDiscoveryDriver + { + OnDiscover = (_, _) => throw new InvalidOperationException("boom-discovery"), + }; + var browser = Browser(FakeDriverFactory.Of(() => driver)); + + var ex = await Should.ThrowAsync( + () => browser.OpenAsync("Fake", "{}", CancellationToken.None)); + ex.Message.ShouldBe("boom-discovery"); // discovery error surfaces, not a shutdown error + driver.ShutdownCalls.ShouldBe(1); // shutdown still ran + } + + [Fact] + public async Task OpenAsync_UnknownType_ThrowsCleanMessage() + { + var browser = Browser(new FakeDriverFactory((_, _, _) => null)); + var ex = await Should.ThrowAsync( + () => browser.OpenAsync("Ghost", "{}", CancellationToken.None)); + ex.Message.ShouldContain("Ghost"); + } + + [Fact] + public async Task OpenAsync_NotDiscoveryCapable_Throws() + { + var browser = Browser(new FakeDriverFactory((_, _, _) => new FakeNonDiscoveryDriver())); + await Should.ThrowAsync( + () => browser.OpenAsync("Flat", "{}", CancellationToken.None)); + } + + [Fact] + public async Task OpenAsync_FlagFalse_Throws() + { + var browser = Browser(FakeDriverFactory.Of(() => new FakeDiscoveryDriver { SupportsOnlineDiscovery = false })); + await Should.ThrowAsync( + () => browser.OpenAsync("Fake", "{}", CancellationToken.None)); + } + + [Fact] + public void PatchForBrowse_MergesAbCipPatch() + { + var patched = DiscoveryDriverBrowser.PatchForBrowse("AbCip", """{ "Host": "1.2.3.4" }"""); + var obj = System.Text.Json.Nodes.JsonNode.Parse(patched)!.AsObject(); + ((string?)obj["Host"]).ShouldBe("1.2.3.4"); + ((bool?)obj["EnableControllerBrowse"]).ShouldBe(true); + + // Unknown type: unchanged. + DiscoveryDriverBrowser.PatchForBrowse("Modbus", """{ "X": 1 }""").ShouldBe("""{ "X": 1 }"""); + } + + [Fact] + public void PatchForBrowse_DeepMergesFocasFixedTree() + { + var patched = DiscoveryDriverBrowser.PatchForBrowse("FOCAS", """{ "FixedTree": { "Series": "30i" } }"""); + var fixedTree = System.Text.Json.Nodes.JsonNode.Parse(patched)!["FixedTree"]!.AsObject(); + ((string?)fixedTree["Series"]).ShouldBe("30i"); // kept + ((bool?)fixedTree["Enabled"]).ShouldBe(true); // added + } + + [Fact] + public void CanBrowse_TrueForDiscoveryCapable() + { + var browser = Browser(FakeDriverFactory.Of(() => new FakeDiscoveryDriver())); + browser.CanBrowse("Fake", "{}").ShouldBeTrue(); + } + + [Fact] + public void CanBrowse_FalseWhenTryCreateReturnsNull() + { + var browser = Browser(new FakeDriverFactory((_, _, _) => null)); + browser.CanBrowse("Ghost", "{}").ShouldBeFalse(); + } + + [Fact] + public void CanBrowse_FalseWhenTryCreateThrows_NoExceptionEscapes() + { + var browser = Browser(new FakeDriverFactory((_, _, _) => throw new System.Text.Json.JsonException("bad json"))); + Should.NotThrow(() => browser.CanBrowse("Fake", "{ half-typed").ShouldBeFalse()); + } + + [Fact] + public async Task CanBrowse_ShutsDownThrowawayInstance() + { + var driver = new FakeDiscoveryDriver { SupportsOnlineDiscovery = false }; + var factory = FakeDriverFactory.Of(() => driver); + var browser = Browser(factory); + + browser.CanBrowse("Fake", "{}").ShouldBeFalse(); + // Teardown is fire-and-forget — await its completion. + await driver.ShutdownDone.Task.WaitAsync(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken); + driver.ShutdownCalls.ShouldBe(1); + } + + [Fact] + public async Task BoundedShutdown_AbandonsWedgedDriver() + { + // Shutdown ignores its token and would never complete; the browser must abandon it + // and still return the successfully-captured session within the shutdown bound. + var driver = new FakeDiscoveryDriver + { + OnShutdown = _ => Task.Delay(Timeout.Infinite, CancellationToken.None), + }; + var browser = Browser(FakeDriverFactory.Of(() => driver)); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + var session = await browser.OpenAsync("Fake", "{}", CancellationToken.None); + sw.Stop(); + + session.ShouldNotBeNull(); // discovery succeeded → usable session + driver.ShutdownCalls.ShouldBe(1); // shutdown was attempted + sw.Elapsed.ShouldBeLessThan(ShortOpen); // returned on the shutdown bound, not the open timeout + } +} From 698703744fa819ae68438172c6179540a1fd2ab9 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 17:31:50 -0400 Subject: [PATCH 5/9] =?UTF-8?q?feat(browse):=20Wave-0=20Batch=20E=20?= =?UTF-8?q?=E2=80=94=20UntilStable=20settle=20loop=20+=20BrowserSessionSer?= =?UTF-8?q?vice=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 9: DiscoverTreeAsync UntilStable settle loop (FOCAS) — re-capture on a 1s interval until the node set is non-empty and stable across two passes, bounded by open-timeout; on timeout with a prior non-empty capture, return it (best-effort) instead of failing. Task 11: BrowserSessionService resolves bespoke-first, falls back to IUniversalDriverBrowser when no bespoke browser matches and CanBrowse is true; new CanBrowse on the service. 16 unit tests green (4 settle + 12 service). --- ...overy-browser-implementation.md.tasks.json | 4 +- .../Browsing/DiscoveryDriverBrowser.cs | 42 +++++++-- .../Browsing/BrowserSessionService.cs | 27 +++++- .../Browsing/IBrowserSessionService.cs | 8 ++ .../DiscoveryDriverBrowserSettleTests.cs | 87 ++++++++++++++++++ .../Browsing/BrowserSessionServiceTests.cs | 89 ++++++++++++++++++- .../Browsing/FakeUniversalDriverBrowser.cs | 20 +++++ 7 files changed, 266 insertions(+), 11 deletions(-) create mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserSettleTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/FakeUniversalDriverBrowser.cs 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 42c5a951..e5a85ce7 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 @@ -10,9 +10,9 @@ {"id": 6, "subject": "Task 6: CapturingAddressSpaceBuilder + captured-tree model", "status": "completed", "blockedBy": [1, 2]}, {"id": 7, "subject": "Task 7: CapturedTreeBrowseSession", "status": "completed", "blockedBy": [6]}, {"id": 8, "subject": "Task 8: IUniversalDriverBrowser + DiscoveryDriverBrowser core open path", "status": "completed", "blockedBy": [7]}, - {"id": 9, "subject": "Task 9: UntilStable settle loop (FOCAS)", "status": "pending", "blockedBy": [8], "parallelWith": [11]}, + {"id": 9, "subject": "Task 9: UntilStable settle loop (FOCAS)", "status": "completed", "blockedBy": [8], "parallelWith": [11]}, {"id": 10, "subject": "Task 10: Capture coalescing + MaxConcurrentCaptures cap", "status": "pending", "blockedBy": [9], "parallelWith": [12]}, - {"id": 11, "subject": "Task 11: BrowserSessionService universal fallback + CanBrowse", "status": "pending", "blockedBy": [8], "parallelWith": [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": "pending", "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]}, diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/DiscoveryDriverBrowser.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/DiscoveryDriverBrowser.cs index 9f50bb3e..5a47d01f 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/DiscoveryDriverBrowser.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/DiscoveryDriverBrowser.cs @@ -95,7 +95,7 @@ public sealed class DiscoveryDriverBrowser : IUniversalDriverBrowser openCts.CancelAfter(_openTimeout); await driver.InitializeAsync(patched, openCts.Token).ConfigureAwait(false); - return await DiscoverTreeAsync(disc, openCts.Token).ConfigureAwait(false); + return await DiscoverTreeAsync(disc, driverType, openCts.Token).ConfigureAwait(false); } finally { @@ -103,14 +103,44 @@ public sealed class DiscoveryDriverBrowser : IUniversalDriverBrowser } } - // Task 9 replaces this with the UntilStable settle version. - private static async Task DiscoverTreeAsync(ITagDiscovery disc, CancellationToken ct) + private static readonly TimeSpan SettleInterval = TimeSpan.FromSeconds(1); + + private async Task DiscoverTreeAsync(ITagDiscovery disc, string driverType, CancellationToken ct) { - var builder = new CapturingAddressSpaceBuilder(NodeCap); + if (disc.RediscoverPolicy != DiscoveryRediscoverPolicy.UntilStable) + { + var builder = new CapturingAddressSpaceBuilder(NodeCap); #pragma warning disable OTOPCUA0001 // One-shot browse capture, NOT a runtime dispatch path: the driver is a throwaway instance we construct/connect/discover/discard for the picker. CapabilityInvoker (retry/breaker/telemetry) governs live polling, not this admin-side capture. - await disc.DiscoverAsync(builder, ct).ConfigureAwait(false); + await disc.DiscoverAsync(builder, ct).ConfigureAwait(false); #pragma warning restore OTOPCUA0001 - return builder.Build(); + 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 + { +#pragma warning disable OTOPCUA0001 // One-shot browse capture, NOT a runtime dispatch path (see above). + await disc.DiscoverAsync(builder, ct).ConfigureAwait(false); +#pragma warning restore OTOPCUA0001 + 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 + } + } } private async Task BoundedShutdownAsync(IDriver driver, string driverType) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs index 60ac40b4..ffb829db 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs @@ -13,7 +13,8 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing; public sealed class BrowserSessionService( IEnumerable browsers, BrowseSessionRegistry registry, - ILogger logger) : IBrowserSessionService + ILogger logger, + IUniversalDriverBrowser universalBrowser) : IBrowserSessionService { /// Upper bound on a single root/expand/attributes call. public static readonly TimeSpan PerCallTimeout = TimeSpan.FromSeconds(20); @@ -25,7 +26,7 @@ public sealed class BrowserSessionService( public async Task OpenAsync(string driverType, string configJson, CancellationToken ct) { if (!_browsersByType.TryGetValue(driverType, out var browser)) - return new(false, $"No browser registered for driver type '{driverType}'.", Guid.Empty); + return await OpenUniversalAsync(driverType, configJson, ct).ConfigureAwait(false); try { var session = await browser.OpenAsync(configJson, ct).ConfigureAwait(false); @@ -40,6 +41,28 @@ public sealed class BrowserSessionService( } } + /// + public bool CanBrowse(string driverType, string configJson) => + _browsersByType.ContainsKey(driverType) || universalBrowser.CanBrowse(driverType, configJson); + + private async Task 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); + } + } + /// public Task> RootAsync(Guid token, CancellationToken ct) => InvokeAsync(token, ct, (s, c) => s.RootAsync(c)); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/IBrowserSessionService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/IBrowserSessionService.cs index 45886b89..9e25fbb5 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/IBrowserSessionService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/IBrowserSessionService.cs @@ -54,6 +54,14 @@ public interface IBrowserSessionService /// Registry handle for the browse session to close. /// A task that represents the asynchronous operation. Task CloseAsync(Guid token); + + /// 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. + /// The driver type name. + /// The authoring DriverConfig JSON (may be mid-edit). + /// True when the picker should offer a Browse affordance for this driver. + bool CanBrowse(string driverType, string configJson); } /// diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserSettleTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserSettleTests.cs new file mode 100644 index 00000000..2a59f80d --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserSettleTests.cs @@ -0,0 +1,87 @@ +using Microsoft.Extensions.Logging.Abstractions; +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 DiscoveryDriverBrowserSettleTests +{ + private static readonly TimeSpan ShortShutdown = TimeSpan.FromMilliseconds(200); + + private static DiscoveryDriverBrowser Browser(IDriverFactory factory, TimeSpan openTimeout) => + new(factory, NullLogger.Instance, openTimeout, ShortShutdown); + + private static void StreamN(IAddressSpaceBuilder builder, int n) + { + for (var i = 0; i < n; i++) + builder.Variable($"T{i}", $"T{i}", + new DriverAttributeInfo($"Dev.T{i}", DriverDataType.Float64, false, null, + SecurityClassification.Operate, false)); + } + + [Fact] + public async Task UntilStable_RerunsUntilNonEmptyAndStable() + { + // Pass 1 streams nothing (FixedTree cache still filling); passes 2+ stream 3 nodes. + var driver = new FakeDiscoveryDriver + { + RediscoverPolicy = DiscoveryRediscoverPolicy.UntilStable, + OnDiscover = (b, idx) => { if (idx >= 2) StreamN(b, 3); return Task.CompletedTask; }, + }; + var browser = Browser(FakeDriverFactory.Of(() => driver), TimeSpan.FromSeconds(30)); + + var session = await browser.OpenAsync("Fake", "{}", CancellationToken.None); + var root = await session.RootAsync(CancellationToken.None); + + root.Count.ShouldBe(3); + driver.DiscoverCalls.ShouldBeGreaterThanOrEqualTo(3); // empty -> 3 -> 3-stable + } + + [Fact] + public async Task UntilStable_NeverStable_ReturnsLastNonEmptyAtTimeout() + { + // Node count grows every pass — never stabilises; on open-timeout return the last capture. + var driver = new FakeDiscoveryDriver + { + RediscoverPolicy = DiscoveryRediscoverPolicy.UntilStable, + OnDiscover = (b, idx) => { StreamN(b, idx); return Task.CompletedTask; }, + }; + var browser = Browser(FakeDriverFactory.Of(() => driver), TimeSpan.FromSeconds(3)); + + var session = await browser.OpenAsync("Fake", "{}", CancellationToken.None); + var root = await session.RootAsync(CancellationToken.None); + + root.ShouldNotBeEmpty(); // last non-empty capture, not an exception + } + + [Fact] + public async Task UntilStable_NeverAnyNodes_FailsAtOpenTimeout() + { + // Every pass empty — no non-empty capture to fall back to, so the timeout surfaces. + var driver = new FakeDiscoveryDriver + { + RediscoverPolicy = DiscoveryRediscoverPolicy.UntilStable, + OnDiscover = (_, _) => Task.CompletedTask, + }; + var browser = Browser(FakeDriverFactory.Of(() => driver), TimeSpan.FromSeconds(2)); + + await Should.ThrowAsync( + () => browser.OpenAsync("Fake", "{}", CancellationToken.None)); + } + + [Fact] + public async Task OncePolicy_SingleDiscoverPass() + { + var driver = new FakeDiscoveryDriver + { + RediscoverPolicy = DiscoveryRediscoverPolicy.Once, + OnDiscover = (b, _) => { StreamN(b, 3); return Task.CompletedTask; }, + }; + var browser = Browser(FakeDriverFactory.Of(() => driver), TimeSpan.FromSeconds(30)); + + await browser.OpenAsync("Fake", "{}", CancellationToken.None); + driver.DiscoverCalls.ShouldBe(1); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowserSessionServiceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowserSessionServiceTests.cs index d69d8322..1939dfa4 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowserSessionServiceTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowserSessionServiceTests.cs @@ -13,7 +13,11 @@ public sealed class BrowserSessionServiceTests { private static BrowserSessionService NewService( BrowseSessionRegistry registry, params IDriverBrowser[] browsers) => - new(browsers, registry, NullLogger.Instance); + new(browsers, registry, NullLogger.Instance, new FakeUniversalDriverBrowser()); + + private static BrowserSessionService NewService( + BrowseSessionRegistry registry, IUniversalDriverBrowser universal, params IDriverBrowser[] browsers) => + new(browsers, registry, NullLogger.Instance, universal); [Fact] public async Task OpenAsync_unknown_driver_type_returns_Ok_false_with_message() @@ -68,6 +72,89 @@ public sealed class BrowserSessionServiceTests result.Message!.ShouldContain("boom"); } + [Fact] + public async Task Open_BespokeBrowserWinsOverUniversal() + { + var registry = new BrowseSessionRegistry(); + var bespokeSession = new FakeBrowseSession(); + var bespoke = new FakeDriverBrowser("AbCip") + { + OpenHandler = (_, _) => Task.FromResult(bespokeSession), + }; + var universal = new FakeUniversalDriverBrowser + { + CanBrowsePredicate = (_, _) => true, // universal would also serve it + OpenHandler = (_, _, _) => Task.FromResult(new FakeBrowseSession()), + }; + var service = NewService(registry, universal, bespoke); + + var result = await service.OpenAsync("AbCip", "{}", CancellationToken.None); + + result.Ok.ShouldBeTrue(); + result.Token.ShouldBe(bespokeSession.Token); // bespoke session, not the universal one + } + + [Fact] + public async Task Open_FallsBackToUniversal_WhenNoBespokeAndCanBrowse() + { + var registry = new BrowseSessionRegistry(); + var universalSession = new FakeBrowseSession(); + var universal = new FakeUniversalDriverBrowser + { + CanBrowsePredicate = (t, _) => t == "AbCip", + OpenHandler = (_, _, _) => Task.FromResult(universalSession), + }; + var service = NewService(registry, universal); // no bespoke browsers + + var result = await service.OpenAsync("AbCip", "{}", CancellationToken.None); + + result.Ok.ShouldBeTrue(); + result.Token.ShouldBe(universalSession.Token); + registry.TryGet(result.Token, out _).ShouldBeTrue(); + } + + [Fact] + public async Task Open_NoBespokeAndCannotBrowse_ReturnsNoBrowserMessage() + { + var registry = new BrowseSessionRegistry(); + var service = NewService(registry, new FakeUniversalDriverBrowser()); // CanBrowse=false + + var result = await service.OpenAsync("Modbus", "{}", CancellationToken.None); + + result.Ok.ShouldBeFalse(); + result.Token.ShouldBe(Guid.Empty); + result.Message!.ShouldContain("Modbus"); + } + + [Fact] + public async Task Open_UniversalThrow_ReturnsOkFalseWithMessage() + { + var registry = new BrowseSessionRegistry(); + var universal = new FakeUniversalDriverBrowser + { + CanBrowsePredicate = (_, _) => true, + OpenHandler = (_, _, _) => throw new InvalidOperationException("universal-boom"), + }; + var service = NewService(registry, universal); + + var result = await service.OpenAsync("AbCip", "{}", CancellationToken.None); + + result.Ok.ShouldBeFalse(); + result.Message!.ShouldContain("universal-boom"); + } + + [Fact] + public void CanBrowse_TrueForBespokeType_TrueForUniversalType_FalseOtherwise() + { + var registry = new BrowseSessionRegistry(); + var universal = new FakeUniversalDriverBrowser { CanBrowsePredicate = (t, _) => t == "AbCip" }; + var service = NewService(registry, universal, new FakeDriverBrowser("Galaxy")); + + service.CanBrowse("Galaxy", "{}").ShouldBeTrue(); // bespoke registered + service.CanBrowse("AbCip", "{}").ShouldBeTrue(); // universal can serve + service.CanBrowse("Modbus", "{}").ShouldBeFalse(); // neither + } + [Fact] public async Task RootAsync_unknown_token_throws_BrowseSessionNotFoundException() { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/FakeUniversalDriverBrowser.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/FakeUniversalDriverBrowser.cs new file mode 100644 index 00000000..9edd82f1 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/FakeUniversalDriverBrowser.cs @@ -0,0 +1,20 @@ +using ZB.MOM.WW.OtOpcUa.Commons.Browsing; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Browsing; + +/// Test double for . CanBrowse is driven by a +/// predicate (default: never); OpenAsync delegates to a handler or returns a fresh session. +internal sealed class FakeUniversalDriverBrowser : IUniversalDriverBrowser +{ + /// CanBrowse gate. Defaults to "no universal affordance for any type". + public Func CanBrowsePredicate { get; init; } = (_, _) => false; + + /// OpenAsync behavior. Defaults to a fresh FakeBrowseSession. + public Func> OpenHandler { get; init; } = + (_, _, _) => Task.FromResult(new FakeBrowseSession()); + + public bool CanBrowse(string driverType, string configJson) => CanBrowsePredicate(driverType, configJson); + + public Task OpenAsync(string driverType, string configJson, CancellationToken ct) => + OpenHandler(driverType, configJson, ct); +} From bbbda99719eb8d97ffb041bf558727d2f7df69bf Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 17:37:02 -0400 Subject: [PATCH 6/9] =?UTF-8?q?feat(browse):=20Wave-0=20Batch=20F=20?= =?UTF-8?q?=E2=80=94=20capture=20coalescing/cap=20+=20AddAdminUI=20DI=20re?= =?UTF-8?q?gistration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 10: in-flight coalescing (Lazy> keyed on driverType+SHA256(config)) so repeat Browse clicks share one device walk; global SemaphoreSlim cap (MaxConcurrentCaptures=4) queues excess; per-caller ct via WaitAsync leaves the shared capture (bounded by open-timeout, CancellationToken.None) unaffected; in-flight-only map (Refresh = fresh capture). 7 concurrency tests green (158 total in Commons.Tests). Task 12: AddAdminUI registers IUniversalDriverBrowser->DiscoveryDriverBrowser with NullDriverFactory fallback (standalone AdminUI => CanBrowse false => manual entry). AdminUI.Tests 542 green. --- ...overy-browser-implementation.md.tasks.json | 4 +- .../Browsing/DiscoveryDriverBrowser.cs | 39 +++- .../EndpointRouteBuilderExtensions.cs | 8 + .../DiscoveryDriverBrowserConcurrencyTests.cs | 177 ++++++++++++++++++ 4 files changed, 225 insertions(+), 3 deletions(-) create mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserConcurrencyTests.cs 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 e5a85ce7..ce5d48fd 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 @@ -11,9 +11,9 @@ {"id": 7, "subject": "Task 7: CapturedTreeBrowseSession", "status": "completed", "blockedBy": [6]}, {"id": 8, "subject": "Task 8: IUniversalDriverBrowser + DiscoveryDriverBrowser core open path", "status": "completed", "blockedBy": [7]}, {"id": 9, "subject": "Task 9: UntilStable settle loop (FOCAS)", "status": "completed", "blockedBy": [8], "parallelWith": [11]}, - {"id": 10, "subject": "Task 10: Capture coalescing + MaxConcurrentCaptures cap", "status": "pending", "blockedBy": [9], "parallelWith": [12]}, + {"id": 10, "subject": "Task 10: Capture coalescing + MaxConcurrentCaptures cap", "status": "completed", "blockedBy": [9], "parallelWith": [12]}, {"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": "pending", "blockedBy": [11], "parallelWith": [10]}, + {"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]}, diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/DiscoveryDriverBrowser.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/DiscoveryDriverBrowser.cs index 5a47d01f..6c12e808 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/DiscoveryDriverBrowser.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/DiscoveryDriverBrowser.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Text.Json.Nodes; using Microsoft.Extensions.Logging; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; @@ -18,6 +19,9 @@ public sealed class DiscoveryDriverBrowser : IUniversalDriverBrowser /// Node-count cap the capturing builder enforces before marking the tree truncated. public const int NodeCap = 50_000; + /// Global cap on simultaneous captures (§4.2). Coalesced awaiters don't hold a slot. + public const int MaxConcurrentCaptures = 4; + /// Per-type declarative config patch guaranteeing browse mode at open time (§5.1). /// A dictionary entry per config-gated driver — not code. internal static readonly IReadOnlyDictionary BrowsePatches = @@ -33,6 +37,8 @@ public sealed class DiscoveryDriverBrowser : IUniversalDriverBrowser private readonly ILogger _logger; private readonly TimeSpan _openTimeout; private readonly TimeSpan _shutdownTimeout; + private readonly ConcurrentDictionary>> _inflight = new(); + private readonly SemaphoreSlim _captureSlots = new(MaxConcurrentCaptures, MaxConcurrentCaptures); /// Production constructor: standard open + shutdown bounds. /// Factory used to construct throwaway/capture driver instances. @@ -75,10 +81,41 @@ public sealed class DiscoveryDriverBrowser : IUniversalDriverBrowser /// public async Task OpenAsync(string driverType, string configJson, CancellationToken ct) { - var tree = await CaptureAsync(driverType, configJson, ct).ConfigureAwait(false); + var key = CaptureKey(driverType, configJson); + // Lazy: exactly one RunCaptureAsync per key even under GetOrAdd races. + var capture = _inflight.GetOrAdd(key, + _ => new Lazy>(() => 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 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 ?? ""))); + private async Task CaptureAsync(string driverType, string configJson, CancellationToken ct) { var patched = PatchForBrowse(driverType, configJson); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs index d8a1eb98..5f2578c6 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs @@ -3,9 +3,11 @@ using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs; using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; using ZB.MOM.WW.OtOpcUa.Commons.Browsing; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser; using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser; @@ -48,6 +50,12 @@ public static class EndpointRouteBuilderExtensions services.AddScoped(); services.AddSingleton(); services.AddSingleton(); + // 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(sp => new DiscoveryDriverBrowser( + sp.GetService() ?? NullDriverFactory.Instance, + sp.GetRequiredService>())); // Roslyn-backed Monaco script-editor analysis (diagnostics/completions/hover/...). services.AddScoped(); diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserConcurrencyTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserConcurrencyTests.cs new file mode 100644 index 00000000..610585a6 --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserConcurrencyTests.cs @@ -0,0 +1,177 @@ +using Microsoft.Extensions.Logging.Abstractions; +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 DiscoveryDriverBrowserConcurrencyTests +{ + private static readonly TimeSpan LongOpen = TimeSpan.FromSeconds(30); + private static readonly TimeSpan ShortShutdown = TimeSpan.FromMilliseconds(200); + private static readonly TimeSpan Wait = TimeSpan.FromSeconds(5); + + private static DiscoveryDriverBrowser Browser(IDriverFactory factory) => + new(factory, NullLogger.Instance, LongOpen, ShortShutdown); + + /// A driver gate: signals when Initialize is entered, blocks until released. + private sealed class Gate + { + public readonly SemaphoreSlim Entered = new(0); + public readonly TaskCompletionSource Release = new(TaskCreationOptions.RunContinuationsAsynchronously); + } + + private static FakeDiscoveryDriver Gated(Gate gate) => new() + { + OnInitialize = async ct => + { + gate.Entered.Release(); + await gate.Release.Task.WaitAsync(ct); + }, + }; + + [Fact] + public async Task SameKey_TwoConcurrentOpens_OneCapture_DistinctTokens() + { + var gate = new Gate(); + var factory = FakeDriverFactory.Of(() => Gated(gate)); + var browser = Browser(factory); + + var open1 = browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None); + var open2 = browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None); + + await gate.Entered.WaitAsync(Wait, TestContext.Current.CancellationToken); // one driver entered Initialize + factory.CreateCalls.ShouldBe(1); // coalesced — only one driver constructed + gate.Release.SetResult(); + + var s1 = await open1; + var s2 = await open2; + s1.Token.ShouldNotBe(s2.Token); // distinct sessions... + (await s1.RootAsync(CancellationToken.None)).Count + .ShouldBe((await s2.RootAsync(CancellationToken.None)).Count); // ...over the same tree + ((FakeDiscoveryDriver)factory.Created[0]).InitializeCalls.ShouldBe(1); + } + + [Fact] + public async Task SameKey_DisposingOneSession_LeavesOtherBrowsable() + { + var gate = new Gate(); + var browser = Browser(FakeDriverFactory.Of(() => Gated(gate))); + + var open1 = browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None); + var open2 = browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None); + await gate.Entered.WaitAsync(Wait, TestContext.Current.CancellationToken); + gate.Release.SetResult(); + + var s1 = await open1; + var s2 = await open2; + await s1.DisposeAsync(); + (await s2.RootAsync(CancellationToken.None)).ShouldNotBeEmpty(); // shared immutable tree survives + } + + [Fact] + public async Task SameKey_FailedCapture_FailsBothAwaiters() + { + var factory = FakeDriverFactory.Of(() => new FakeDiscoveryDriver + { + OnDiscover = (_, _) => throw new InvalidOperationException("shared-boom"), + }); + var browser = Browser(factory); + + var open1 = browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None); + var open2 = browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None); + + (await Should.ThrowAsync(() => open1)).Message.ShouldBe("shared-boom"); + (await Should.ThrowAsync(() => open2)).Message.ShouldBe("shared-boom"); + } + + [Fact] + public async Task SameKey_OpenAfterCompletion_RunsFreshCapture() + { + var factory = FakeDriverFactory.Of(() => new FakeDiscoveryDriver()); // ungated, completes fast + var browser = Browser(factory); + + await browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None); + await browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None); + + factory.CreateCalls.ShouldBe(2); // in-flight-only map: second open is a fresh capture (Refresh path) + } + + [Fact] + public async Task DistinctKeys_CapLimitsInflightCaptures() + { + const int cap = DiscoveryDriverBrowser.MaxConcurrentCaptures; // 4 + const int total = cap + 2; // 6 + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var entered = 0; + var factory = new FakeDriverFactory((_, _, _) => new FakeDiscoveryDriver + { + OnInitialize = async ct => { Interlocked.Increment(ref entered); await release.Task.WaitAsync(ct); }, + }); + var browser = Browser(factory); + + var opens = Enumerable.Range(0, total) + .Select(i => browser.OpenAsync("Fake", $$"""{"i":{{i}}}""", CancellationToken.None)) + .ToArray(); + + // Give the semaphore time to admit exactly `cap` captures into Initialize. + await Task.Delay(TimeSpan.FromMilliseconds(500), TestContext.Current.CancellationToken); + Volatile.Read(ref entered).ShouldBe(cap); // at most 4 in flight; the other 2 queue on the semaphore + + release.SetResult(); + var sessions = await Task.WhenAll(opens); + sessions.Length.ShouldBe(total); + factory.CreateCalls.ShouldBe(total); // all eventually run once a slot frees + } + + [Fact] + public async Task QueuedOpen_ObservesCallerCancellation() + { + const int cap = DiscoveryDriverBrowser.MaxConcurrentCaptures; // 4 + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var factory = new FakeDriverFactory((_, _, _) => new FakeDiscoveryDriver + { + OnInitialize = async ct => { await release.Task.WaitAsync(ct); }, + }); + var browser = Browser(factory); + + // Fill all slots with distinct-key captures. + var inflight = Enumerable.Range(0, cap) + .Select(i => browser.OpenAsync("Fake", $$"""{"i":{{i}}}""", CancellationToken.None)) + .ToArray(); + await Task.Delay(TimeSpan.FromMilliseconds(300), TestContext.Current.CancellationToken); + + // A queued open with a cancelable token — it waits for a slot, then the caller cancels. + using var cts = new CancellationTokenSource(); + var queued = browser.OpenAsync("Fake", """{"i":"queued"}""", cts.Token); + cts.Cancel(); + await Should.ThrowAsync(() => queued); + + // The in-flight captures are unaffected. + release.SetResult(); + var done = await Task.WhenAll(inflight); + done.Length.ShouldBe(cap); + } + + [Fact] + public async Task CoalescedAwaiter_CancellationDoesNotCancelSharedCapture() + { + var gate = new Gate(); + var factory = FakeDriverFactory.Of(() => Gated(gate)); + var browser = Browser(factory); + + using var cts1 = new CancellationTokenSource(); + var open1 = browser.OpenAsync("Fake", """{"h":"1"}""", cts1.Token); + var open2 = browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None); + + await gate.Entered.WaitAsync(Wait, TestContext.Current.CancellationToken); + cts1.Cancel(); // first caller abandons + await Should.ThrowAsync(() => open1); + + gate.Release.SetResult(); // shared capture proceeds regardless + var s2 = await open2; + (await s2.RootAsync(CancellationToken.None)).ShouldNotBeEmpty(); + ((FakeDiscoveryDriver)factory.Created[0]).InitializeCalls.ShouldBe(1); + } +} From 80cda8f227311d4b4340c52deddd713121620539 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 17:41:21 -0400 Subject: [PATCH 7/9] =?UTF-8?q?feat(browse):=20Wave-0=20Batch=20G=20?= =?UTF-8?q?=E2=80=94=20AbCip/TwinCAT/FOCAS=20picker=20bodies=20gain=20univ?= =?UTF-8?q?ersal=20browse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 14/15/16: each picker body keeps its manual builder and appends a DriverOperator-gated browse affordance (mirrors OpcUaClientAddressPickerBody) — Browse/Refresh/Close, DriverBrowseTree over the universal DiscoveryDriverBrowser session, leaf-only commit (OnNodeSelected fires for folders too), snapshot-at-open label, fire-and-forget close on dispose. _canBrowse evaluated once in OnInitializedAsync (CanBrowse constructs a throwaway driver). FOCAS commits the leaf FullName directly (the group/id builder can't reconstruct it). Editors pass DriverType + GetConfigJson through. Razor-live-verified at Task 18. --- ...overy-browser-implementation.md.tasks.json | 6 +- .../Pickers/AbCipAddressPickerBody.razor | 112 ++++++++++++++++- .../Pickers/FOCASAddressPickerBody.razor | 115 +++++++++++++++++- .../Pickers/TwinCATAddressPickerBody.razor | 111 ++++++++++++++++- .../Uns/TagEditors/AbCipTagConfigEditor.razor | 4 +- .../Uns/TagEditors/FocasTagConfigEditor.razor | 4 +- .../TagEditors/TwinCATTagConfigEditor.razor | 4 +- 7 files changed, 341 insertions(+), 15 deletions(-) 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
@@ -22,6 +32,42 @@
+@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
@@ -18,6 +30,42 @@
+@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
@@ -11,6 +21,42 @@
+@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" /> } From 014c76b986dc83145ea6c7600449d81c20ed2a7d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 17:42:42 -0400 Subject: [PATCH 8/9] =?UTF-8?q?docs(browse):=20Wave-0=20status=20flip=20?= =?UTF-8?q?=E2=80=94=20universal=20browser=20P1=20implemented=20(live=20ga?= =?UTF-8?q?te=20pending)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 17: design doc Status draft -> P1 implemented; program doc §9 Code not started -> Wave 0 landed. Full build 0 errors; touched suites green (Core.Abstractions 135, Commons 158, AdminUI 542, AbCip 342, TwinCAT 191, FOCAS 271). --- docs/plans/2026-07-15-driver-expansion-program-design.md | 8 ++++++-- .../2026-07-15-universal-discovery-browser-design.md | 3 ++- ...iversal-discovery-browser-implementation.md.tasks.json | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/plans/2026-07-15-driver-expansion-program-design.md b/docs/plans/2026-07-15-driver-expansion-program-design.md index 52f2a1b8..afff3f64 100644 --- a/docs/plans/2026-07-15-driver-expansion-program-design.md +++ b/docs/plans/2026-07-15-driver-expansion-program-design.md @@ -215,5 +215,9 @@ MELSEC is out of the sequence (deferred). ## 9. Status -Research: **done** (8 reports). Designs: **done** (7 design docs). Code: **not started.** -Next action: execute Wave 0 (universal browser), then Wave 1. +Research: **done** (8 reports). Designs: **done** (7 design docs). Code: **Wave 0 landed** — +the universal Discover-backed browser (P1) is code-complete and unit-tested on +`feat/universal-discovery-browser` (`CapturingAddressSpaceBuilder` + `CapturedTreeBrowseSession` ++ `DiscoveryDriverBrowser`/`IUniversalDriverBrowser` + `BrowserSessionService` fallback + +`ITagDiscovery.SupportsOnlineDiscovery` gate + AbCip/TwinCAT/FOCAS opt-ins & picker browse UI), +with the live `/run` gate outstanding. Next action: complete the Wave-0 live gate, then Wave 1. diff --git a/docs/plans/2026-07-15-universal-discovery-browser-design.md b/docs/plans/2026-07-15-universal-discovery-browser-design.md index a5b5f05d..7966f785 100644 --- a/docs/plans/2026-07-15-universal-discovery-browser-design.md +++ b/docs/plans/2026-07-15-universal-discovery-browser-design.md @@ -1,6 +1,7 @@ # Universal Discover-backed browser (`DiscoveryDriverBrowser`) — design -**Status:** draft, 2026-07-15. Foundational Wave-0 item of the next-driver roadmap +**Status:** P1 implemented 2026-07-15 (code-complete + unit-tested, live `/run` gate pending); +P2/P3 pending. Foundational Wave-0 item of the next-driver roadmap (`docs/research/drivers/README.md`). ## 1. Motivation 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 4c3f0a60..0c5f9151 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 @@ -18,7 +18,7 @@ {"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": 17, "subject": "Task 17: Full build/test sweep + doc status flip", "status": "completed", "blockedBy": [14, 15, 16]}, {"id": 18, "subject": "Task 18: Live /run verify on docker-dev (GATE)", "status": "pending", "blockedBy": [17]} ], "lastUpdated": "2026-07-15T17:30:00Z" From 26ef8131b0e1001054660d658171de2995822608 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 18:14:03 -0400 Subject: [PATCH 9/9] docs(browse): Wave-0 live-gate result + follow-ups (Task 18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live-verified on docker-dev: AbCip picker renders Browse button (full CanBrowse chain), clicking it runs the universal-browser capture in production (central-2 logs) with PatchForBrowse demonstrably applied (@tags walk ran without the authored config setting EnableControllerBrowse); error surfaces gracefully. Modbus picker shows NO Browse button (negative). Populated tree-render is fixture-blocked — ab_server returns ErrorUnsupported for the controller @tags walk (test sim limitation, not a code defect). Two follow-ups recorded: (1) full tree-render needs a symbol-browse capable AB backend; (2) driver-page pickers don't pass live config to the picker body. --- ...versal-discovery-browser-implementation.md | 34 +++++++++++++++++++ ...overy-browser-implementation.md.tasks.json | 2 +- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-07-15-universal-discovery-browser-implementation.md b/docs/plans/2026-07-15-universal-discovery-browser-implementation.md index a08bbe5b..5f946d95 100644 --- a/docs/plans/2026-07-15-universal-discovery-browser-implementation.md +++ b/docs/plans/2026-07-15-universal-discovery-browser-implementation.md @@ -1083,6 +1083,40 @@ Identical shape (confirm DriverType string from the FOCAS `Register()`). Note in --- +## 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. + +### Follow-ups discovered during the gate + +1. **Full tree-render verification is fixture-blocked.** `ab_server` doesn't implement the `@tags` controller + walk. To live-verify a populated tree + `TagConfig.FullName` leaf commit, point the universal browser at a + symbol-enumeration-capable AB backend (a real ControlLogix, or a sim that serves `@tags`). +2. **Driver-page address pickers pass no live config.** `AbCipDriverPage`/`TwinCATDriverPage`/`FOCASDriverPage` + host the same `*AddressPickerBody` but do **not** pass `DriverType`/`GetConfigJson`, so their (now-visible) + Browse button would capture against `"{}"` (no device). Out of Task 14's scope (which listed only the + TagEditor pass-through); wire the live form config through there too, or hide the Browse affordance on the + driver-config picker. + ## Out of scope (explicitly) - TwinCAT/FOCAS live-fixture verification (no shipped fixtures assumed reachable; AbCip is the gate). 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 0c5f9151..f34d5db4 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 @@ -19,7 +19,7 @@ {"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": "completed", "blockedBy": [14, 15, 16]}, - {"id": 18, "subject": "Task 18: Live /run verify on docker-dev (GATE)", "status": "pending", "blockedBy": [17]} + {"id": 18, "subject": "Task 18: Live /run verify on docker-dev (GATE)", "status": "completed", "blockedBy": [17]} ], "lastUpdated": "2026-07-15T17:30:00Z" }