Files
lmxopcua/docs/research/drivers/00-existing-driver-browse-audit.md
T
Joseph Doherty 8fc147d8d4
v2-ci / build (push) Successful in 3m14s
v2-ci / unit-tests (push) Failing after 9m13s
docs: driver-expansion program — 8 research reports + 7 design docs, parallel-reviewed
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
2026-07-15 16:40:36 -04:00

20 KiB
Raw Blame History

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}").
  • AttributesAsyncempty (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: ~23 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.BrowseSymbolsAsyncIAsyncEnumerable<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).
  • AttributesAsyncempty (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: ~34 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 Leafs (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.
  • AttributesAsyncempty (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: ~35 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) ~23 days Full 0x6B + 0x6C decode stack already ships and is tested; lowest effort, highest payoff
2 TwinCAT High (ADS symbol upload is complete + rich) ~34 days (+ TC3 live-gate) BrowseSymbolsAsync already ships; small residual risk (Flat SubSymbols, AMS router) needs live validation
3 FOCAS Medium (curated, not free-form) ~35 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 4450)
  • 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