docs: driver-expansion program — 8 research reports + 7 design docs, parallel-reviewed
v2-ci / build (push) Successful in 3m14s
v2-ci / unit-tests (push) Failing after 9m13s

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
This commit is contained in:
Joseph Doherty
2026-07-15 16:40:36 -04:00
parent 37cdbef7a5
commit 8fc147d8d4
17 changed files with 6408 additions and 0 deletions
@@ -0,0 +1,294 @@
# 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: ~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.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: ~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 `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: ~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