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

295 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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