# 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..Contracts` — options + tag DTOs + parser + enums (no backend NuGet dep). 2. `ZB.MOM.WW.OtOpcUa.Driver.` — 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 `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` (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. - **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 | — | serial impls behind the **existing** `IModbusTransport` seam; P1 (RTU-over-TCP) zero new deps, P2 direct-serial adds `System.IO.Ports` | | ~~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` | — | — | S–M (~3–5 d) | | 1 | Modbus RTU | none | Yes | **S (lowest)** | | 1 | SQL poll | bespoke schema | No | S–M | | 2 | MTConnect Agent | universal | No | S–M | | 2 | MQTT/Sparkplug B | bespoke observation | No | M–L | | 3 | BACnet/IP | universal (+ bespoke-lazy P-later) | No | M–L | | 3 | Omron | offline import | Yes | M–L | **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 + socat serial pair. - **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. - **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: **not started.** Next action: execute Wave 0 (universal browser), then Wave 1.