Adds the driver-expansion program design (umbrella: universal Discover-backed browser + MTConnect, MQTT/Sparkplug B, BACnet/IP, SQL poll, Omron, Modbus RTU; MELSEC deferred) plus the per-driver research reports. All docs went through a 7-agent parallel review against the codebase before this commit. Highlights fixed in review: - universal browser: FOCAS FixedTree fills post-connect -> UntilStable settle + FixedTree.Enabled patch; MQTT reconciled to bespoke (was contradicting the program doc's SupportsOnlineDiscovery=false verdict) - modbus-rtu: SerialPort.ReadTimeout doesn't bound async BaseStream reads -> linked-CTS per-op deadline (R2-01 class); BCL enum reuse would leak System.IO.Ports into Contracts - bacnet: DiscoveryRediscoverPolicy enum name; UDP 47808 contention; live suite rewritten around unicast Who-Is + BBMD (broadcast doesn't cross VMs) - sql-poll: real tier registration via DriverFactoryRegistry.Register; blackhole gate must not docker-pause the shared central SQL Server - mqtt: Sparkplug v3.0 STATE topic form; first-in-repo proto codegen noted - omron: host hardcodes isIdempotent:false today (retry seam unshipped); v1 scopes UDTs to dotted-leaf access - mtconnect: SecurityClassification.ViewOnly; factory ParseEnum<T> pattern - program doc: both valid enum-serialization patterns; IRediscoverable is change-signal-gated; RTU P2 adds System.IO.Ports; label is host-side
20 KiB
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
IDriverBrowsertoday — AbCip, TwinCAT, AbLegacy, FOCAS, S7, Modbus. (OpcUaClient + Galaxy already have browsers — seedocs/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'sTagConfig.
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_UPLOADINFO2thenADSIGRP_SYM_UPLOAD(+ADSIGRP_SYM_DT_UPLOADfor datatypes); the .NETSymbolLoaderFactorywraps this and yields aDynamicSymbolsCollectionyou enumerate byInstancePath, descendingSubSymbolsfor 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/Writeagainst 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
.Browserproject. For AbCip and TwinCAT the enumeration already lives in the runtimeDriver.*project. The design doc's decision #4 ("keep AdminUI from pulling runtimeDriver.*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.Browserproject references the runtimeDriver.AbCip/Driver.TwinCATproject 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→ oneFoldernode per device, plus a two-way split of the symbol table intoController/(controller-scope tags,ProgramScope == null) andPrograms/<prog>/(program-scope tags), driven off the already-decodedProgramScope. ApplyAbCipSystemTagFilterso@-prefixed infrastructure tags don't clutter the tree (parity with the runtime discovery filter).ExpandAsync(nodeId)→- device / scope folder → its atomic tags as
Leafand its UDT tags asFolder; - a UDT
Folder→ its members (via the Template Object) asLeaf, recursing for nested structs (membernodeId = "{tagPath}.{member}").
- device / scope folder → its atomic tags as
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:
{ "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:
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→ oneFolderper device; under it the top-level PLC scopes as folders (Constants,MAIN,GVL,Global_Version,TwinCAT_SystemInfoVarList, …), synthesised by splitting each symbol's dottedInstancePathon the first segment. ApplyTwinCATSystemSymbolFilterto hide the infrastructure symbol lists (parity with runtime discovery).ExpandAsync(nodeId)→ children at the nextInstancePathdepth: atomic symbols asLeaf, structs/FB instances asFolder. BecauseBrowseSymbolsAsyncyields 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):
- AMS router. The AdminUI host process needs a reachable AMS router to browse — on Windows
from TwinCAT XAR, elsewhere from the
Beckhoff.TwinCAT.Ads.TcpRouterpackage hosted in-proc.Beckhoff.TwinCAT.Ads7.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. - Flat-mode
SubSymbols. The code comment warns that on some TC3 firmware,Flatmode may not populateSubSymbolsfor struct/UDT/FB symbols, silently dropping members; the documented fallback isSymbolsLoadMode.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→ oneFolderper device, then category folders:Identity,Axes,Spindles,Program,Timers,Status(populated live fromFixedTreeCache), plusMacros,Parameters,PMC(populated fromFocasCapabilityMatrixfor the device's declared series).ExpandAsync→ e.g.Axes/→ the live axis names as folders → per-axisLeafs (AbsolutePosition,MachinePosition,ServoLoad, …);Macros/→ aLeafper 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
(
CipSymbolObjectDecoder0x6B +CipTemplateObjectDecoder0x6C +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 sharedZB.MOM.WW.OtOpcUa.Cip.Corelib (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 runtimeDiscoverAsyncand 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 gateAbCipDriverOptions.EnableControllerBrowse - TwinCAT enumeration:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/{AdsTwinCATClient (BrowseSymbolsAsync),TwinCATSymbolExpander,TwinCATSystemSymbolFilter}.cs; TagConfig:…Driver.TwinCAT.Contracts/{TwinCATDriverOptions,TwinCATEquipmentTagParser}.cs; browse gateTwinCATDriverOptions.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(seeTagsdoc-comment) - Modbus:
…Driver.Modbus.Contracts/ModbusDriverOptions.cs(seeTagsdoc-comment);…Driver.Modbus/ModbusTcpTransport.cs - Package versions (
Directory.Packages.props):libplctag1.5.2,Beckhoff.TwinCAT.Ads7.0.172,S7netplus0.20.0