Files
lmxopcua/docs/plans/2026-07-15-universal-discovery-browser-design.md
T
Joseph Doherty fd0bec4ffe
v2-ci / build (push) Successful in 3m30s
v2-ci / unit-tests (push) Failing after 8m53s
docs(driver-expansion): resolve all open design concerns from the review pass
Every concern the 7-agent review parked is now decided and integrated:

- universal browser: in-flight capture coalescing keyed (driverType,
  config-hash) + global cap of 4 concurrent captures; CanBrowse catches a
  throwing TryCreate and defensively shuts down the throwaway instance;
  cleanup ShutdownAsync bounded at 10s (R2-01); picker Refresh = close +
  re-capture. Coalesced sessions share an immutable tree, so DisposeAsync
  drops the session's reference, not the tree.
- mtconnect: UNAVAILABLE pinned to BadNoCommunication (fleet's
  BadCommunicationError stays reserved for the driver's own transport
  failures); TIME_SERIES sampleCount flows into ArrayDim; Subscribe timeout
  bounds only the stream-start handshake; library version/TFM folded into
  the license checklist.
- mqtt: browse rebirth is an explicit DriverOperator-gated "Request rebirth"
  button (RequestRebirthAsync(scope)) - never fired by open/root/expand;
  hand-rolled reconnect loop committed for P1 (MQTTnet v5 dropped
  ManagedMqttClient); Wave-2-start library re-verification checkbox;
  implement from the Sparkplug v3.0 spec text.
- bacnet: P1 opens with a first-spike checklist (package TFM, BBMD/
  segmented-RPM/COV API smoke, unicast-I-Am fixture behavior); AdminUI-node
  browse registers a second foreign device - BBMD FD-table sizing note.
- sql-poll: split-node browse needs env parity for Sql__ConnectionStrings__
  refs on admin nodes (actionable error + session-only pasted literal);
  one-row-per-key query contract (last-wins + rate-limited warning);
  absent key -> BadNoData; operationTimeout > commandTimeout authoring rule;
  Browser->Driver.Sql SqlClient transitive on AdminUI accepted on record.
- omron: FINS framer gated on W227 + live golden vectors; CIP string layout
  is the first P1 live-gate item; AbCip harness static-init anti-pattern
  note; writable-defaults-true operator warning.
- modbus-rtu: first P1 step confirms pymodbus exposes framer=rtu on a TCP
  server (custom-script fallback otherwise).
- program doc: new cross-cutting rule - driver ctors must be connection-free
  (the universal browser's CanBrowse throwaway instances depend on it).
2026-07-15 16:54:09 -04:00

376 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Universal Discover-backed browser (`DiscoveryDriverBrowser`) — design
**Status:** draft, 2026-07-15. Foundational Wave-0 item of the next-driver roadmap
(`docs/research/drivers/README.md`).
## 1. Motivation
Today the AdminUI address picker is backed by one **bespoke** `IDriverBrowser` per driver
type — only **OpcUaClient** and **Galaxy** exist, each a full hand-written
`IBrowseSession` (connect + lazy level-at-a-time browse). Every other driver's picker is a
static manual-entry stub.
But the existing-driver browse audit
(`docs/research/drivers/00-existing-driver-browse-audit.md`) found that AbCip, TwinCAT, and
FOCAS **already contain complete, unit-tested symbol enumeration** — it lives in their
runtime `ITagDiscovery.DiscoverAsync`, streamed into an `IAddressSpaceBuilder`, and is
simply never surfaced to the picker. The same is true of every future discovery-capable
driver (MTConnect `/probe`, BACnet Who-Is/object-list).
The insight: **`DiscoverAsync` already produces exactly the tree the picker wants.** Instead
of writing N bespoke browsers, write **one** generic browser that captures whatever a driver
streams into the builder and re-presents it. This is the highest-leverage single piece of
browse work on the roadmap.
## 2. The seam that makes it work
`ITagDiscovery.DiscoverAsync(IAddressSpaceBuilder builder, ct)` does not return a tree — it
**streams structured nodes into a builder** (`Core.Abstractions/IAddressSpaceBuilder.cs`):
```csharp
IAddressSpaceBuilder Folder(string browseName, string displayName); // → child builder
IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo a);
void AddProperty(string browseName, DriverDataType t, object? v);
```
Two facts close the loop:
- `Folder(...)` returns a **child builder scoped inside the folder** → the driver's hierarchy
is expressed as the builder-nesting graph. Capture the nesting and you inherit the driver's
tree (folders, UDT structure, component nesting) — this is **not** a flat dump.
- `DriverAttributeInfo.FullName` **is the driver-side full reference** for read/write
addressing — i.e. exactly the value the picker commits as `TagConfig.FullName`
(`DriverAttributeInfo.cs:13`). No per-driver commit logic needed.
So a **capturing `IAddressSpaceBuilder`** run against any driver's `DiscoverAsync` yields a
`BrowseNode` tree whose leaves already carry the commit value and the datatype metadata.
## 3. Architecture — two-tier browse
```
BrowserSessionService.OpenAsync(driverType, configJson)
├─ bespoke IDriverBrowser registered for driverType? ── yes ──► use it (Tier 2)
│ (OpcUaClient, Galaxy, later: large ControlLogix/BACnet)
└─ no bespoke browser ───► DiscoveryDriverBrowser (Tier 1, universal fallback)
gated on online-discovery capability
```
- **Tier 1 — universal `DiscoveryDriverBrowser`** (this doc): the default. Covers any driver
whose `DiscoverAsync` enumerates from the *device*.
- **Tier 2 — bespoke `IBrowseSession`** (existing pattern, `2026-05-28-driver-browsers-design.md`):
reserved for large / structured / lazy address spaces where eager one-shot discovery is too
heavy. A bespoke browser **overrides** the universal one simply by being registered for that
`DriverType`.
### 3.1 Resolution change in `BrowserSessionService` (required)
`BrowserSessionService` currently indexes browsers with
`browsers.ToDictionary(b => b.DriverType)` — **one browser per type, and a duplicate
`DriverType` key throws at construction.** Therefore the universal browser **cannot** be one
of the injected `IEnumerable<IDriverBrowser>` (it has no single `DriverType`, and registering
it under every type would collide). Instead:
- Introduce `IUniversalDriverBrowser` with
`Task<IBrowseSession> OpenAsync(string driverType, string configJson, CancellationToken ct)`
and `bool CanBrowse(string driverType, string configJson)`.
- Inject it into `BrowserSessionService` as a **separate optional dependency** (not part of the
`IDriverBrowser` set). `OpenAsync` resolves bespoke-first, falls back to the universal browser
when no bespoke browser matches **and** `CanBrowse` is true; otherwise returns the existing
"no browser registered" result (→ picker shows manual entry).
- `RootAsync`/`ExpandAsync`/`AttributesAsync`/`CloseAsync` are unchanged — the universal
browser returns an ordinary `IBrowseSession` that lands in the same `BrowseSessionRegistry`
(TTL reaper, 20 s per-call timeout) as today.
## 4. Components
### 4.1 `CapturingAddressSpaceBuilder` (the core mechanism)
An in-memory `IAddressSpaceBuilder` that records the streamed tree instead of materializing
OPC UA nodes. New home: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/` (Commons already owns
`BrowseNode`/`IBrowseSession` and is referenced by AdminUI; `IAddressSpaceBuilder`/
`DriverAttributeInfo` live in `Core.Abstractions`, which Commons can reference).
| Builder call | Captured as | Serves |
|---|---|---|
| `Folder(bn, dn)` | node `{ id = parentPath + "/" + bn, display = dn, Folder, children[] }`; returns a child capturing builder scoped to it | `BrowseNode(id, dn, Folder, HasChildrenHint=true)` |
| `Variable(bn, dn, attr)` | leaf `{ id = attr.FullName, display = dn, Leaf, attr }`; returns a stub `IVariableHandle{ FullReference = attr.FullName }` | `BrowseNode(attr.FullName, dn, Leaf, false)` + `AttributeInfo` |
| `AddProperty(...)` | attached to the current node's property bag (optional; feeds the side-panel) | `AttributesAsync` |
| `IVariableHandle.MarkAsAlarmCondition(info)` | records `IsAlarm=true` on the leaf; returns a **no-op `IAlarmConditionSink`** (browse never delivers live transitions) | `AttributeInfo.IsAlarm=true` (picker pre-fills a default `alarm` block, per existing Galaxy behaviour) |
Leaf → `AttributeInfo` mapping (drives the side-panel + commit):
`AttributeInfo(Name: bn, DriverDataType: attr.DriverDataType.ToString(), IsArray: attr.IsArray,
SecurityClass: attr.SecurityClass.ToString(), IsAlarm: attr.IsAlarm)`. Commit `NodeId` =
`attr.FullName``TagConfig.FullName`; `attr.ArrayDim`/`IsHistorized`/`WriteIdempotent` are
available to pre-fill the typed tag editor.
The builder enforces a **node-count cap** (config, default e.g. 50 000): once exceeded it stops
recording and marks the tree `truncated=true` so the session can surface "results truncated —
narrow the driver config or use manual entry" rather than OOM on a 100 k-node backend.
### 4.2 `DiscoveryDriverBrowser : IUniversalDriverBrowser`
`OpenAsync(driverType, configJson, ct)`:
1. `driver = _driverFactory.TryCreate(driverType, ephemeralId, PatchForBrowse(driverType, configJson))`
`null` ⇒ throw a clean "driver type not available in this host" (see §6 wiring).
2. `if (driver is not ITagDiscovery disc || !disc.SupportsOnlineDiscovery)` ⇒ throw
"driver has no online discovery — author tags manually" (should not happen — `CanBrowse`
already gated it, but defend).
3. `await driver.InitializeAsync(patchedConfigJson, openCts.Token)`**real device connect**,
under an **open-timeout** (config, default 60 s; separate from the 20 s per-call browse
timeout, which only covers Root/Expand/Attributes serving from memory).
4. `var capture = new CapturingAddressSpaceBuilder(nodeCap); await disc.DiscoverAsync(capture, openCts.Token);`
— eager, whole-tree, one-shot. **Except when `disc.RediscoverPolicy == UntilStable`**
(FOCAS): its `FixedTreeCache` is filled by a background loop a couple of seconds *after*
connect, so an immediate single pass captures an empty device folder (the comment above
`FocasDriver.DiscoverAsync` states this explicitly). For `UntilStable` drivers, re-run the
capture on a short interval until the node set is non-empty and stable across two passes,
bounded by the open-timeout — the same contract `DriverInstanceActor` honours at deploy.
5. `await driver.ShutdownAsync(...)` in a `finally` — the tree is fully captured; hold nothing
live. (No lazy expand ⇒ no need to keep the connection.) **The shutdown itself is bounded**
(the R2-01 per-op-deadline rule, program doc §3.1 — no unbounded waits anywhere): it runs
under its **own 10 s linked CTS**, because a driver wedged during discovery may be equally
wedged in shutdown, and cleanup must not extend the open beyond open-timeout + that bound.
On shutdown timeout: log a warning, **abandon the instance** (drop the reference — never
block on it or rethrow), and still return / fail the open on the **discovery outcome**
a hung shutdown neither fails a successful capture nor masks the real discovery error.
6. return `new CapturedTreeBrowseSession(capture.Root)`.
#### Capture concurrency — coalescing + global cap
Every `OpenAsync` is heavy: it constructs, connects, and fully enumerates a **real driver
against the live device**. Repeated Browse clicks (or two operators picking against the same
controller) must not stack parallel symbol walks on one PLC. Two controls, both inside
`DiscoveryDriverBrowser`:
- **In-flight coalescing**, keyed on `(driverType, hash of configJson)` (e.g. SHA-256 of the
caller-supplied JSON; `PatchForBrowse` is a pure function of `driverType`, so hashing the
pre-patch config is equivalent per key). A second open for the same key **awaits the same
capture task** rather than starting another walk; each awaiting caller then receives its
**own** `CapturedTreeBrowseSession` (own token, own TTL in the `BrowseSessionRegistry`)
over the **shared, immutable captured tree**. A failed capture fails every coalesced
awaiter with the same error. The coalescing map holds **in-flight captures only** — no
result caching: the entry is removed when the capture completes (success or failure), so
the next open for that key (including a Refresh, §4.3) triggers a fresh capture.
- **A global cap on simultaneous captures** — `MaxConcurrentCaptures = 4` (a const, like
`BrowserSessionService.PerCallTimeout`). Excess opens **queue** (semaphore, FIFO) and still
honour the caller's cancellation token while queued. Coalesced awaiters don't consume a
slot — only distinct in-flight captures count against the cap.
`CanBrowse(driverType, configJson)` = `TryCreate` (cheap, no connect) yields a non-null
instance **and** that instance is `ITagDiscovery { SupportsOnlineDiscovery: true }`. Used by
the AdminUI to decide whether to render the **Browse** button vs. manual entry, before any
connect. Two hardening rules:
- **`TryCreate` can throw, not just return null.** Factories parse `configJson` *inside*
`TryCreate`, so malformed/half-typed JSON (exactly what a picker holds mid-authoring) can
surface as an exception. `CanBrowse` must catch **any** exception → `false` — it is a UI
affordance gate and must never throw into the page.
- **Defensive teardown of the throwaway instance.** Whatever the outcome (not discovery-capable,
flag false, or an exception after construction), `CanBrowse` best-effort
`ShutdownAsync`s/disposes any instance it *did* create — bounded, swallow-all — so probing
never leaks driver instances.
**Invariant the throwaway-instance pattern depends on:** driver constructors are
**connection-free** today — every driver in the tree connects in `InitializeAsync`, never in
its ctor. `CanBrowse` (construct → inspect → discard, potentially on every picker render) and
the capture path's construct-then-patch flow both rely on this. A future driver that opens a
device connection in its constructor would silently turn `CanBrowse` into a connect storm —
treat "no I/O in driver ctors" as a driver-authoring rule this browser now depends on (worth a
line in the program doc's cross-cutting rules when P1 lands).
### 4.3 `CapturedTreeBrowseSession : IBrowseSession`
Serves entirely from the in-memory captured tree (the shape the MTConnect research report
proposed for its cached-`/probe` session — now subsumed by this generic component): `RootAsync` = top-level nodes; `ExpandAsync(nodeId)` = the captured children of that
node; `AttributesAsync(nodeId)` = the leaf's `AttributeInfo` (+ any captured properties).
`Token`/`LastUsedUtc` per the interface; `DisposeAsync` drops the **session's reference** to
the tree (the tree is immutable and may be shared by coalesced sessions, §4.2 — disposing one
session never affects another). No I/O after open.
**Point-in-time snapshot + Refresh.** The captured tree is a snapshot taken at open time and
never updates for the session's whole TTL — a tag added on the controller after open does not
appear. The picker therefore exposes a **Refresh** action = close the session + re-open it
(`CloseAsync` then `OpenAsync` with the same driverType/configJson): a fresh capture that flows
through the same coalescing/cap path as any open. Because the coalescing map holds in-flight
captures only (no result caching), Refresh always yields a new capture — or joins one already
in flight, which is equally fresh. Cheap to build (the picker already has both calls); the UI
should label browse results as "snapshot taken at open/refresh time".
## 5. The online-discovery capability gate
The distinction the gate encodes: does `DiscoverAsync` enumerate from the **device** (browsable)
or merely replay **pre-declared** tags (flat-address drivers — Modbus/S7/MELSEC/AbLegacy/
Omron-FINS, where it would show only what you already authored)?
**Add a default-interface property** on `ITagDiscovery` (mirrors the existing
`RediscoverPolicy => UntilStable` default member on the same interface — zero churn for drivers
that don't opt in):
```csharp
/// <summary>True when DiscoverAsync enumerates the tag set from the live backend
/// (browsable), rather than replaying pre-declared/authored tags. Default false.</summary>
bool SupportsOnlineDiscovery => false;
```
- **Return true (device-enumerated):** OpcUaClient, BACnet, MTConnect, and — **config-gated**
— AbCip/TwinCAT/FOCAS (their device enumeration sits behind a config flag; the browse patch
guarantees it at open time, see §5.1).
- **Leave false (authored replay):** Modbus, Modbus-RTU, S7, MELSEC, AbLegacy,
Omron (CIP + FINS) — their pickers stay manual-entry (Omron gets an offline
tag-export importer later); and **MQTT/Sparkplug + SQL poll**, whose runtime
`DiscoverAsync` is also deliberately authored-only — they get **bespoke** browsers instead
(observation-window / `INFORMATION_SCHEMA` walk — see the program doc §4), because a
universal replay of authored tags would discover nothing new.
(Galaxy/OpcUaClient keep their bespoke browsers regardless — the flag only governs the
*universal fallback*.)
### 5.1 Per-driver browse-config patch (`PatchForBrowse`) — the one wrinkle
For AbCip/TwinCAT/FOCAS, device enumeration is **config-gated**: AbCip/TwinCAT's
`DiscoverAsync` only walks the controller when `EnableControllerBrowse = true`, and FOCAS only
emits its FixedTree when `FixedTree.Enabled = true` (default `false`,
`FocasFixedTreeOptions.Enabled`) — but the picker holds the *authoring* config (often
`false`). The universal browser therefore merges a tiny, **declarative** per-type JSON
patch before constructing the driver:
```csharp
static readonly IReadOnlyDictionary<string, string> BrowsePatches = new Dictionary<string,string>(OrdinalIgnoreCase) {
["AbCip"] = """{ "EnableControllerBrowse": true }""",
["TwinCAT"] = """{ "EnableControllerBrowse": true }""",
["FOCAS"] = """{ "FixedTree": { "Enabled": true } }""",
// OpcUaClient/BACnet/MTConnect: no patch — discovery is unconditional
};
```
This is a dictionary entry, not code — it keeps the "zero per-driver *browser*" property while
being honest that browse-mode activation is one declarative fact per config-gated driver.
`SupportsOnlineDiscovery` for AbCip/TwinCAT/FOCAS returns true because the patch guarantees
browse mode at open time. (FOCAS additionally needs the §4.2 `UntilStable` settle — the patch
turns the FixedTree on; the settle waits for its cache to fill.)
## 6. Wiring & deployment
- Register in AdminUI DI (`EndpointRouteBuilderExtensions.AddAdminUI`):
`services.AddSingleton<IUniversalDriverBrowser, DiscoveryDriverBrowser>();`**one line**,
no per-driver registration (contrast the two bespoke lines at
`EndpointRouteBuilderExtensions.cs:49-50`).
- Depends on `IDriverFactory` (Core.Abstractions). In the **fused Host** this is the real
`DriverFactoryRegistryAdapter` (all `Driver.*.Register()` ran) → every discovery-capable type
is constructible. On a **standalone AdminUI** with no driver assemblies it's
`NullDriverFactory``TryCreate` returns null → `CanBrowse` false → universal browse simply
unavailable (graceful; picker falls back to manual entry). No new failure mode.
- **Network/credentials posture is unchanged from the existing browsers**: OpcUaClient/Galaxy
browsers already connect to live backends *from the AdminUI process* using credentials in
`configJson`. The universal browser just extends that to more driver types — same role gating
(picker is behind the existing `ConfigEditor`/browse authorization), same
credentials-in-JSON handling, same `BrowseSessionRegistry` TTL reaping.
## 7. Where universal applies vs. bespoke
| Driver | Universal covers it? | Bespoke later? |
|---|---|---|
| **AbCip** | Yes (patch → `EnableControllerBrowse`) | Only if controller symbol set too large for eager one-shot (UDT drill-down) |
| **TwinCAT** | Yes (patch) | Only for very large symbol sets / Flat-mode caveats |
| **FOCAS** | Yes (patch → `FixedTree.Enabled` + §4.2 UntilStable settle) | Unlikely — surface is small/curated |
| **MTConnect** | Yes (`/probe`) | Optional — `/probe` is already whole-model; eager is fine |
| **BACnet** | Yes for small sites | **Yes** for large multi-device sites (lazy per-device object-list) |
| **MQTT/Sparkplug** | **No** (`SupportsOnlineDiscovery=false` — runtime discovery is authored-only by design) | **Yes** — bespoke observation-window `MqttBrowseSession` (its design doc §4) |
| **SQL poll** | **No** (authored replay) | **Yes** — bespoke `INFORMATION_SCHEMA` schema browser (its design doc) |
| **OpcUaClient** | (bespoke already) | Keep bespoke (100k-node lazy browse) |
| **Galaxy** | (bespoke already) | Keep bespoke (two-stage attribute pick) |
| Modbus/RTU, S7, MELSEC, AbLegacy, Omron | **No** (`SupportsOnlineDiscovery=false`) | N/A — not browsable (Omron: offline importer P-later) |
## 8. Limits (why bespoke browsers still exist)
1. **Eager, not lazy.** `DiscoverAsync` runs the whole enumeration at open. For huge trees
(ControlLogix 10 k+ tags, OPC UA 100 k nodes) the node-cap truncates and the UX is worse than
a bespoke per-click `ExpandAsync`. Graduate those to Tier 2.
2. **Runs the real driver in-process** (construct + connect + one-shot discover + bounded
shutdown) — heavier than the lightweight ad-hoc bespoke session, and requires the driver
assemblies in the host (fused Host: yes). The §4.2 coalescing + `MaxConcurrentCaptures` cap
bound the aggregate load, but each individual capture is still a full device walk.
3. **No incrementally-fetched attribute panel** beyond what `DriverAttributeInfo` +
`AddProperty` carry (fine for the picker's needs; Galaxy's richer two-stage pick stays bespoke).
## 9. Testing
- **Unit — `CapturingAddressSpaceBuilder`:** drive it with a fake `ITagDiscovery` that streams a
known Folder/Variable/AddProperty/alarm graph; assert the `BrowseNode` tree, leaf `NodeId ==
FullName`, `AttributeInfo` mapping, node-cap truncation, and the no-op alarm sink.
- **Unit — `DiscoveryDriverBrowser`:** fake `IDriverFactory` returning a fake driver; assert
Initialize→Discover→Shutdown ordering (shutdown always runs, even on discovery failure),
open-timeout, `PatchForBrowse` merge, `CanBrowse` gate
(true for `SupportsOnlineDiscovery`, false otherwise / `TryCreate`→null), and the
`UntilStable` settle (a fake `UntilStable` driver whose tree only fills on the second
discovery pass must yield the full tree; a never-stable one must stop at the open-timeout).
- **Unit — capture coalescing:** two concurrent `OpenAsync` calls with the same
(driverType, configJson) run **one** capture (fake driver counts Initialize/Discover
invocations) and both callers receive **distinct session tokens** over the shared tree;
disposing one session leaves the other browsable; a failed capture fails both awaiters; a
subsequent open after completion (Refresh path) runs a **new** capture (in-flight-only map,
no result caching).
- **Unit — capture cap queueing:** with `MaxConcurrentCaptures = 4` and >4 concurrent opens on
**distinct** keys (fake drivers gated on a test signal), at most 4 captures are in flight at
once; queued opens complete after a slot frees; a queued open observes caller cancellation.
- **Unit — `CanBrowse` hardening:** a fake factory whose `TryCreate` **throws** on malformed
configJson yields `CanBrowse == false` (no exception escapes); when `TryCreate` returns a
non-discovery (or flag-false) instance, `CanBrowse` is false **and** the created instance
was shut down/disposed.
- **Unit — bounded shutdown:** a fake driver whose `ShutdownAsync` never completes — the open
still returns within open-timeout + the 10 s shutdown bound, on the **discovery** outcome,
for both legs: successful capture ⇒ a usable session is returned; failed discovery ⇒ the
discovery error (not a shutdown timeout) surfaces. The wedged instance is abandoned, not
awaited.
- **Unit — `BrowserSessionService` resolution:** bespoke-first, universal-fallback, and
manual-entry-when-neither; confirm no `ToDictionary` collision.
- **Integration (fixture-gated):** point the universal browser at the existing driver fixtures on
`10.100.0.35` — AbCip (ControlLogix sim) and the OPC UA reference server — and assert a real
captured tree; reuse the driver fixtures already in `tests/`.
- **Live-verify:** on docker-dev, open the `/uns` TagModal picker for an AbCip driver and confirm
the controller tag tree renders and a picked leaf commits `TagConfig.FullName` (the usual
live-`/run` discipline — Razor binding bugs pass unit tests).
## 10. Phasing & effort
- **P1 — universal browser end-to-end:** `CapturingAddressSpaceBuilder` +
`CapturedTreeBrowseSession` + `DiscoveryDriverBrowser` + `IUniversalDriverBrowser` +
`BrowserSessionService` fallback + `ITagDiscovery.SupportsOnlineDiscovery` default member +
the AbCip/TwinCAT/FOCAS patches + the `UntilStable` settle loop (§4.2) + capture coalescing
and the `MaxConcurrentCaptures` cap + the bounded (10 s) shutdown + hardened `CanBrowse`
(catch-throwing-`TryCreate`, defensive teardown) + DI line + AdminUI
`CanBrowse`→Browse-button gate + the picker **Refresh** action (§4.3). Set
`SupportsOnlineDiscovery=true` on OpcUaClient(n/a—bespoke), AbCip, TwinCAT, FOCAS.
**~35 days.**
- **P2 — light up new drivers for free:** as MTConnect/BACnet land, each sets
`SupportsOnlineDiscovery=true` (+ patch if config-gated) and gets a working picker with no
browser code. (MQTT/Sparkplug and SQL poll ship bespoke browsers instead — program doc §4.)
- **P3 — graduate to bespoke** only where §8.1 bites (large BACnet sites, ControlLogix UDT
drill-down): add a bespoke `IBrowseSession` that overrides the universal fallback for that type.
Net: one ~35 day piece replaces the previously-planned per-driver browser builds for AbCip +
TwinCAT (and pre-covers FOCAS + every future discovery driver), with bespoke lazy browsers
reserved for the genuinely-large trees.
## 11. v3 forward note (Raw/UNS two-subtree — lands after this)
This browser ships **before** v3 (`docs/plans/2026-07-15-raw-uns-two-subtree-v3-design.md`)
against the current `/uns` TagModal picker. When v3's Raw tree lands, three things about the
*consumer* change while the session/tree layer here (`CapturingAddressSpaceBuilder`,
`CapturedTreeBrowseSession`, `IUniversalDriverBrowser`, `BrowserSessionService` resolution)
carries over unchanged:
1. **Commit contract:** §2's "commits `TagConfig.FullName`" describes the pre-v3 picker. In
v3, `TagConfig` no longer carries identity (the tag's identity is its RawPath); the picked
leaf's `attr.FullName` is written into the driver-typed `TagConfig` as an ordinary
**address field**, and the commit creates **raw `Tag` rows** (name from browse name,
datatype from `DriverAttributeInfo`) under a Device/TagGroup — optionally mirroring the
captured folder nesting as `TagGroup`s.
2. **Config input:** v3 moves endpoint/connection settings from `DriverConfig` into
`DeviceConfig`. `OpenAsync(driverType, configJson, ct)`'s `configJson` becomes the
**merged Driver + Device config** composed by the caller (the `/raw` browse modal);
`PatchForBrowse` merges on top as today.
3. **Browse-button gate:** unchanged mechanism (`CanBrowse` / bespoke registration), hosted
in the `/raw` tree's "Add tags ▸ Browse device…" action instead of the equipment TagModal.