Files
lmxopcua/docs/plans/2026-07-15-driver-expansion-program-design.md
T
Joseph Doherty 014c76b986 docs(browse): Wave-0 status flip — universal browser P1 implemented (live gate pending)
Task 17: design doc Status draft -> P1 implemented; program doc §9 Code not started -> Wave 0
landed. Full build 0 errors; touched suites green (Core.Abstractions 135, Commons 158,
AdminUI 542, AbCip 342, TwinCAT 191, FOCAS 271).
2026-07-15 17:42:42 -04:00

224 lines
15 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.
# Driver-expansion program — overall design
**Status:** program design, 2026-07-15. Umbrella for the next-driver effort: one universal
browse mechanism + six new/extended drivers (MELSEC deferred). This is the authoritative index
and the single statement of the effort's shared architecture, dependency graph, and build order.
Per-driver detail lives in the linked design docs; cross-cutting rules are stated **once here**
so the per-driver docs don't re-derive them.
## 1. Goal & scope
Extend the OtOpcUa OPC UA server's driver fleet under the existing Equipment-kind address-space
model — no new COM gateway, all in-process .NET 10 — and make more of the fleet **browsable**
from the AdminUI `/uns` address picker.
**Curated set (user, 2026-07-15):** MTConnect Agent · MQTT/Sparkplug B · MELSEC SLMP · Omron ·
BACnet/IP · SQL poll · Modbus RTU. **MELSEC is deferred** (commercial-license-forced hand-rolled
framer + the hardest addressing model on the roadmap; revisit on a Mitsubishi-shop need). The
other six are designed and scheduled below.
Alongside the new drivers, a **universal Discover-backed browser** retrofits browse to the
already-shipped fleet (AbCip, TwinCAT, FOCAS) and is the foundation every browsable new driver
builds on.
Existing fleet for context: Modbus(TCP), S7, AbCip, AbLegacy, TwinCAT, FOCAS, Galaxy,
OpcUaClient, Historian.Gateway (`src/Drivers/`).
## 2. Document map
**Program:** this file.
**Research reports** (`docs/research/drivers/`, index: [`README.md`](../research/drivers/README.md)):
- [`mtconnect-agent.md`](../research/drivers/mtconnect-agent.md) ·
[`mqtt-sparkplug.md`](../research/drivers/mqtt-sparkplug.md) ·
[`melsec-slmp.md`](../research/drivers/melsec-slmp.md) *(deferred)* ·
[`omron.md`](../research/drivers/omron.md) ·
[`bacnet-ip.md`](../research/drivers/bacnet-ip.md) ·
[`sql-poll.md`](../research/drivers/sql-poll.md) ·
[`modbus-rtu.md`](../research/drivers/modbus-rtu.md) ·
[`00-existing-driver-browse-audit.md`](../research/drivers/00-existing-driver-browse-audit.md)
**Design docs** (`docs/plans/2026-07-15-*`):
- [`universal-discovery-browser-design.md`](2026-07-15-universal-discovery-browser-design.md) — **Wave 0**
- [`mtconnect-driver-design.md`](2026-07-15-mtconnect-driver-design.md)
- [`bacnet-ip-driver-design.md`](2026-07-15-bacnet-ip-driver-design.md)
- [`mqtt-sparkplug-driver-design.md`](2026-07-15-mqtt-sparkplug-driver-design.md)
- [`sql-poll-driver-design.md`](2026-07-15-sql-poll-driver-design.md)
- [`omron-driver-design.md`](2026-07-15-omron-driver-design.md)
- [`modbus-rtu-driver-design.md`](2026-07-15-modbus-rtu-driver-design.md)
Prior art the designs build on: [`2026-05-28-driver-browsers-design.md`](2026-05-28-driver-browsers-design.md)
(bespoke `IBrowseSession` pattern), [`2026-06-12-galaxy-standard-driver-design.md`](2026-06-12-galaxy-standard-driver-design.md)
(standard Equipment-kind driver), and the driver-typed tag editors design.
## 3. Shared architecture (the contract every driver in this program follows)
Every driver here is a **standard Equipment-kind driver** — an `IDriver` (lifecycle: `Initialize`
/ `Reinitialize` / `Shutdown` / `GetHealth` / footprint) composing only the capability interfaces
its backend supports, from `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/`:
| Capability | Interface | This program's drivers |
|---|---|---|
| Discover | `ITagDiscovery.DiscoverAsync(IAddressSpaceBuilder, ct)` | all (device-enumerated or authored-only) |
| Read | `IReadable` | all |
| Subscribe | `ISubscribable` | all (poll-based, except the push drivers: MQTT, BACnet COV, MTConnect's `/sample` stream) |
| Write | `IWritable` | Omron, Modbus-RTU (v1); MTConnect/MQTT/BACnet/SQL read-only in v1 |
| Probe | `IHostConnectivityProbe` | all |
| Re-discover | `IRediscoverable` | only drivers with a native backend change signal (per its doc contract) — e.g. MQTT rebirth; BACnet/SQL are discovery-capable but have no change signal and correctly omit it |
**Per-driver build pattern** (identical across the fleet — see any existing driver + the
driver-typed-editors design):
1. `ZB.MOM.WW.OtOpcUa.Driver.<Name>.Contracts` — options + tag DTOs + parser + enums (no backend
NuGet dep).
2. `ZB.MOM.WW.OtOpcUa.Driver.<Name>` — the runtime `IDriver` + capabilities.
3. `.Browser` project **only when bespoke browse is required** (see §4).
4. Factory + `DriverType` string + `Register()`, wired in the Host's `DriverFactoryBootstrap`
(factory + probe on admin nodes via `TryAddEnumerable`).
5. Typed tag editor + validator: a `<Name>TagConfigModel` (`FromJson`/`ToJson`/`Validate`) +
`TagConfigEditorMap` + `TagConfigValidator` entries.
6. Docker fixture under `tests/.../Docker/`, deployed to `10.100.0.35` via `lmxopcua-fix sync`
(the `project=lmxopcua` label is a deployment convention applied host-side — no checked-in
compose file carries a `labels:` entry today).
### 3.1 Cross-cutting rules (mandatory, stated once)
- **Enum-serialization trap** — every enum on a config/tag surface MUST serialize as **names,
never numbers**, on both sides (AdminUI page serializer **and** probe carry
`JsonStringEnumConverter`). Two shipped factory-side patterns both satisfy this — Modbus keeps
DTO fields `string?` + parses via `ParseEnum<T>` (no converter in the factory); OpcUaClient
uses enum-typed DTOs with the converter in factory **and** probe. Either is fine; a mismatch
between the authoring side and the factory side **faults the driver** at deploy. This is a
proven systemic bug — see the driver enum-serialization memory. Non-negotiable for every
driver here.
- **Per-op deadline (R2-01 frozen-peer lesson)** — every network call (read/write/discover/probe/
subscribe/agent-HTTP/SQL-query) MUST have a bounded deadline (linked-CTS / socket ReadTimeout /
`CommandTimeout`). A frozen peer must never wedge a poll. No unbounded waits anywhere.
- **`WriteIdempotent`** — writable drivers flag only tags whose replay is safe. Note the invoker
seam (`ExecuteWriteAsync(…, isIdempotent, …)`) exists but the host's write dispatch currently
hardcodes `isIdempotent: false` (the arch-review "hardcoded isIdempotent" Medium) — so **no
write auto-retries today**; drivers carry the per-tag flag now and retry activates when that
fleet plumbing lands.
- **Secrets** — connection strings / API keys / broker creds come from env/secret refs (mirror
`ServerHistorian__ApiKey`), never committed or logged.
- **Ctor is connection-free** — driver constructors must not open connections or touch the
network; all connects happen in `InitializeAsync`. Every existing driver already obeys this,
and the universal browser's `CanBrowse` throwaway-instance pattern **depends** on it — a
ctor-connecting driver would leak connections on every capability probe.
- **Live-verify discipline** — Razor binding + deploy-inertness bugs pass unit tests and review;
every driver's picker/editor and deploy path gets a docker-dev `/run` live-verify before it's
trusted.
## 4. Browse architecture — the two-tier decision
The program's central browse decision: instead of a bespoke browser per driver, a **universal
Discover-backed browser** (`DiscoveryDriverBrowser`, Wave 0) captures any driver's
`ITagDiscovery.DiscoverAsync` output and re-presents it to the picker. Bespoke `IBrowseSession`
browsers are reserved for the cases the universal one genuinely can't serve.
**The gate:** a new default-interface member `ITagDiscovery.SupportsOnlineDiscovery => false`
(mirrors the existing `RediscoverPolicy` default member). A driver opts in when its `DiscoverAsync`
**enumerates from the device**; flat-address drivers leave it false and stay manual-entry. This
member is **added in the universal browser's P1** — **every browsable driver in this program
depends on it**, which is why the universal browser is the Wave-0 gate.
| Driver | Browse mechanism | Verdict |
|---|---|---|
| **MTConnect** | `/probe` device model via `DiscoverAsync` | **Universal** (`SupportsOnlineDiscovery=true`) |
| **BACnet/IP** | Who-Is + object-list via `DiscoverAsync` | **Universal** for small sites; bespoke-lazy `IBrowseSession` a documented P-later for large multi-device sites |
| **AbCip** *(existing)* | CIP Symbol 0x6B/0x6C in `DiscoverAsync` | **Universal** (+ `PatchForBrowse` `EnableControllerBrowse:true`) |
| **TwinCAT** *(existing)* | ADS symbol upload in `DiscoverAsync` | **Universal** (+ patch); bespoke lazy only if symbol set too big |
| **FOCAS** *(existing)* | FixedTree curated discovery | **Universal** (curated; + patch `FixedTree.Enabled:true` + an `UntilStable` settle — its tree fills post-connect, so a one-shot capture would be empty) |
| **MQTT/Sparkplug** | passive topic/birth observation window | **Bespoke** `MqttBrowseSession` — runtime discovery is authored-only, so universal would replay only authored tags |
| **SQL poll** | `INFORMATION_SCHEMA` schema walk | **Bespoke** `SqlBrowseSession` — schema walk ≠ `DiscoverAsync` |
| **Omron** | libplctag `@tags` unsupported on NJ/NX | **None live** — offline Sysmac tag-export importer (P-later); FINS never browsable |
| **Modbus RTU** | flat registers, no discovery | **None** — not browsable |
| S7, AbLegacy, Modbus(TCP) *(existing)* | flat / no on-wire symbols | **None** — documented in the audit |
Bespoke browsers **override** the universal fallback simply by being registered for their
`DriverType` (`BrowserSessionService` resolves bespoke-first). See the universal-browser design §3
for the required `BrowserSessionService` fallback change (the `ToDictionary` dup-key constraint
means the universal browser is a **separate `IUniversalDriverBrowser`**, not one of the injected
`IDriverBrowser` set).
## 5. Library & license decisions
| Driver | Library | License | Note |
|---|---|---|---|
| MTConnect | TrakHound MTConnect.NET (`-Common` + `-HTTP`) | MIT | netstd2.0 → loads on net10; pin + license-review; hand-roll fallback |
| BACnet/IP | System.IO.BACnet (ela-compil fork, `BACnet`) | MIT | multi-targets net10; YABE engine; callback API → TCS adapter |
| MQTT/Sparkplug | MQTTnet v5 + **hand-rolled Tahu protobuf** | MIT | **Not SparkplugNet** — its MQTTnet-4.x transitive pin collides with the repo's deliberately-OFF transitive pinning (Roslyn-split constraint) |
| Omron (CIP) | libplctag.NET | MPL-2.0 | same dep AbCip already ships (`plc=omron-njnx`); FINS hand-rolled |
| SQL poll | Microsoft.Data.SqlClient | — | already in-repo at 6.1.1 — zero new deps for P1 |
| Modbus RTU | extend existing Modbus | — | **RTU-over-TCP only** behind the **existing** `IModbusTransport` seam; zero new deps. Direct serial (`System.IO.Ports`) **descoped** (user, 2026-07-15) — serial buses are reached via serial→Ethernet gateways |
| ~~MELSEC~~ | ~~hand-rolled 3E~~ | — | deferred; HslCommunication is a commercial-license blocker |
**Avoid HslCommunication** (commercial license; the "MIT" claim is a stale abandoned fork) for both
MELSEC and Omron.
## 6. Dependency graph & build order
```
Wave 0 ─ Universal browser ── adds ITagDiscovery.SupportsOnlineDiscovery ──┐
│ (DiscoveryDriverBrowser + CapturingAddressSpaceBuilder + │ every browsable
│ BrowserSessionService fallback) │ driver depends
│ │ on this member
├── immediately lights up AbCip / TwinCAT / FOCAS pickers (existing) │
▼ │
Wave 1 ─ Modbus RTU (extend, lowest effort) · SQL poll (bespoke schema browser) ◄┘
Wave 2 ─ MTConnect (universal browse) · MQTT/Sparkplug (bespoke observation browser)
Wave 3 ─ BACnet/IP (universal browse + COV) · Omron (CIP-first; last — no NJ/NX CIP sim)
```
| Wave | Item | Browse | Write v1 | Effort |
|---|---|---|---|---|
| 0 | Universal `DiscoveryDriverBrowser` | — | — | SM (~35 d) |
| 1 | Modbus RTU | none | Yes | **S (lowest)** |
| 1 | SQL poll | bespoke schema | No | SM |
| 2 | MTConnect Agent | universal | No | SM |
| 2 | MQTT/Sparkplug B | bespoke observation | No | ML |
| 3 | BACnet/IP | universal (+ bespoke-lazy P-later) | No | ML |
| 3 | Omron | offline import | Yes | ML |
**Sequencing rationale:** Wave 0 first because the `SupportsOnlineDiscovery` seam gates every
browsable driver and it retrofits browse to three shipped drivers for near-zero marginal cost.
Wave 1 is the low-effort/high-leverage pair (RTU extends an existing driver; SQL adds zero deps).
Wave 2 is the strategic telemetry/UNS pair. Wave 3 is the heavier/test-gated pair — Omron last
because there is **no free NJ/NX CIP simulator**, so its wire correctness is a live-hardware gate.
MELSEC is out of the sequence (deferred).
## 7. Testing & fixtures
- Fixtures live under `tests/.../Docker/`, deployed to the shared host `10.100.0.35` via
`lmxopcua-fix sync` (which owns the `project=lmxopcua` labelling host-side). Per-driver sims: `mtconnect/cppagent`, Mosquitto/EMQX +
Sparkplug simulator, `bacnet-stack`/ela-compil `BasicServer`, central SQL Server
(`10.100.0.35,14330`) + seeded table, `rtu_over_tcp` pymodbus profile (no serial rig —
direct-serial descoped).
- **Env-gated live suites** for what a sim can't cover: BACnet Who-Is/BBMD UDP broadcast, Omron CIP
wire (live hardware), MQTT broker soak.
- Per driver: unit (framer/parser/type-map with golden vectors + fakes) → integration (docker
fixture) → **live `/run`** on docker-dev (picker + editor + deploy path).
## 8. Deferred / out of scope
- **MELSEC SLMP** — deferred (§1). Research complete (`melsec-slmp.md`); revisit on demand.
- **Modbus direct-serial transport** (`ModbusRtuTransport` / `System.IO.Ports`) — descoped
(user, 2026-07-15); design record kept in the RTU doc §2b. RTU-over-TCP via a
serial→Ethernet gateway is the only shipped RTU mode.
- **Write-back** for MTConnect (Interfaces), MQTT (NCMD/DCMD), BACnet (WriteProperty priority
array), SQL (parameterized UPSERT) — all documented as later phases in the respective designs.
- **Bespoke lazy browsers** for large BACnet sites and large ControlLogix/TwinCAT symbol sets —
graduate from universal only when eager one-shot discovery proves too heavy.
- Shared `Cip.Core` extraction (AbCip ↔ Omron CIP) — a follow-up, not a gate; keep Omron
self-contained in v1.
## 9. Status
Research: **done** (8 reports). Designs: **done** (7 design docs). Code: **Wave 0 landed**
the universal Discover-backed browser (P1) is code-complete and unit-tested on
`feat/universal-discovery-browser` (`CapturingAddressSpaceBuilder` + `CapturedTreeBrowseSession`
+ `DiscoveryDriverBrowser`/`IUniversalDriverBrowser` + `BrowserSessionService` fallback +
`ITagDiscovery.SupportsOnlineDiscovery` gate + AbCip/TwinCAT/FOCAS opt-ins & picker browse UI),
with the live `/run` gate outstanding. Next action: complete the Wave-0 live gate, then Wave 1.