# Existing-driver browse audit — which of the 6 no-browser drivers should get an `IDriverBrowser` > **Status:** research complete, 2026-07-15. Author: browse-audit sweep. > **Scope:** the 6 shipped drivers with **no** `IDriverBrowser` today — AbCip, TwinCAT, > AbLegacy, FOCAS, S7, Modbus. (OpcUaClient + Galaxy already have browsers — see > `docs/plans/2026-05-28-driver-browsers-design.md`.) > **Question answered:** which protocols support live online discovery / symbol enumeration, > what the browse would return, and how a picked node maps to that driver's `TagConfig`. ## TL;DR The single most important finding: **AbCip and TwinCAT already contain a complete, working symbol-enumeration stack** — but it is buried in the *runtime* driver's `DiscoverAsync` (gated by an `EnableControllerBrowse` option) and is **not** exposed as the interactive, per-click `IDriverBrowser` the AdminUI address picker consumes. Building their browsers is therefore mostly a *wrapping* job over code that already ships and is already unit-tested, not a from-scratch protocol implementation. That makes them high-value / low-effort and the clear top priority. --- ## 1. Verdict table | Driver | Browseable? | Mechanism (protocol-native discovery) | Existing code to reuse | Recommended action | |---|---|---|---|---| | **AbCip** (ControlLogix EtherNet/IP CIP) | **Yes** | CIP **Symbol Object class 0x6B** enumerated via libplctag's `@tags` pseudo-tag; UDT members via **Template Object class 0x6C** | `IAbCipTagEnumerator` / `LibplctagTagEnumerator` + `CipSymbolObjectDecoder` + `CipTemplateObjectDecoder` + `AbCipTemplateCache` + `AbCipUdtMemberLayout` **already exist** | **Add browser — priority 1** | | **TwinCAT** (Beckhoff ADS) | **Yes** | ADS **symbol upload** (`ADSIGRP_SYM_UPLOAD`) via `SymbolLoaderFactory` (flat mode) | `AdsTwinCATClient.BrowseSymbolsAsync` + `TwinCATSymbolExpander` **already exist** | **Add browser — priority 2** | | **FOCAS** (Fanuc CNC) | **Borderline** (curated, not free-form) | Fixed FOCAS API surface: live-enumerated axes/spindles/identity + per-series valid Macro/Parameter/PMC ranges | `FocasDriver.DiscoverAsync` FixedTree + `FixedTreeCache` + `FocasCapabilityMatrix` **already exist** | **Add curated browser — priority 3 (defer)** | | **AbLegacy** (Rockwell PCCC / PLC-5 / SLC) | **No** | PCCC data-table is numeric (`N7:0`), no symbolic catalog; libplctag exposes no file-directory list | none (discovery = static re-advertisement of configured tags) | **Skip** | | **S7** (Siemens S7comm) | **No** | Symbols live in the TIA/STEP7 project, not the CPU; S7netplus exposes no symbol/block-list API | none | **Skip** | | **Modbus** | **No** | Flat register space; no discovery in the Modbus protocol | none | **Skip** | --- ## 2. Protocol-browseability evidence (sources) - **CIP Symbol Object 0x6B** — the CIP object used to *browse* controller symbol addresses; Rockwell's `Get Instance Attribute List` (service 0x55) enumerates the tag table, and the ControlLogix tag database is accessed via ANSI-symbolic Data-Table-Read against the returned tag names. Some firmware/devices don't implement 0x6B, so a browse must degrade gracefully. (AdvancedHMI *Generic EtherNet/IP and CIP Technical Information*; scadaprotocols.com *CIP Object Model*; PLCS.net tag-read threads.) - **ADS symbol upload** — Beckhoff's documented flow reads `ADSIGRP_SYM_UPLOADINFO2` then `ADSIGRP_SYM_UPLOAD` (+ `ADSIGRP_SYM_DT_UPLOAD` for datatypes); the .NET `SymbolLoaderFactory` wraps this and yields a `DynamicSymbolsCollection` you enumerate by `InstancePath`, descending `SubSymbols` for struct/UDT/FB members. (Beckhoff InfoSys *Access Data via Symbol Loader* / *SymbolLoaderFactory.Create*; fisothemes *TwinCAT ADS in .NET* guide.) - **S7comm has no on-wire symbol table** — S7-300/400/1200/1500 CPUs store only compiled code; symbol tables + comments live in the offline TIA/STEP7 project. S7-1200/1500 *optimized* blocks are addressable only symbolically and only via the PLC's **OPC UA server** — classic S7comm GET/PUT (what S7netplus speaks) cannot browse or even read them by name. (FlowFuse *Read S7 optimized datablocks*; Industrial Monitor Direct *S7-1500 optimized block access* + *Siemens S7 upload missing symbols*.) - **PCCC is numeric** — PLC-5/SLC access is `Protected Typed Logical Read/Write` against numeric data-table addresses (`N7:0`, length N). There are no symbolic names on the wire and no standard "list the files" catalog service. (Rockwell 1785-6.8.5 *Connecting PLC-5/SLC*; Ipesoft *EtherNet/IP and encapsulated PCCC*.) --- ## 3. Browse designs for the **Yes / Borderline** drivers The two existing browsers are the template: a sibling `*.Browser` project holding an `IDriverBrowser` factory + an `IBrowseSession`, registered one line in `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs` (`AddAdminUI`). The picker razor bodies already exist per-driver under `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/` — today `AbCipAddressPickerBody.razor`, `TwinCATAddressPickerBody.razor`, and `FOCASAddressPickerBody.razor` are **static manual-entry stubs**; only `OpcUaClientAddressPickerBody` and `GalaxyAddressPickerBody` embed the shared `DriverBrowseTree`. So "wiring the picker" is the same delta that was applied to those two. > **Architectural note that differs from OpcUaClient/Galaxy:** for those two, the enumeration > logic lives *inside* the `.Browser` project. For AbCip and TwinCAT the enumeration already > lives in the *runtime* `Driver.*` project. The design doc's decision #4 ("keep AdminUI from > pulling runtime `Driver.*` SDK chains") was about not dragging heavy SDKs transitively — but > the AbCip/TwinCAT **browsers must connect with those exact SDKs anyway** (`libplctag`, > `Beckhoff.TwinCAT.Ads`), so that concern is moot here. Two clean options: > **(a)** the `.Browser` project references the runtime `Driver.AbCip` / `Driver.TwinCAT` > project directly and calls the existing enumerator (least work); or > **(b)** extract the enumerator + decoders into a small shared core lib both the runtime driver > and the browser consume (cleaner separation, more churn). Recommendation: **(a)** for the > first cut — the enumerator interfaces (`IAbCipTagEnumerator`, `ITwinCATClient.BrowseSymbolsAsync`) > are already the seam, so a later extraction to (b) is mechanical. ### 3.1 AbCip browser (`DriverType = "AbCip"`) **Mechanism.** Open a libplctag connection from the form's `AbCipDriverOptions` JSON (the same shape the runtime factory + `AbCipDriverProbe` consume). For each configured device (`AbCipDeviceOptions.HostAddress`), read the `@tags` pseudo-tag and decode with the existing `CipSymbolObjectDecoder` → a stream of `AbCipDiscoveredTag { Name, ProgramScope, DataType, ReadOnly, IsSystemTag, ElementCount, IsArray, TemplateInstanceId }`. For a `Structure`-typed tag, resolve UDT members through the existing `AbCipTemplateCache` / `CipTemplateObjectDecoder` (class 0x6C) → `AbCipUdtShape` member list. **Session semantics:** - `RootAsync` → one `Folder` node per device, plus a two-way split of the symbol table into `Controller/` (controller-scope tags, `ProgramScope == null`) and `Programs//` (program-scope tags), driven off the already-decoded `ProgramScope`. Apply `AbCipSystemTagFilter` so `@`-prefixed infrastructure tags don't clutter the tree (parity with the runtime discovery filter). - `ExpandAsync(nodeId)` → - device / scope folder → its atomic tags as `Leaf` and its UDT tags as `Folder`; - a UDT `Folder` → its members (via the Template Object) as `Leaf`, recursing for nested structs (member `nodeId = "{tagPath}.{member}"`). - `AttributesAsync` → **empty** (AbCip has no Galaxy-style attribute side-panel; the leaf *is* the address). Kind is the terminating signal, same as OpcUaClient. **Node → `TagConfig` mapping.** The picker commits the leaf's `NodeId` (the Logix symbolic path) into the AbCip TagConfig JSON authored by `AbCipTagConfigModel` / parsed by `AbCipEquipmentTagParser.TryParse`: ```json { "tagPath": "Program:Main.MyTag", "dataType": "DInt", "deviceHostAddress": "ab://10.0.0.5/1,0", "isArray": false, "arrayLength": 1, "writable": true } ``` The browser already knows `dataType` (from `AbCipDiscoveredTag.DataType`), `isArray` + `arrayLength` (from `IsArray` + `ElementCount`), and can pre-fill `writable = !ReadOnly` — so the pick can populate the whole config, not just the path (a nicer UX than OpcUaClient's path-only commit). `deviceHostAddress` is the device the tree node came from. **Project layout** (mirrors `Driver.OpcUaClient.Browser`): ``` src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Browser/ AbCipDriverBrowser.cs (IDriverBrowser; DriverType="AbCip"; opens libplctag conn) AbCipBrowseSession.cs (IBrowseSession over IAbCipTagEnumerator + template cache) ZB.MOM.WW.OtOpcUa.Driver.AbCip.Browser.csproj → refs Commons + Driver.AbCip (option a) [+ libplctag transitively] tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Browser.Tests/ (fake enumerator, decoder golden) ``` **DI line** to add in `EndpointRouteBuilderExtensions.AddAdminUI`: ```csharp services.AddSingleton(); ``` **Effort: ~2–3 days.** The protocol-hard part (0x6B decode, 0x6C template decode, system-tag filter, UDT layout) is done and tested. Work is: JSON-open path (copy the probe's connect), the two-level `RootAsync`/`ExpandAsync` projection, and the picker razor (copy the OpcUaClient tree wiring). Live-gate against the `abcip`/`controllogix` docker fixture on `10.100.0.35:44818`. ### 3.2 TwinCAT browser (`DriverType = "TwinCAT"`) **Mechanism.** Open an `AdsClient` from the form's `TwinCATDriverOptions` JSON (same shape the runtime consumes), then call the **already-implemented** `AdsTwinCATClient.BrowseSymbolsAsync` → `IAsyncEnumerable`. That method builds a `SymbolLoaderFactory.Create(_client, SymbolsLoadMode.Flat)` loader and runs the pure `TwinCATSymbolExpander.ExpandLeaves` to descend struct/UDT/FB instances to atomic members (deduped by `InstancePath`). Each yielded symbol already carries the mapped `TwinCATDataType`, optional array length, and read-only flag. **Session semantics:** - `RootAsync` → one `Folder` per device; under it the top-level PLC scopes as folders (`Constants`, `MAIN`, `GVL`, `Global_Version`, `TwinCAT_SystemInfoVarList`, …), synthesised by splitting each symbol's dotted `InstancePath` on the first segment. Apply `TwinCATSystemSymbolFilter` to hide the infrastructure symbol lists (parity with runtime discovery). - `ExpandAsync(nodeId)` → children at the next `InstancePath` depth: atomic symbols as `Leaf`, structs/FB instances as `Folder`. Because `BrowseSymbolsAsync` yields the fully-expanded leaf set, the session can build the whole path→children index once on open and serve expands from memory (no per-click wire call — the symbol blob is downloaded once by the loader). - `AttributesAsync` → **empty** (leaf is the address). **Node → `TagConfig` mapping.** Commit the leaf's `InstancePath` as `symbolPath` in the TwinCAT TagConfig JSON (`TwinCATTagDefinition`: `symbolPath`, `dataType`, `deviceHostAddress`, `arrayLength`, `writable`). The browser knows `dataType` + `arrayLength` + `writable` (`!ReadOnly`) from the discovered symbol, so it pre-fills the full config. **Two live caveats (already flagged in `AdsTwinCATClient`):** 1. **AMS router.** The AdminUI host process needs a reachable AMS router to browse — on Windows from TwinCAT XAR, elsewhere from the `Beckhoff.TwinCAT.Ads.TcpRouter` package hosted in-proc. `Beckhoff.TwinCAT.Ads` **7.0.172** is cross-platform-capable (netstandard), so this runs on the Linux/macOS AdminUI host *if* a router is wired — but that is a deployment prerequisite to document, not a code change. 2. **Flat-mode `SubSymbols`.** The code comment warns that on some TC3 firmware, `Flat` mode may not populate `SubSymbols` for struct/UDT/FB symbols, silently dropping members; the documented fallback is `SymbolsLoadMode.VirtualTree`. This must be **live-gated on a real TC3 target** before trusting struct expansion. **Project layout:** `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Browser/` (`TwinCATDriverBrowser` + `TwinCATBrowseSession`, refs Commons + Driver.TwinCAT). **DI line:** `services.AddSingleton();` **Effort: ~3–4 days** + a live-gate session on a real TC3 PLC for the `SubSymbols`/router risk. The enumeration + expansion + type-mapping are done and unit-tested; work is the JSON-open path, the path→tree projection, the picker razor, and the live validation. ### 3.3 FOCAS browser (`DriverType = "FOCAS"`) — curated, borderline FOCAS has **no free-form symbol tree** — it's a fixed API surface. But the driver already does a **live protocol-native discovery**: `FocasDriver.DiscoverAsync` (FixedTree) live-reads `cnc_sysinfo` / `cnc_rdaxisname` / `cnc_rdspdlname` at session init (cached in `FixedTreeCache`) to enumerate the machine's actual axes, spindles, identity, timers, program info, and status. Separately, `FocasCapabilityMatrix` encodes the *valid ranges* of Macro variables, CNC Parameters, and PMC letter-areas per `FocasCncSeries` (static tables, not live-queried). **A curated browse would return:** - `RootAsync` → one `Folder` per device, then category folders: `Identity`, `Axes`, `Spindles`, `Program`, `Timers`, `Status` (populated live from `FixedTreeCache`), plus `Macros`, `Parameters`, `PMC` (populated from `FocasCapabilityMatrix` for the device's declared series). - `ExpandAsync` → e.g. `Axes/` → the live axis names as folders → per-axis `Leaf`s (`AbsolutePosition`, `MachinePosition`, `ServoLoad`, …); `Macros/` → a `Leaf` per valid macro number in the series range (or a paged range picker for the large ranges); `PMC/R` → valid R addresses. - `AttributesAsync` → **empty** (leaf is the address). **Node → `TagConfig` mapping.** Commit the canonical FOCAS address string (`X0.0`, `R100`, `PARAM:1815/0`, `MACRO:500`) as `address` in the FOCAS TagConfig, with `dataType` pre-filled from the category (positions → Float64, macros → Float64, PMC bit → Bit, etc.). **Effort: ~3–5 days**, medium value (curated list, not the "discover unknown tags" win that AbCip/TwinCAT give). Reuses `FixedTreeCache` + `FocasCapabilityMatrix`. **Defer** behind AbCip and TwinCAT. --- ## 4. Justification for the **No** drivers ### AbLegacy (PCCC / PLC-5 / SLC) — No PCCC addresses are opaque numeric data-table references (`N7:0`, `F8:0`, `B3:0/5`) with **no symbolic names on the wire** and **no standard catalog service**. libplctag (the driver's client, `libplctag` 1.5.2) exposes no PCCC data-table-file directory listing. The driver's `DiscoverAsync` merely re-advertises the statically configured `_options.Tags` — zero live reads. The only "browse" one could build is blind range-probing (try reading `N7:0`, `N9:0`, …, infer existence from status codes) which is slow, unreliable, and enumerates addresses not meaningful names — no better than the existing manual `N7:0`-style entry. **Skip.** ### S7 (S7comm) — No The Siemens CPU holds only compiled code; the symbol table lives exclusively in the offline TIA/STEP7 project. Classic S7comm (GET/PUT — what `S7netplus` 0.20.0 speaks) has no symbol-browse service, and `S7netplus` exposes no block-list/SZL API. S7-1200/1500 *optimized* blocks are reachable **only** symbolically via the CPU's own OPC UA server — which is precisely the `OpcUaClient` driver's job, not S7comm's. (An operator who wants to browse an S7-1500 optimized DB should point the **OpcUaClient** driver at the PLC's OPC UA server, where the existing OpcUaClient browser already works.) The driver's own `S7DriverOptions.Tags` doc-comment states it plainly: "S7 has a symbol-table protocol but S7.Net does not expose it, so the driver operates off a static tag list." **Skip.** ### Modbus — No Modbus is a flat, typeless register/coil space (`Coils`, `DiscreteInputs`, `InputRegisters`, `HoldingRegisters`) with **no discovery mechanism in the protocol at all** — a device cannot tell you which registers exist or what they mean. The transport is hand-rolled (`ModbusTcpTransport`, no NuGet). The driver's own option doc-comment says it: "Modbus has no discovery protocol — the driver returns exactly these." **Skip.** (The existing `ModbusAddressPickerBody` region+address builder is the right and only tool here.) --- ## 5. Priority ordering, shareable code, effort **Build order (value × effort):** | Priority | Browser | Value | Effort | Why this order | |---|---|---|---|---| | **1** | **AbCip** | High (ControlLogix ubiquity; symbolic tag browse is the flagship UX) | **~2–3 days** | Full 0x6B + 0x6C decode stack already ships and is tested; lowest effort, highest payoff | | **2** | **TwinCAT** | High (ADS symbol upload is complete + rich) | **~3–4 days** (+ TC3 live-gate) | `BrowseSymbolsAsync` already ships; small residual risk (Flat `SubSymbols`, AMS router) needs live validation | | **3** | **FOCAS** | Medium (curated, not free-form) | **~3–5 days** | Reuses FixedTree + capability matrix, but lower discovery value; defer | | — | AbLegacy / S7 / Modbus | — | — | No browser (documented above) | **Shareable-code opportunities:** - **AbCip → future Omron / generic-EtherNet/IP browser.** The CIP browse core (`CipSymbolObjectDecoder` 0x6B + `CipTemplateObjectDecoder` 0x6C + `AbCipSystemTagFilter` + `AbCipUdtMemberLayout`) is protocol-CIP, not Rockwell-specific. If a second CIP-family driver ever lands (Omron NX/NJ also speak CIP tag access), extracting that core into a shared `ZB.MOM.WW.OtOpcUa.Cip.Core` lib (design-doc option **b**) would let both browsers share the decode path. Not worth doing pre-emptively for one consumer — flag it as the natural refactor point when/if the second CIP driver appears. - **AbCip + TwinCAT both already put their enumerator behind an interface** (`IAbCipTagEnumerator`, `ITwinCATClient.BrowseSymbolsAsync`) — that interface *is* the reuse seam between runtime `DiscoverAsync` and the new browser session. No new abstraction needed. - Nothing shareable across the No drivers (they have no discovery to share). **Net recommendation:** build **AbCip** then **TwinCAT** browsers now (both are wrap-existing-code jobs, ~1 week combined); **defer FOCAS** (curated, medium value); **do not build** AbLegacy, S7, or Modbus browsers — their protocols have no online discovery and the existing manual-entry pickers are the correct surface. --- ## Appendix — key source paths - Contracts: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/{IDriverBrowser,IBrowseSession,BrowseNode}.cs` - Templates: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/`, `…Driver.Galaxy.Browser/` - DI: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs` (`AddAdminUI`, lines 44–50) - Picker bodies: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/*AddressPickerBody.razor` - **AbCip** enumeration: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/{IAbCipTagEnumerator,LibplctagTagEnumerator,CipSymbolObjectDecoder,CipTemplateObjectDecoder,AbCipTemplateCache,AbCipUdtMemberLayout,AbCipSystemTagFilter}.cs`; TagConfig: `…Driver.AbCip.Contracts/{AbCipDriverOptions,AbCipEquipmentTagParser}.cs`; browse gate `AbCipDriverOptions.EnableControllerBrowse` - **TwinCAT** enumeration: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/{AdsTwinCATClient (BrowseSymbolsAsync),TwinCATSymbolExpander,TwinCATSystemSymbolFilter}.cs`; TagConfig: `…Driver.TwinCAT.Contracts/{TwinCATDriverOptions,TwinCATEquipmentTagParser}.cs`; browse gate `TwinCATDriverOptions.EnableControllerBrowse` - **FOCAS**: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/{FocasDriver (DiscoverAsync/FixedTree),FocasCapabilityMatrix,IFocasClient}.cs`; TagConfig: `…Driver.FOCAS.Contracts/FocasDriverOptions.cs` - **AbLegacy**: `…Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs`; `…Driver.AbLegacy/AbLegacyDriver.cs` (`DiscoverAsync` = static re-advertise) - **S7**: `…Driver.S7.Contracts/S7DriverOptions.cs` (see `Tags` doc-comment) - **Modbus**: `…Driver.Modbus.Contracts/ModbusDriverOptions.cs` (see `Tags` doc-comment); `…Driver.Modbus/ModbusTcpTransport.cs` - Package versions (`Directory.Packages.props`): `libplctag` 1.5.2, `Beckhoff.TwinCAT.Ads` 7.0.172, `S7netplus` 0.20.0