docs(mqtt): P1 plain-MQTT milestone live-verified; endpoint recorded
Task 14 — the P1 milestone gate. Ran the live /run verification on a docker-dev
rig against the real Mosquitto TLS+auth fixture, and recorded the result.
The gate did its job: it found MQTT was unauthorable in production.
RawDriverTypeDialog's driver-type list is a hand-maintained array that nobody
added MQTT to, so the /raw "New driver" picker never offered it — with every
other layer (contracts, driver, browser, factory, host registration, typed tag
editor) complete and 266 green unit tests. Nothing tied that array to
DriverTypeNames and AdminUI has no bUnit, so no offline test could see it.
Fixed, plus RawDriverTypeDialogParityTests: a reflection parity guard that
fails for ANY DriverTypeNames entry missing from the picker. Verified
falsifiable — RED with "…cannot be authored from the /raw New-driver picker,
so they are unreachable in production: Mqtt" before the one-line fix.
A second gap is left OPEN and documented, not fixed (no task ever covered it):
MqttDriverForm / MqttDeviceForm do not exist, and DriverConfigModal/DeviceModal
have no raw-JSON fallback — so an operator can create an MQTT driver but cannot
author its broker endpoint or credentials. P1 is not operator-complete until
those two razor forms land. The gate authored config via SQL to get past it.
Live gate result (isolated 2-node MAIN stack, image built from this branch,
fixture at 10.100.0.35:8883, AllowUntrustedServerCertificate rather than
pinning the CA into the rig):
- typed MQTT tag editor renders and round-trips; Json shows the JSON-path
field, Raw/Scalar hide it; wildcard topic a/+/c blocked at save; data-type
list offers Float64 and no Double; qos/retainSeed absent on "(driver
default)" and qos:2 written as a number; isHistorized/historianTagname
survive a topic-only edit; SparkplugB renders the P2 placeholder and
switching back keeps Plain values; jsonPath is guidance not a gate in all
three shapes ($ seeded only for a brand-new blank tag, blank stays absent,
clearing saves fine)
- deployed, and both editor-written blobs resolved and served changing live
values through OPC UA (22.8 / 1629, StatusCode Good) — no BadNodeIdUnknown
- the bespoke #-observation browser drove a real broker session and rendered
the fixture's actual topic tree (line1/{counter,state,temperature},
noise/chatter, retained/seed); a Modbus device's picker still correctly
reports "Browsing unavailable"
Also found live and documented (not a code change): both nodes of a redundant
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 ~2s while still reporting Healthy. Unset (the default) is
correct; confirmed 0 reconnects/60s on both nodes after removing it.
Suites: offline 1134 passed / 0 failed (266 Driver.Mqtt + 730 AdminUI incl. the
3 new guards + 138 Core.Abstractions); live 7/7 against the fixture.
Docs: new docs/drivers/Mqtt.md + Mqtt-Test-Fixture.md (the per-driver + fixture
convention every other driver follows), MQTT rows in docs/drivers/README.md,
TestConnectProbes.md, root README.md, and infra/README.md §3.
CLAUDE.md drift corrected while adding the MQTT endpoints:
- the "every fixture carries project: lmxopcua" claim was false — only the
MQTT fixture does; the filter returns one stack, not the fleet
- lmxopcua-fix.ps1 is Windows-VM-only; on macOS drive the host over ssh/rsync
(and the docker-dev rig itself runs locally under OrbStack)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -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-<driver>/` 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**.
|
||||
|
||||
|
||||
@@ -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) |
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 <id> 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
+12
-2
@@ -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=<pw> 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:
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
("TwinCAT", DriverTypeNames.TwinCAT),
|
||||
("FOCAS", DriverTypeNames.FOCAS),
|
||||
("OpcUaClient", DriverTypeNames.OpcUaClient),
|
||||
("MQTT", DriverTypeNames.Mqtt),
|
||||
("Galaxy", DriverTypeNames.Galaxy),
|
||||
("Calculation", "Calculation"),
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Guards the <c>/raw</c> "New driver" picker against driver-type drift.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="RawDriverTypeDialog"/> is the <b>only</b> surface an operator can author a
|
||||
/// <c>/raw</c> driver from, and its option list is a hand-maintained array — nothing tied it to
|
||||
/// <see cref="DriverTypeNames"/>. 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 <b>unauthorable in production</b>, 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So this asserts parity by reflection rather than re-listing the drivers: a new
|
||||
/// <see cref="DriverTypeNames"/> constant that nobody surfaces in the picker fails here instead of
|
||||
/// at someone's live gate. It reads the private static <c>Types</c> field because that array is
|
||||
/// the component's own source of truth for the rendered <c><option></c> set.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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<object>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user