# 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/<prog>/`
  (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<IDriverBrowser, AbCipDriverBrowser>();
```

**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<TwinCATDiscoveredSymbol>`. 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<IDriverBrowser, TwinCATDriverBrowser>();`

**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
