diff --git a/CLAUDE.md b/CLAUDE.md index d274a661..7cf8f8cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -168,13 +168,29 @@ central SQL Server. > recipes (S7 blackhole, GLAuth outage, HistorianGateway LiveIntegration), the integration-test > harness, the docker-dev rig, and the deploy setup. Start there; the sections below + `docs/v2/dev-environment.md` carry the detail. -> **Migrated 2026-04-28**: Docker config + host moved off this dev VM (DESKTOP-6JL3KKO) onto the shared Linux Docker host (`DOCKER`, 10.100.0.35) so the dev VM could shed WSL2/Hyper-V and have its GPU re-attached via ESXi passthrough. Docker Desktop is no longer installed here. All checked-in `appsettings.json` defaults, fixture-class default endpoints, and `e2e-config.sample.json` were rewritten to target `10.100.0.35`. The driver fixture compose files under `tests/.../Docker/docker-compose.yml` now carry a `project: lmxopcua` label on every service. See `docs/v2/dev-environment.md` for the full rewrite (header dated 2026-04-28). +> **Migrated 2026-04-28**: Docker config + host moved off this dev VM (DESKTOP-6JL3KKO) onto the shared Linux Docker host (`DOCKER`, 10.100.0.35) so the dev VM could shed WSL2/Hyper-V and have its GPU re-attached via ESXi passthrough. Docker Desktop is no longer installed here. All checked-in `appsettings.json` defaults, fixture-class default endpoints, and `e2e-config.sample.json` were rewritten to target `10.100.0.35`. See `docs/v2/dev-environment.md` for the full rewrite (header dated 2026-04-28). + +> ⚠️ **The `project: lmxopcua` label is aspirational, not universal.** This note used to claim every fixture compose file carries it. As of 2026-07-24 only the **MQTT** fixture actually does — `grep -rn "labels:" tests --include=*.yml` matches `Driver.Mqtt.IntegrationTests/Docker/docker-compose.yml` and nothing else. So `docker ps --filter label=project=lmxopcua` returns the MQTT fixture alone, not the fleet. Add the label when you touch an older fixture; until then enumerate by container name. Docker workloads run on a shared Linux host at **`10.100.0.35`** — not on this VM. Stacks live at `/opt/otopcua-/` on the host and carry the `project=lmxopcua` label so they're discoverable via `docker ps --filter label=project=lmxopcua`. -**`docker -H ssh://...` does NOT work from this VM.** Windows OpenSSH ↔ docker.exe stdio bridging hangs (`docker system dial-stdio` runs server-side but no API data flows). Use the helper below — it SSHes into the docker host and runs `docker compose` server-side. +**`docker -H ssh://...` does NOT work from the Windows VM.** Windows OpenSSH ↔ docker.exe stdio bridging hangs (`docker system dial-stdio` runs server-side but no API data flows). Use the helper below — it SSHes into the docker host and runs `docker compose` server-side. -**Use `lmxopcua-fix.ps1` (in `~/bin`) to control fixtures from this VM:** +> **On macOS there is no `lmxopcua-fix` — drive the host over SSH directly.** The helper is a Windows-VM +> script; `~/bin` is empty on the Mac and the commands below will not resolve. Use passwordless SSH +> (and `rsync` in place of `sync`), which is what `infra/README.md` §1 documents as the Mac path: +> +> ```bash +> ssh dohertj2@10.100.0.35 'docker ps --format "{{.Names}}\t{{.Status}}"' +> ssh dohertj2@10.100.0.35 'cd /opt/otopcua-mqtt && docker compose up -d' +> rsync -av tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/ \ +> dohertj2@10.100.0.35:/opt/otopcua-mqtt/ # the `sync` step, by hand +> ``` +> +> A local container runtime *does* exist on the Mac (OrbStack), so the `docker-dev/` rig itself runs +> locally there — it is only the shared **fixtures** that live on `10.100.0.35`. + +**Use `lmxopcua-fix.ps1` (in `~/bin`) to control fixtures from the Windows VM:** ```powershell lmxopcua-fix ls # list all lmxopcua-tagged containers on the host @@ -195,6 +211,11 @@ lmxopcua-fix sync modbus # rsync this repo's tests/.../Docker/ - AB CIP: `10.100.0.35:44818` (`AB_SERVER_ENDPOINT`) - S7: `10.100.0.35:1102` (`S7_SIM_ENDPOINT`) - OPC UA reference (opc-plc): `opc.tcp://10.100.0.35:50000` (`OPCUA_SIM_ENDPOINT`) +- MQTT (Mosquitto, **auth on both listeners — no anonymous fallback**): `10.100.0.35:8883` TLS+auth + (`MQTT_FIXTURE_ENDPOINT`) · `10.100.0.35:1883` plaintext-but-authenticated (`MQTT_FIXTURE_PLAIN_ENDPOINT`). + Also needs `MQTT_FIXTURE_USERNAME` / `MQTT_FIXTURE_PASSWORD`, and `MQTT_FIXTURE_CA_CERT` to pin the + fixture CA (`scp dohertj2@10.100.0.35:/opt/otopcua-mqtt/secrets/ca.crt`). A co-running publisher loops + JSON under `otopcua/fixture/…`. Override any endpoint via the env var to point at a real PLC. The local OtOpcUa server runs on this VM at `opc.tcp://localhost:4840` — **that's not on the docker host**. diff --git a/README.md b/README.md index f90372d9..2f0b0963 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OtOpcUa -OPC UA server (.NET 10 AnyCPU) that exposes a fleet of industrial drivers as a single OPC UA address space. Drivers ship in-process for AVEVA System Platform Galaxy (via the sibling `mxaccessgw` repo), Modbus TCP, Siemens S7, Allen-Bradley CIP (ControlLogix / CompactLogix), Allen-Bradley Legacy (SLC 500 / MicroLogix), Beckhoff TwinCAT (ADS), FANUC FOCAS, and OPC UA Client (gateway). +OPC UA server (.NET 10 AnyCPU) that exposes a fleet of industrial drivers as a single OPC UA address space. Drivers ship in-process for AVEVA System Platform Galaxy (via the sibling `mxaccessgw` repo), Modbus TCP, Siemens S7, Allen-Bradley CIP (ControlLogix / CompactLogix), Allen-Bradley Legacy (SLC 500 / MicroLogix), Beckhoff TwinCAT (ADS), FANUC FOCAS, OPC UA Client (gateway), and MQTT (broker subscribe; Sparkplug B in progress). A cross-platform client stack (.NET 10) — shared library, CLI, and Avalonia desktop app — connects to any OPC UA server. @@ -15,7 +15,7 @@ A cross-platform client stack (.NET 10) — shared library, CLI, and Avalonia de | address space + capability fan-out| +-------------------------------------+ | | | | | | | | - Galaxy Modbus S7 AbCip AbLeg TwinCAT FOCAS OpcUaClient + Galaxy Modbus S7 AbCip AbLeg TwinCAT FOCAS OpcUaClient MQTT | v mxaccessgw (sibling repo, gRPC) @@ -91,7 +91,7 @@ See [docs/Client.CLI.md](docs/Client.CLI.md) and [docs/Client.UI.md](docs/Client |---|---| | Driver specs (per-driver capability surface, config, addressing) | [docs/v2/driver-specs.md](docs/v2/driver-specs.md) | | Galaxy driver | [docs/drivers/Galaxy.md](docs/drivers/Galaxy.md) | -| Modbus / S7 / AbCip / AbLegacy / TwinCAT / FOCAS / OpcUaClient | [docs/drivers/](docs/drivers/) | +| Modbus / S7 / AbCip / AbLegacy / TwinCAT / FOCAS / OpcUaClient / MQTT | [docs/drivers/](docs/drivers/) | | Galaxy parity rig (mxaccessgw setup) | [docs/v2/Galaxy.ParityRig.md](docs/v2/Galaxy.ParityRig.md) | | Galaxy performance + tracing | [docs/v2/Galaxy.Performance.md](docs/v2/Galaxy.Performance.md) | diff --git a/docs/drivers/Mqtt-Test-Fixture.md b/docs/drivers/Mqtt-Test-Fixture.md new file mode 100644 index 00000000..622758b2 --- /dev/null +++ b/docs/drivers/Mqtt-Test-Fixture.md @@ -0,0 +1,104 @@ +# MQTT test fixture + +Coverage map + gap inventory for the MQTT driver's harness. + +**TL;DR:** the unit suite (266 tests) carries the mapping, indexing and failure-classification logic; +the live suite (7 tests) proves the parts only a real broker can prove — TLS, real authentication, CA +pinning in both directions, retained-message seeding, and that a rejected CONNACK is actually +surfaced. Sparkplug B has **no fixture at all** — it is unimplemented (P2). + +## What the fixture is + +`eclipse-mosquitto:2.0.22` plus a second container of the same image running `publisher/publish.sh` +(the image ships `mosquitto_pub`, so no bespoke image is needed). Compose lives at +`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/`; the deployed stack is +`/opt/otopcua-mqtt` on the shared Docker host. + +**Two authenticated listeners, no anonymous fallback** (`allow_anonymous false`) — deliberate: an +anonymous broker would let a broken auth config pass every test. + +| Listener | Endpoint | Env var | +|---|---|---| +| TLS + auth | `10.100.0.35:8883` | `MQTT_FIXTURE_ENDPOINT` | +| plaintext + auth | `10.100.0.35:1883` | `MQTT_FIXTURE_PLAIN_ENDPOINT` | + +`MqttFixture` gates the suite on `MQTT_FIXTURE_ENDPOINT` / `MQTT_FIXTURE_USERNAME` / +`MQTT_FIXTURE_PASSWORD` (+ `MQTT_FIXTURE_CA_CERT`, `MQTT_FIXTURE_TOPIC_PREFIX`) and **skips cleanly** +when they are absent, so the suite is safe to run offline. + +**Unlike every other fixture it needs generated material first** — `./gen-fixture-material.sh` writes +the gitignored `secrets/` (password file, CA, server cert/key). The broker will not start without it. +Bring-up and the CA-fetch recipe are in [`infra/README.md`](../../infra/README.md) §3. + +### Published topics + +| Topic | Shape | Retain | Why it exists | +|---|---|---|---| +| `otopcua/fixture/retained/seed` | `{"value":42.5,…}` | ✅ **published exactly once** | The only way to prove retained *seeding*. On a republished topic a retained seed is indistinguishable from a fast live publish | +| `otopcua/fixture/line1/temperature` | `{"value":…,"unit":"C","seq":n}` | ✅ | JSON + JSONPath extraction | +| `otopcua/fixture/line1/counter` | `{"value":n}` | ✅ | Monotonic, for ordering | +| `otopcua/fixture/line1/state` | `RUNNING` (bare text) | ✅ | Non-JSON `Raw`/`Scalar` payloads | +| `otopcua/fixture/noise/chatter` | JSON | ❌ | **Never authored as a tag** — a broker carrying unauthored traffic is the normal case and the driver must stay silent about it | + +## What it actually covers + +The 7 live tests: TLS+auth connect; connect pinned to the fixture CA; connect pinned to a *foreign* +CA is **rejected** (the falsifiability control — a pinning test that only ever passes proves nothing); +wrong password is rejected **and surfaced** (the MQTTnet-5 silent-`Healthy` defect); plain +subscribe→value under a RawPath; retained message seeds a value on subscribe; and an unauthored topic +stays silent while an authored tag keeps flowing. + +Live-gated end-to-end (2026-07-24, P1 milestone): deployed on the docker-dev rig against this fixture, +both tags resolved and served changing values through OPC UA, and the AdminUI `#`-observation picker +rendered the real topic tree. + +## What it does NOT cover + +### 1. Sparkplug B — entirely +No edge-node simulator exists. Birth/alias/seq-gap/rebirth/death are P2 (Tasks 15–26) and the +project-owned C# simulator is part of that work. + +### 2. Broker-side failure injection +No test kills the broker mid-session, partitions the network, or forces a SUBACK failure against a +real broker. Reconnect, backoff and per-topic degradation are **unit-proven only**. + +### 3. Wildcard tags against a live broker +The by-filter indexing fix that keeps wildcard tags alive across a reconnect is covered by unit tests; +no live test authors a `+`/`#` tag and bounces the connection. + +### 4. QoS 2 delivery semantics +The fixture publishes at QoS 0/1. QoS 2's exactly-once handshake is never exercised end-to-end. + +### 5. Scale +One publisher, five topics, a ~2 s cadence. Nothing probes the 50 000-node browse cap, the 1 MiB +payload cap, or thousands of authored tags. + +### 6. Redundant-pair client-id collision +Found by hand during the P1 gate (a fixed `clientId` makes pair nodes evict each other), **not** by a +test. No automated coverage asserts that two concurrent drivers coexist. + +## When to trust the MQTT fixture, when to reach for a real broker + +| Question | Fixture answers it? | +|---|---| +| Does TLS + auth work, and does bad auth fail loudly? | ✅ yes | +| Is a retained message seeded on subscribe? | ✅ yes | +| Does JSONPath extraction + coercion behave? | ✅ yes (unit + live) | +| Does the driver ignore unauthored traffic? | ✅ yes | +| Does it survive a broker restart / flapping link? | ❌ unit-only | +| Does it hold up at plant tag counts? | ❌ no | +| Anything Sparkplug B | ❌ not implemented | + +## Follow-up candidates + +1. Broker-restart / SUBACK-failure live tests (closes gap 2). +2. A live wildcard-across-reconnect test (closes gap 3). +3. A concurrent-two-driver test pinning the client-id hazard (closes gap 6). +4. The Sparkplug edge-node simulator + its rebirth/seq-gap/death matrix (P2). + +## Key fixture / config files + +- `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/docker-compose.yml` +- `tests/Drivers/…/Docker/mosquitto.conf` · `Docker/gen-fixture-material.sh` · `Docker/publisher/publish.sh` +- `tests/Drivers/…/MqttFixture.cs` · `PlainMqttLiveTests.cs` +- [`infra/README.md`](../../infra/README.md) §3 — bring-up, endpoints, CA fetch diff --git a/docs/drivers/Mqtt.md b/docs/drivers/Mqtt.md new file mode 100644 index 00000000..632384b3 --- /dev/null +++ b/docs/drivers/Mqtt.md @@ -0,0 +1,170 @@ +# MQTT Driver + +In-process MQTT **broker-subscribe** driver. It holds one MQTTnet 5 client against one broker, +subscribes to the topics its authored tags name, and forwards each PUBLISH straight to +`ISubscribable.OnDataChange`. Ingest is **push, not poll**, and **one-way** — there is no `IWritable`. + +Sparkplug B is **P2** and not implemented: `mode: SparkplugB` is a placeholder everywhere it appears. +See [Mqtt-Test-Fixture.md](Mqtt-Test-Fixture.md) for the harness and +[`../plans/2026-07-15-mqtt-sparkplug-driver-design.md`](../plans/2026-07-15-mqtt-sparkplug-driver-design.md) +for the design. + +## Project Layout + +| Project | Holds | +|---|---| +| `Driver.Mqtt.Contracts` | Config records, enums, `MqttTagDefinition` + the pure `MqttTagDefinitionFactory`, and the one shared `MqttJson.Options`. **Deliberately transport-free** — no MQTTnet reference | +| `Driver.Mqtt` | Runtime: `MqttDriver`, `MqttConnection` (connect + reconnect supervisor), `MqttSubscriptionManager`, `LastValueCache`, factory registration, `MqttDriverProbe` | +| `Driver.Mqtt.Browser` | `MqttDriverBrowser` + `MqttBrowseSession` — the AdminUI address picker's `#`-observation session | + +> `.Browser` references `.Driver` (not just `.Contracts`) — a **documented, deliberate exception** +> so browse can reuse `MqttConnection.BuildClientOptions` (TLS / CA-pin / credentials / protocol +> mapping). Its cost is pulling `Core` into the AdminUI graph. The csproj carries a boxed warning; +> do not copy the pattern into a new driver's browser. The clean fix (deferred) is a leaf +> `Driver.Mqtt.Transport` project. + +## Capability Surface + +```csharp +public sealed class MqttDriver + : IDriver, ITagDiscovery, ISubscribable, IReadable, IHostConnectivityProbe, IRediscoverable, IAsyncDisposable, IDisposable +``` + +| Capability | Entry point | Notes | +|---|---|---| +| `IDriver` | `InitializeAsync` / `ReinitializeAsync` / `ShutdownAsync` / `GetHealth` | Order **register → attach → connect** is contractual. `ReinitializeAsync` applies a tag-only delta in place, and rebuilds the session when the broker identity changed | +| `ITagDiscovery` | `DiscoverAsync` | Replays **only the authored `rawTags`** into an `Mqtt` folder. `SupportsOnlineDiscovery => false`, `RediscoverPolicy => Once` — *a broker's live topic set is not a tag catalogue* | +| `ISubscribable` | `SubscribeAsync` / `OnDataChange` | One SUBSCRIBE per distinct authored topic. **`publishingInterval` is ignored** — MQTT is push-based; this driver neither polls nor throttles | +| `IReadable` | `ReadAsync` | Serves the last received/retained value from `LastValueCache`; never throws. A never-seen tag reads `BadWaitingForInitialData` | +| `IHostConnectivityProbe` | `GetHostStatuses` | 1-second poll of `MqttConnection.State`; `HostName` is `"{host}:{port}"` | +| `IRediscoverable` | `OnRediscoveryNeeded` | **Seam only** — nothing raises it in Plain mode. Sparkplug DBIRTH will | + +**Not implemented:** `IWritable`, `IPerCallHostResolver`, `IAlarmSource`, `IHistoryProvider`. +Materialized nodes are `SecurityClassification.ViewOnly`, `IsAlarm: false`, `IsHistorized: false` — +*"MQTT ingest is one-way in P1 … ViewOnly rather than an Operate node whose writes would silently vanish."* + +Driver-type string: **`Mqtt`** (`DriverTypeNames.Mqtt`). + +## Configuration + +Bound through `MqttJson.Options` — **case-insensitive**, enums by **name**, unknown members skipped. +Author the connection on the **device** (`DeviceConfig`) and the mode on the **driver** +(`DriverConfig`); `DriverDeviceConfigMerger` merges them, and injects `rawTags`. + +```jsonc +// DriverConfig +{ "Mode": "Plain", "Plain": { "TopicPrefix": "", "DefaultQos": 1 } } + +// DeviceConfig +{ + "Host": "10.100.0.35", + "Port": 8883, + "UseTls": true, + "Username": "otopcua", + "Password": "", // supply via ${secret:} / env — never commit + "CaCertificatePath": null // pins the chain; null = OS trust store +} +``` + +Every key has a default, so nothing is strictly required by the binder. Notable ones: +`port` **8883**, `useTls` **true**, `protocolVersion` **V500**, `cleanSession` **true**, +`keepAliveSeconds` **30**, `connectTimeoutSeconds` **15**, +`reconnectMinBackoffSeconds` **1** / `reconnectMaxBackoffSeconds` **30**, +`maxPayloadBytes` **1 MiB**, `allowUntrustedServerCertificate` **false**. + +> ⚠️ **Leave `clientId` unset on a redundant pair.** Both nodes of a pair run the driver. MQTT +> requires client ids to be unique per broker, so a *fixed* `clientId` makes the two nodes evict each +> other forever — the broker logs `Client already connected, closing old connection` and both +> nodes reconnect every few seconds while still reporting `Healthy`. Unset (the default) lets MQTTnet +> generate a unique id per connection, which is correct. This was observed live during the P1 gate. +> (The probe and browser already self-uniquify with `-probe-` / `-browse-` suffixes.) + +## Tag Configuration + +| Key | Type | Default | Notes | +|---|---|---|---| +| `topic` | string | — | **Required.** Absent/blank rejects the tag ⇒ `BadNodeIdUnknown` | +| `payloadFormat` | `Json` \| `Raw` \| `Scalar` | `Json` | **Strict** — a typo'd value rejects the tag | +| `jsonPath` | string | `"$"` | Absent *or empty* ⇒ document root. `Json` only | +| `dataType` | `DriverDataType` | `String` | **Strict.** `Float64`, not `Double` — see below | +| `qos` | int 0–2 | driver's `defaultQos` | **Strict** — a malformed value rejects rather than silently weakening delivery | +| `retainSeed` | bool | `true` | Seed this tag from the broker's retained message on subscribe | + +There is **no `mode` key** — the AdminUI's "Tag shape" selector is UI-only, inferred from the blob and +never serialized. + +**`dataType` uses `DriverDataType`, which has `Float64` and has no `Double`.** Authoring `"Double"` is +a strict-enum rejection, not a fallback. + +Payload handling: `Json` selects via JSONPath then coerces (miss ⇒ `BadDecodingError`, coerce fail ⇒ +`BadTypeMismatch`); `Raw` returns the payload's UTF-8 text verbatim and **ignores `dataType`**; +`Scalar` decodes then coerces. The JSONPath subset is `$`, `$.a.b`, `$['a']`, `$.a[0]` — **no +filters, slices, wildcards or recursive descent**. + +### Wildcards + +The **runtime accepts** a `+`/`#` topic (it is ambiguous, not unparseable) and the deploy-time +`Inspect` pass **warns**. The **AdminUI editor rejects** it outright — the one place the editor is +deliberately stricter than the driver, because one Tag holds one value and a wildcard would feed it +from many source topics. Wildcard tags are indexed **by filter**, not by concrete topic, so they +survive a reconnect; a message fans out to *every* matching tag, and unauthored topics are dropped. + +## Connection + Failure Semantics + +`MqttConnectionState`: `Disconnected → Connected → Reconnecting → Faulted → Disposed`. +A broker that is merely down stays **`Reconnecting` indefinitely** — unreachable is never a fault. +Backoff is exponential between the two `reconnectBackoff` bounds. + +**MQTTnet 5 does not throw on a rejected CONNACK** — it returns the code and leaves the client +disconnected. Left unhandled, a wrong password produced a `Healthy` driver that never received a +value. `MqttConnection` therefore raises `MqttConnectRejectedException`: + +- **Unrecoverable ⇒ `Faulted`, supervisor stops** (retrying cannot help): `MalformedPacket`, + `ProtocolError`, `UnsupportedProtocolVersion`, `ClientIdentifierNotValid`, `BadUserNameOrPassword`, + `NotAuthorized`, `Banned`, `BadAuthenticationMethod`, `TopicNameInvalid`, `PacketTooLarge`, + `PayloadFormatInvalid`, `RetainNotSupported`, `QoSNotSupported`, `ServerMoved`. +- **Everything else is transient** and keeps retrying — including `UnspecifiedError` and any future + code. (`ServerMoved` is permanent, `UseAnotherServer` is temporary — per the MQTT 5 spec.) + +A reconnect that completes without re-subscribing would be a healthy-looking connection that receives +nothing forever, so `OnReconnectedAsync` **throws** when zero filters are granted, tearing the session +down rather than swallowing it. A SUBACK failure degrades just the affected RawPaths to +`BadCommunicationError`. + +## Browse + +`MqttDriverBrowser` opens a **read-only observation session**: it subscribes at QoS 0 to `#` (or +`{topicPrefix}/#`) under a `-browse-`-suffixed client id and builds a `/`-segment tree from whatever +publishes. **Nothing is ever published.** It is *inherently incomplete* — a topic that stays silent +during the window is invisible — so manual entry remains the escape hatch. + +Bounds: 50 000 nodes (then `⚠ Observation truncated`), 1024-char topics / 256-char segments (then +`⚠ Some topics were too long`), 512-byte payload inspection. Sessions are reaped after 2 minutes idle. +`SparkplugB` browse throws `NotSupportedException`. + +The bespoke browser wins over the universal `DiscoveryDriverBrowser`, which would decline MQTT anyway +(`SupportsOnlineDiscovery` is `false`). + +## Test Connect + +`MqttDriverProbe` performs **one CONNECT** (no SUBSCRIBE, no publish) under a `-probe-` client id and +disconnects. Success message: `"MQTT CONNECT OK"`. A rejected CONNACK is described by the same +`DescribeConnackRejection` the running driver uses, so the button and the driver report a rejection in +identical words. See [TestConnectProbes.md](TestConnectProbes.md). + +## Testing + +- **Unit** — `tests/Drivers/…Driver.Mqtt.Tests` (266 tests): tag-config mapping, JSONPath subset, + strict-enum rejection, subscription indexing (incl. the wildcard-by-filter case), reconnect/rebuild + branches, CONNACK classification. +- **Live (env-gated)** — `tests/Drivers/…Driver.Mqtt.IntegrationTests` (7 tests) against the Mosquitto + fixture; skips cleanly when `MQTT_FIXTURE_ENDPOINT` is unset. See + [Mqtt-Test-Fixture.md](Mqtt-Test-Fixture.md) and `infra/README.md` §3. + +## Operational Notes + +- **Read-only.** No writes reach the broker; every node is ViewOnly. +- **No alarms, no driver-side history.** Historization is a server-side concern (`isHistorized`). +- **Both pair nodes subscribe independently** — MQTT fan-out is per-connection, and the Primary gate + applies downstream, not to ingest. +- **Unauthored traffic is ignored.** A busy broker is normal; the driver never auto-provisions a tag. diff --git a/docs/drivers/README.md b/docs/drivers/README.md index 1bea11ad..32a63e7c 100644 --- a/docs/drivers/README.md +++ b/docs/drivers/README.md @@ -29,6 +29,7 @@ Driver type metadata is registered at startup in `DriverTypeRegistry` (`src/Core | [TwinCAT](TwinCAT.md) | `Driver.TwinCAT` | B | Beckhoff `TwinCAT.Ads` (`TcAdsClient`) | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IRediscoverable | The only native-notification driver outside Galaxy — ADS delivers `ValueChangedCallback` events the driver forwards straight to `ISubscribable.OnDataChange` without polling. Symbol tree uploaded via `SymbolLoaderFactory` | | [FOCAS](FOCAS.md) | `Driver.FOCAS` | A | Pure-managed `FocasWireClient` — FOCAS/2 Ethernet binary protocol on TCP:8193, inlined into the driver assembly | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource | `IWritable` is implemented but read-only by design — `WriteAsync` returns `BadNotWritable` for every point. CNC-shaped data model (axes, spindle, PMC, macros, alarms) not a flat tag map. Previously Tier-C (Host + P/Invoke + shim DLL); retired in the 2026-04-24 migration when the managed wire client landed | | [OPC UA Client](OpcUaClient.md) | `Driver.OpcUaClient` | B | OPCFoundation `Opc.Ua.Client` | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IAlarmSource, IHistoryProvider, IHostConnectivityProbe | Gateway/aggregation driver — the only driver implementing driver-side `IHistoryProvider` (forwards HistoryRead to the upstream server). Opens a single `Session` against a remote OPC UA server and re-exposes its address space. Owns its own `ApplicationConfiguration` (distinct from `Client.Shared`) because it's always-on with keep-alive + `TransferSubscriptions` across SDK reconnect, not an interactive CLI | +| [MQTT](Mqtt.md) | `Driver.Mqtt` (+ `.Browser`, `.Contracts`) | A | [MQTTnet 5](https://github.com/dotnet/MQTTnet) | IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IRediscoverable | **Push, not poll, and read-only** — the broker delivers PUBLISH frames the driver forwards straight to `ISubscribable.OnDataChange`; `IReadable` serves the last retained/received value from cache, and there is **no `IWritable`** (nothing publishes back). Subscriptions are indexed **by topic filter, not by topic**, so wildcard tags survive a reconnect. A rejected CONNACK throws `MqttConnectRejectedException`: unrecoverable codes → `Faulted` + supervisor stop, transient → retry. Sparkplug B is P2 (`mode: SparkplugB` is a placeholder) | | [Historian.Gateway](../Historian.md) | `Driver.Historian.Gateway` | — | `ZB.MOM.WW.HistorianGateway.Client` gRPC (`historian_gateway.v1`) | IHistorianDataSource (server-side read backend) + alarm `SendEvent` writer + `WriteLiveValues` recorder + `IHistorianProvisioning` | Not a tag driver — the sole historian backend. Registers `GatewayHistorianDataSource : IHistorianDataSource` for HistoryRead and serves alarm-write + continuous historization through the gateway. No `IDriver`/`ITagDiscovery` surface. (The retired Wonderware sidecar backend it replaced is documented at [Historian.Wonderware.md](Historian.Wonderware.md).) | ## Per-driver documentation @@ -40,13 +41,14 @@ Driver type metadata is registered at startup in `DriverTypeRegistry` (`src/Core - **FOCAS** has a short getting-started doc because the backend-selection env var + alarm projection opt-in need explaining up front: - [FOCAS.md](FOCAS.md) — deployment, config, capability surface, alarm projection, troubleshooting -- **Modbus TCP**, **AB CIP**, **AB Legacy**, **Siemens S7**, **TwinCAT**, and **OPC UA Client** each have a per-driver overview page: +- **Modbus TCP**, **AB CIP**, **AB Legacy**, **Siemens S7**, **TwinCAT**, **OPC UA Client**, and **MQTT** each have a per-driver overview page: - [Modbus.md](Modbus.md) — in-process Modbus-TCP driver: address formats, polled subscription model, DL205 octal mapping - [AbCip.md](AbCip.md) — AB CIP / EtherNet-IP driver (ControlLogix / CompactLogix / Micro800 / GuardLogix): tag discovery, UDT resolution, alarm source - [AbLegacy.md](AbLegacy.md) — AB Legacy PCCC driver (SLC 500 / MicroLogix / PLC-5): file-based addressing, user-authored tag list - [S7.md](S7.md) — Siemens S7 driver (S7-300/400/1200/1500 + S7-200): getting started, config, data-block addressing, serialized single-connection model - [TwinCAT.md](TwinCAT.md) — Beckhoff TwinCAT (ADS) driver: getting started, native-notification subscription, symbol-tree upload - [OpcUaClient.md](OpcUaClient.md) — OPC UA Client (gateway/aggregation) driver: remote-server session, driver-side HistoryRead forwarding, reconnect behaviour + - [Mqtt.md](Mqtt.md) — MQTT broker-subscribe driver: broker/TLS/auth config, topic + JSON-path tag binding, retained-value seeding, `#`-observation browser, CONNACK-rejection semantics - **Historian.Gateway** (server-side historian backend, not a tag driver) is documented in the main guide: - [../Historian.md](../Historian.md) — HistorianGateway backend: read-path registration, HistoryRead dispatch, alarm store-and-forward (`SendEvent`), continuous historization (`WriteLiveValues`), `EnsureTags` provisioning, config keys, deployment prerequisites. (The retired Wonderware sidecar backend it replaced: [Historian.Wonderware.md](Historian.Wonderware.md).) @@ -64,12 +66,13 @@ Each driver has a dedicated fixture doc that lays out what the integration / uni - [TwinCAT](TwinCAT-Test-Fixture.md) — XAR-VM integration scaffolding (task #221); three smoke tests skip when VM unreachable. Unit via `FakeTwinCATClient` with native-notification harness - [FOCAS](FOCAS-Test-Fixture.md) — no integration fixture, unit-only via `FakeFocasClient`; Tier C out-of-process isolation scoped but not shipped - [OPC UA Client](OpcUaClient-Test-Fixture.md) — Dockerized `opc-plc` integration suite (task #215): real Secure Channel + Session, read + subscribe verified end-to-end; write not yet exercised in the integration suite; exhaustive capability matrix (reconnect, failover, cert-auth, history, alarms) via unit suite with mocked `Session` +- [MQTT](Mqtt-Test-Fixture.md) — Dockerized `eclipse-mosquitto` with **TLS + real auth on both listeners (no anonymous fallback)** + a JSON-publisher sidecar; env-gated live suite (7 tests) covers TLS/auth connect, CA pinning both ways, subscribe→value, retained seeding, and wrong-password rejection. Sparkplug B has no fixture yet (P2) - [Galaxy](../v1/drivers/Galaxy-Test-Fixture.md) — richest harness: gateway E2E + ZB SQL live-smoke + MXAccess opt-in (v1 archive) ## Related cross-driver docs - [HistoricalDataAccess.md](../v1/HistoricalDataAccess.md) — `IHistoryProvider` dispatch, aggregate mapping, continuation points. The OPC UA Client driver is the only driver that implements driver-side `IHistoryProvider` (it forwards HistoryRead to the upstream server); the AVEVA Historian path is served server-side by the HistorianGateway-backed `IHistorianDataSource` instead. Other drivers do not implement the interface and return `BadHistoryOperationUnsupported`. -- [AlarmTracking.md](../AlarmTracking.md) — `IAlarmSource` event model and filtering. Implemented by Galaxy (native MxAccess alarms, working end-to-end), OPC UA Client, AB CIP, and FOCAS; AB Legacy, Modbus, S7, and TwinCAT have no alarm source. +- [AlarmTracking.md](../AlarmTracking.md) — `IAlarmSource` event model and filtering. Implemented by Galaxy (native MxAccess alarms, working end-to-end), OPC UA Client, AB CIP, and FOCAS; AB Legacy, Modbus, S7, TwinCAT, and MQTT have no alarm source. - [Subscriptions.md](../v1/Subscriptions.md) — how the Server multiplexes subscriptions onto `ISubscribable.OnDataChange`. - [docs/v2/driver-stability.md](../v2/driver-stability.md) — tier system (A / B / C), shared `CapabilityPolicy` defaults per tier × capability, `MemoryTracking` hybrid formula, and process-level recycle rules. - [docs/v2/plan.md](../v2/plan.md) — authoritative vision, architecture decisions, migration strategy. diff --git a/docs/drivers/TestConnectProbes.md b/docs/drivers/TestConnectProbes.md index 64e14cbe..c9797412 100644 --- a/docs/drivers/TestConnectProbes.md +++ b/docs/drivers/TestConnectProbes.md @@ -49,6 +49,7 @@ with a human-readable explanation rather than a false-green TCP-open tick. | **TwinCAT** | `AdsClient.Connect` + `ReadStateAsync`. See [degrade semantics](#twincat-degrade) below. | `"ADS state: {state}"` | Deferred — no ADS target | | **FOCAS** | `cnc_allclibhndl3` via a direct `DllImport("fwlib32")` in the probe. See [degrade semantics](#focas-degrade) below. | `"FOCAS handle OK"` | Deferred — no CNC + FWLIB | | **Galaxy** | gRPC unary call to `GalaxyRepository.TestConnection` on the configured mxaccessgw endpoint. See [auth-rejection rule](#galaxy-auth-rejection) below. | `"gateway gRPC OK"` | `http://10.100.0.48:5120` (mxaccessgw) | +| **MQTT** | One MQTT `CONNECT` (no SUBSCRIBE, no publish) under a `-probe-`-suffixed client id, then disconnect. **A rejected CONNACK is a returned result code, not an exception** — MQTTnet 5 does not throw — so the probe inspects `ResultCode` and reuses the driver's own `DescribeConnackRejection`, making the button and the running driver word a rejection identically. | `"MQTT CONNECT OK"` | `10.100.0.35:8883` (Mosquitto TLS+auth fixture) | **Historian.Wonderware** had a TCP `Hello`→`HelloAck` handshake probe before Phase 5, but the Wonderware historian backend (and its driver-type / probe) has since been **retired** — the historian @@ -133,6 +134,7 @@ above. (Live verification on `10.100.0.48:5120` with no key returns | S7 | Verified | python-snap7 `10.100.0.35:1102` | | AbCip | Verified | CIP sim `10.100.0.35:44818` | | Galaxy | Verified | mxaccessgw `10.100.0.48:5120`; `Unauthenticated` reply counts as Ok | +| MQTT | Verified | Mosquitto `10.100.0.35:8883`; live suite covers TLS+auth Ok, foreign-CA rejection, and wrong-password rejection surfacing rather than reporting healthy | | AbLegacy | Deferred | No PLC5/SLC sim; unit-proven + code path identical to AbCip | | TwinCAT | Deferred | No ADS target; unit-proven + degrade guard tested | | FOCAS | Deferred | No CNC + FWLIB on dev host; degrade guard is the CI-observable path | diff --git a/docs/plans/2026-07-24-driver-expansion-tracking.md b/docs/plans/2026-07-24-driver-expansion-tracking.md index 9b88fb66..b35caa62 100644 --- a/docs/plans/2026-07-24-driver-expansion-tracking.md +++ b/docs/plans/2026-07-24-driver-expansion-tracking.md @@ -33,7 +33,7 @@ it reaches 📝. | **1** | Modbus RTU (extend Modbus) | [design](2026-07-15-modbus-rtu-driver-design.md) | [plan](2026-07-24-modbus-rtu-driver.md) · [tasks](2026-07-24-modbus-rtu-driver.md.tasks.json) | 📝 **Plan ready** (11 tasks) | **S (lowest)** | `pymodbus` `rtu_over_tcp` — CI-simulatable, no new infra | | **1** | SQL poll | [design](2026-07-15-sql-poll-driver-design.md) | [plan](2026-07-24-sql-poll-driver.md) · [tasks](2026-07-24-sql-poll-driver.md.tasks.json) | 📝 **Plan ready** (22 tasks) | S–M | **existing central SQL Server** `10.100.0.35,14330` + SQLite unit | | **2** | MTConnect Agent | [design](2026-07-15-mtconnect-driver-design.md) | [plan](2026-07-24-mtconnect-driver.md) · [tasks](2026-07-24-mtconnect-driver.md.tasks.json) | 📝 **Plan ready** (23 tasks) | S–M | Dockerized `mtconnect/cppagent` — CI-simulatable | -| **2** | MQTT / Sparkplug B | [design](2026-07-15-mqtt-sparkplug-driver-design.md) | [plan](2026-07-24-mqtt-sparkplug-driver.md) · [tasks](2026-07-24-mqtt-sparkplug-driver.md.tasks.json) | 📝 **Plan ready** (27 tasks, P1+P2) | M–L | Mosquitto/EMQX + C# Sparkplug sim — CI-simulatable | +| **2** | MQTT / Sparkplug B | [design](2026-07-15-mqtt-sparkplug-driver-design.md) | [plan](2026-07-24-mqtt-sparkplug-driver.md) · [tasks](2026-07-24-mqtt-sparkplug-driver.md.tasks.json) | ✅ **P1 done** (Tasks 0–14, live-gated) · 📝 P2 Sparkplug plan ready (Tasks 15–26) | M–L | Mosquitto TLS+auth fixture **live** at `10.100.0.35:8883` | > Waves 3+ (BACnet/IP, Omron) and the deferred MELSEC are tracked in the program design doc §6/§8, > not here. Omron CIP is the program's sole real-hardware wire gate; BACnet's broadcast/BBMD leg is @@ -130,17 +130,46 @@ Both CI-simulatable on the shared docker host, no hardware. integration fixture (env-gated). Depends on Wave 0's browse seam being live-verified (#468). - **Effort:** S–M (≈1–1.5 wk with TrakHound, ≈2.5–3 wk hand-rolled). -### MQTT / Sparkplug B — 📝 Plan ready +### MQTT / Sparkplug B — ✅ P1 done (live-gated) · 📝 P2 plan ready - **Design:** [`2026-07-15-mqtt-sparkplug-driver-design.md`](2026-07-15-mqtt-sparkplug-driver-design.md) - **Implementation plan:** [`2026-07-24-mqtt-sparkplug-driver.md`](2026-07-24-mqtt-sparkplug-driver.md) · [`.tasks.json`](2026-07-24-mqtt-sparkplug-driver.md.tasks.json) — **27 tasks** (P1 plain MQTT = Tasks 0–14, a complete shippable milestone; P2 Sparkplug B = Tasks 15–26). Task 0 is the mandatory MQTTnet-5/net10 + central-pinning validation spike. - **Scope:** P1 plain MQTT (MQTTnet-5 connect/TLS/auth + hand-rolled reconnect, subscribe→ `OnDataChange`, retained last-value read, `#`-observation browser); P2 Sparkplug B ingest (vendored Tahu proto, birth/alias/seq-gap/rebirth state machine, death→STALE). -- **Fixture:** Mosquitto/EMQX broker (TLS + real auth, not anonymous) + a **project-owned C# - Sparkplug edge-node simulator** for the rebirth/seq-gap/death test matrix + env-gated live suite - (`MQTT_FIXTURE_ENDPOINT`). -- **Effort:** M–L. **Top risks:** Sparkplug state-machine correctness; MQTTnet-5/net10 + the repo's - fragile central pinning (mitigated by hand-rolling Tahu over one MQTTnet-5 client). +- **Fixture:** Mosquitto TLS+auth broker + JSON publisher sidecar, **live** at `10.100.0.35:8883` + (`:1883` plaintext-but-authenticated); stack `/opt/otopcua-mqtt`, compose in + `tests/Drivers/…​.Driver.Mqtt.IntegrationTests/Docker/`. Env-gated live suite + (`MQTT_FIXTURE_ENDPOINT`, see `infra/README.md` §3). The **C# Sparkplug edge-node simulator** is P2 + work and does not exist yet. +- **P1 status (2026-07-24):** Tasks 0–14 complete. `.Contracts`/`.Driver`/`.Browser` triad + factory, + `DriverTypeNames.Mqtt`, host factory/probe registration, AdminUI typed tag editor + bespoke + `#`-observation browser. **Offline 1131 passed / 0 failed** (266 driver · 727 AdminUI · 138 + Core.Abstractions); **live 7/7** against the fixture. +- **P1 live gate found two AdminUI authoring gaps** (the gate's whole point — both were invisible to + 266 green unit tests, because AdminUI has no bUnit and nothing tied these hand-maintained lists to + `DriverTypeNames`): + 1. **FIXED —** `RawDriverTypeDialog`'s option array never got an MQTT row, so **no operator could + create an MQTT driver at all**. Fixed, plus a reflection parity guard + (`RawDriverTypeDialogParityTests`, 3 tests) that fails for *any* future `DriverTypeNames` entry + missing from the picker — so the next driver fails a test instead of a live gate. + 2. **OPEN — `MqttDriverForm` / `MqttDeviceForm` do not exist.** `DriverConfigModal` and + `DeviceModal` dispatch on `DriverType` and fall through to *"No typed config form for driver + type Mqtt"* — and **neither modal has a raw-JSON fallback**, unlike the `/uns` TagModal. So an + operator can create an MQTT driver but cannot author its broker host/port/TLS/credentials + through the AdminUI at all. The P1 gate authored `DriverConfig`/`DeviceConfig` by direct SQL to + get past this. **P1 is not operator-complete until these two razor forms land** (a + `MqttDriverForm` + `MqttDeviceForm` pair modelled on the OpcUaClient ones, plus their + serialization tests). This is a plan omission, not a regression: no task ever covered the + driver/device forms — Task 6's "typed editor" was the *tag* editor. +- **Redundant-pair hazard worth knowing (documented, not a code change):** both nodes of a pair run + the driver, so a **fixed `clientId` makes them evict each other forever** — the broker logs + `already connected, closing old connection` and both nodes reconnect every ~2 s while still + reporting `Healthy`. Leaving `clientId` unset (the default) is correct. Observed live; captured in + `docs/drivers/Mqtt.md`. A future `MqttDriverForm` should not offer a free-text client id without + this warning. +- **Effort:** M–L. **Top risks (P2):** Sparkplug state-machine correctness; MQTTnet-5/net10 + the repo's + fragile central pinning (mitigated by hand-rolling Tahu over one MQTTnet-5 client) — the pinning risk + is **retired**: P1 shipped on MQTTnet 5 with the repo's central pinning intact. --- @@ -150,7 +179,8 @@ Both CI-simulatable on the shared docker host, no hardware. 2. **Build Wave 1** — Modbus RTU first (lowest effort, no new infra), then SQL poll. Both plans are 📝 ready; run the command from the [Executing a plan](#executing-a-plan-git-worktree--subagent-per-task) table (subagent-driven-development in a git worktree). -3. **Build Wave 2** (MTConnect, then MQTT/Sparkplug) — both plans 📝 ready. MTConnect's browse leg - wants #468 closed first; MQTT's Task 0 pinning spike gates everything after it. +3. **Build Wave 2** — MTConnect's browse leg wants #468 closed first. **MQTT P1 is done and + live-gated**; the remaining MQTT work is **P2 Sparkplug B** (Tasks 15–26), whose first step is the + vendored Tahu proto + the project-owned C# edge-node simulator the P1 fixture does not include. Update this file's Summary table and per-wave status whenever a deliverable changes state. diff --git a/infra/README.md b/infra/README.md index 18b81cda..b0d0cc08 100644 --- a/infra/README.md +++ b/infra/README.md @@ -37,7 +37,7 @@ ssh dohertj2@10.100.0.35 'docker ps --filter label=project=lmxopcua --format "{{ | Stack dir | Purpose | |---|---| | `/opt/otopcua-mssql` | central SQL (always-on) | - | `/opt/otopcua-modbus` · `/opt/otopcua-abcip` · `/opt/otopcua-s7` · `/opt/otopcua-opcuaclient` | driver fixtures | + | `/opt/otopcua-modbus` · `/opt/otopcua-abcip` · `/opt/otopcua-s7` · `/opt/otopcua-opcuaclient` · `/opt/otopcua-mqtt` | driver fixtures | | `~/otopcua-ablegacy` · `~/otopcua-focas` | driver fixtures (user-owned) | | `~/otopcua-harness` | Host.IntegrationTests real-mode deps (SQL + GLAuth) — see §4 | @@ -69,9 +69,19 @@ when it isn't running. Bring one up, `dotnet test`, tear down. Repo compose live | S7 | `otopcua-python-snap7` | `10.100.0.35:1102` | `docker compose --profile s7_1500 up -d` | | OpcUaClient | `mcr.microsoft.com/iotedge/opc-plc:2.14.10` | `opc.tcp://10.100.0.35:50000` | `docker compose up -d` | | FOCAS | `otopcua-focas-sim` (vendored focas-mock) | `10.100.0.35:8193` | `docker compose up -d` (stack `~/otopcua-focas`) | +| MQTT | `eclipse-mosquitto:2.0.22` (+ a publisher sidecar) | `10.100.0.35:8883` TLS+auth · `:1883` plaintext+auth | `MQTT_FIXTURE_USERNAME=otopcua MQTT_FIXTURE_PASSWORD= docker compose up -d` (stack `/opt/otopcua-mqtt`) | **Endpoint overrides** point a suite at a real PLC instead of the sim: -`MODBUS_SIM_ENDPOINT` · `AB_SERVER_ENDPOINT` · `S7_SIM_ENDPOINT` · `OPCUA_SIM_ENDPOINT`. +`MODBUS_SIM_ENDPOINT` · `AB_SERVER_ENDPOINT` · `S7_SIM_ENDPOINT` · `OPCUA_SIM_ENDPOINT` · +`MQTT_FIXTURE_ENDPOINT` / `MQTT_FIXTURE_PLAIN_ENDPOINT`. + +> **MQTT needs credentials + material, unlike the other fixtures.** The broker has **no anonymous +> fallback on either listener** (deliberate — an anonymous broker would let a bad auth config pass), +> so it will not even start until `./gen-fixture-material.sh` has written `secrets/` (password file + +> CA + server cert/key, gitignored) and `MQTT_FIXTURE_USERNAME`/`MQTT_FIXTURE_PASSWORD` are exported +> at `docker compose up`. The live suite additionally wants `MQTT_FIXTURE_CA_CERT` pointing at a local +> copy of the CA: `scp dohertj2@10.100.0.35:/opt/otopcua-mqtt/secrets/ca.crt /tmp/mqtt-fixture-ca.crt`. +> It is also the only fixture whose services carry the `project: lmxopcua` label (see CLAUDE.md). > **Fixture-cycling gotcha:** profile-gated services can share a port; a plain `docker compose down` > may leave a stale container bound. Force-remove before switching profiles: diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor index e43dc14a..2d3397eb 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor @@ -67,6 +67,7 @@ ("TwinCAT", DriverTypeNames.TwinCAT), ("FOCAS", DriverTypeNames.FOCAS), ("OpcUaClient", DriverTypeNames.OpcUaClient), + ("MQTT", DriverTypeNames.Mqtt), ("Galaxy", DriverTypeNames.Galaxy), ("Calculation", "Calculation"), ]; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/RawDriverTypeDialogParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/RawDriverTypeDialogParityTests.cs new file mode 100644 index 00000000..c3a9721b --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/RawDriverTypeDialogParityTests.cs @@ -0,0 +1,85 @@ +using System.Reflection; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Raw; + +/// +/// Guards the /raw "New driver" picker against driver-type drift. +/// +/// +/// +/// is the only surface an operator can author a +/// /raw driver from, and its option list is a hand-maintained array — nothing tied it to +/// . The MQTT P1 live gate found the consequence: the driver shipped +/// complete (contracts, driver, browser, factory + host registration, typed tag editor, 266 green +/// unit tests) and was still unauthorable in production, because nobody added the row to +/// this array. Every offline test passed the whole way — the array is not reachable from any of +/// them, and AdminUI has no bUnit. +/// +/// +/// So this asserts parity by reflection rather than re-listing the drivers: a new +/// constant that nobody surfaces in the picker fails here instead of +/// at someone's live gate. It reads the private static Types field because that array is +/// the component's own source of truth for the rendered <option> set. +/// +/// +public sealed class RawDriverTypeDialogParityTests +{ + private static IReadOnlyList<(string Label, string Value)> DialogTypes() + { + var field = typeof(RawDriverTypeDialog).GetField( + "Types", + BindingFlags.NonPublic | BindingFlags.Static); + + field.ShouldNotBeNull( + "RawDriverTypeDialog.Types was renamed or removed — this guard reads it by name and " + + "silently guards nothing once it cannot find it."); + + var raw = (Array)field!.GetValue(null)!; + + return raw.Cast() + .Select(t => + { + var type = t.GetType(); + return ((string)type.GetField("Item1")!.GetValue(t)!, + (string)type.GetField("Item2")!.GetValue(t)!); + }) + .ToList(); + } + + [Fact] + public void Every_declared_driver_type_is_offered_by_the_new_driver_picker() + { + var offered = DialogTypes().Select(t => t.Value).ToHashSet(StringComparer.Ordinal); + + var missing = DriverTypeNames.All.Where(n => !offered.Contains(n)).ToList(); + + missing.ShouldBeEmpty( + $"These driver types are declared in DriverTypeNames but cannot be authored from the " + + $"/raw New-driver picker, so they are unreachable in production: {string.Join(", ", missing)}. " + + "Add a row to RawDriverTypeDialog.Types."); + } + + [Fact] + public void The_picker_offers_no_driver_type_that_is_not_declared() + { + var declared = DriverTypeNames.All.ToHashSet(StringComparer.Ordinal); + + var unknown = DialogTypes().Select(t => t.Value).Where(v => !declared.Contains(v)).ToList(); + + unknown.ShouldBeEmpty( + $"The /raw New-driver picker offers driver types with no DriverTypeNames constant, so " + + $"authoring one produces a driver no factory can build: {string.Join(", ", unknown)}."); + } + + [Fact] + public void Mqtt_is_authorable_from_the_new_driver_picker() + { + // The specific regression the P1 live gate caught, pinned by name so a future refactor of the + // parity facts above cannot quietly drop MQTT again. + DialogTypes().Select(t => t.Value).ShouldContain(DriverTypeNames.Mqtt); + } +}