Merge branch 'feat/mqtt-sparkplug-driver'
# Conflicts: # src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs # src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor # src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs # src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs # src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs # src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj # src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
# MQTT test fixture
|
||||
|
||||
Coverage map + gap inventory for the MQTT driver's harness.
|
||||
|
||||
**TL;DR:** the unit suite (581 tests) carries the mapping, indexing, Sparkplug state-machine and
|
||||
failure-classification logic; the live suite (15 tests) proves the parts only a real broker can prove
|
||||
— TLS, real authentication, CA pinning in both directions, retained-message seeding, that a rejected
|
||||
CONNACK is actually surfaced, and the Sparkplug birth/alias/rebirth/death path against a real
|
||||
edge-node simulator.
|
||||
|
||||
## 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), plus — behind the `sparkplug`
|
||||
compose profile — **`otopcua-sparkplug-sim`**, a project-owned C# Sparkplug-B edge-node simulator
|
||||
(`tests/Drivers/….Driver.Mqtt.IntegrationTests/SparkplugSimulator/`). 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.
|
||||
|
||||
### The Sparkplug simulator
|
||||
|
||||
Group **`OtOpcUaSim`**; edge nodes **`EdgeA`** and **`EdgeB`**; `EdgeA` additionally publishes device
|
||||
**`Filler1`**. Node metrics `Temperature` (Float), `Pressure` (Double), `Count` (Int32), `Running`
|
||||
(Boolean), `Serial` (String) plus the mandatory `bdSeq` and **`Node Control/Rebirth`** — the last one
|
||||
deliberately carries a `/`, so it exercises the "a metric name is not a topic segment" rule. Device
|
||||
metrics: `Temperature` (Float), `FillCount` (Int64), `Jammed` (Boolean). Cadence ~2 s.
|
||||
|
||||
It **subscribes to `spBv1.0/OtOpcUaSim/NCMD/{node}`** and re-births on a rebirth request, so it
|
||||
exercises the real NCMD round trip rather than simulating it. Births are not retained (per spec), so
|
||||
`docker restart otopcua-sparkplug-sim` is the way to force a fresh birth on demand:
|
||||
|
||||
```bash
|
||||
ssh dohertj2@10.100.0.35 'cd /opt/otopcua-mqtt && docker compose --profile sparkplug up -d'
|
||||
ssh dohertj2@10.100.0.35 'docker restart otopcua-sparkplug-sim' # force a birth
|
||||
ssh dohertj2@10.100.0.35 'docker logs -f otopcua-sparkplug-sim'
|
||||
```
|
||||
|
||||
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. Re-gated for P2 (2026-07-25) against the Sparkplug simulator — see
|
||||
[Mqtt.md](Mqtt.md).
|
||||
|
||||
## What it does NOT cover
|
||||
|
||||
### 1. Sparkplug primary-host STATE, and write-through
|
||||
The simulator never observes a Host Application `STATE`, because the driver never publishes one
|
||||
(`ActAsPrimaryHost` is unimplemented). Nor is there any DCMD/NCMD **command** coverage: rebirth is the
|
||||
only NCMD the driver ever sends, and `IWritable` is deferred.
|
||||
|
||||
Sparkplug **seq-gap injection** is unit-proven only — the simulator always publishes a well-formed
|
||||
monotonic `seq`, so no live test drives the gap → rebirth path end-to-end.
|
||||
|
||||
### 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 |
|
||||
| Does a Sparkplug birth → alias → DATA → death cycle work? | ✅ yes (live, vs. the simulator) |
|
||||
| Does an NCMD rebirth request actually get answered? | ✅ yes (the simulator subscribes + re-births) |
|
||||
| Sparkplug seq-gap → rebirth | ❌ unit-only (the sim never skips a seq) |
|
||||
| Sparkplug primary-host STATE / DCMD writes | ❌ 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. A seq-gap injection switch on the simulator, closing the last unit-only Sparkplug leg.
|
||||
|
||||
## 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,392 @@
|
||||
# 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`.
|
||||
|
||||
**Two ingest shapes, selected by the driver's `Mode`:** `Plain` binds a tag to a concrete MQTT topic;
|
||||
`SparkplugB` binds it to a `group / edge node / device? / metric` tuple and decodes Eclipse-Tahu
|
||||
protobuf under one `spBv1.0/{GroupId}/#` subscription. Both run over the same single client.
|
||||
|
||||
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.
|
||||
|
||||
> **Status.** P1 (plain MQTT) and P2 (Sparkplug B ingest) are both shipped and live-gated against the
|
||||
> Mosquitto + Sparkplug-simulator fixture. Write-through (`IWritable` — NCMD/DCMD/plain publish) is
|
||||
> **deferred**; every MQTT node materializes read-only. See
|
||||
> [Known gaps](#known-gaps) for what is wired but not yet end-to-end observable.
|
||||
|
||||
## 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` is `Once` in Plain and **`UntilStable` in Sparkplug** (half of what a Sparkplug tag needs — its datatype — is only knowable from a birth) |
|
||||
| `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` | Nothing raises it in Plain mode. **Sparkplug raises it** when a birth for an *authored* scope carries a metric-name set different from the last one seen for that scope — both conditions are anti-storm, see [Rediscovery](#rediscovery-sparkplug) |
|
||||
|
||||
**Not implemented:** `IWritable`, `IPerCallHostResolver`, `IAlarmSource`, `IHistoryProvider`.
|
||||
Materialized nodes are `SecurityClassification.ViewOnly`, `IsAlarm: false`, `IsHistorized: false` —
|
||||
*"MQTT ingest is one-way … 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.
|
||||
|
||||
**The whole connection is authored on the DRIVER** (`DriverConfig`), via `MqttDriverForm` in the `/raw`
|
||||
driver-config modal. Unlike Modbus / S7 / OPC UA Client, MQTT does **not** use the v3
|
||||
endpoint→`DeviceConfig` split: an MQTT driver instance holds exactly one broker session, so
|
||||
`MqttDeviceForm` is informational (the same shape as `GalaxyDeviceForm`) and devices under an MQTT
|
||||
driver are pure organisational grouping in the RawPath.
|
||||
|
||||
> **Why not the device.** `DriverDeviceConfigMerger` merges a device's keys up to the top level *only
|
||||
> when the driver has exactly one device*. A device-authored broker connection therefore disappears
|
||||
> from the merged blob the moment a second device is added — a silent outage with nothing to see at
|
||||
> deploy time. Driver-level authoring is correct for 0, 1 or N devices. A pre-form deployment that put
|
||||
> the connection on a sole device still works (merge-up wins over the driver's keys) and
|
||||
> `MqttDeviceForm` flags those keys so the mismatch is visible; move them to the driver.
|
||||
|
||||
`DriverDeviceConfigMerger` still merges driver + device and injects `rawTags`.
|
||||
|
||||
```jsonc
|
||||
// DriverConfig — authored by MqttDriverForm. PascalCase keys, enums as names.
|
||||
{
|
||||
"Host": "10.100.0.35",
|
||||
"Port": 8883,
|
||||
"UseTls": true,
|
||||
"AllowUntrustedServerCertificate": false,
|
||||
"CaCertificatePath": null, // pins the chain; null = OS trust store
|
||||
"Username": "otopcua",
|
||||
"Password": "", // never commit
|
||||
"ProtocolVersion": "V500",
|
||||
"CleanSession": true,
|
||||
"KeepAliveSeconds": 30,
|
||||
"ConnectTimeoutSeconds": 15,
|
||||
"ReconnectMinBackoffSeconds": 1,
|
||||
"ReconnectMaxBackoffSeconds": 30,
|
||||
"MaxPayloadBytes": 1048576,
|
||||
"Mode": "Plain",
|
||||
"Plain": { "TopicPrefix": "", "DefaultQos": 1 }
|
||||
}
|
||||
|
||||
// DeviceConfig — empty; MQTT has no per-device endpoint.
|
||||
{}
|
||||
```
|
||||
|
||||
### `Sparkplug` sub-object (`Mode: "SparkplugB"`)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"Mode": "SparkplugB",
|
||||
"Sparkplug": {
|
||||
"GroupId": "OtOpcUaSim", // REQUIRED — the driver's whole subscription scope
|
||||
"HostId": "", // Host Application identity; receive-only (see below)
|
||||
"ActAsPrimaryHost": false, // NOT IMPLEMENTED — logs a warning when true
|
||||
"RequestRebirthOnGap": true, // a seq gap asks the edge node to re-announce (NCMD)
|
||||
"BirthObservationWindowSeconds": 15 // how long discovery collects births before calling it stable
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Key | Default | Notes |
|
||||
|---|---|---|
|
||||
| `GroupId` | `""` | **Required.** The driver subscribes exactly `spBv1.0/{GroupId}/#`. Blank ⇒ it connects, reports `Healthy`, and ingests **nothing**. It is a literal topic segment, so `/`, `+`, `#` are refused by the form |
|
||||
| `HostId` | `""` | Sparkplug Host Application identity. **Receive-only** — the driver observes `STATE` and never publishes one |
|
||||
| `ActAsPrimaryHost` | `false` | **Not implemented.** Setting it logs a startup **warning** rather than being silently inert — being a primary host is a three-part contract (retained `ONLINE`, a matching `OFFLINE` registered as the MQTT Will *at connect time*, an explicit `OFFLINE` on clean shutdown), and shipping the `ONLINE` half without the Will is strictly worse than shipping neither: a crashed node would leave a retained `ONLINE` asserting forever that a dead host is alive |
|
||||
| `RequestRebirthOnGap` | `true` | On a detected sequence-number gap, publish a rebirth-request NCMD instead of running on stale metric state |
|
||||
| `BirthObservationWindowSeconds` | `15` | Discovery's birth-collection window. Must be ≥ 1 |
|
||||
|
||||
`MqttDriverForm` authors all five in Sparkplug mode. The sub-object is **merged, never replaced** (a
|
||||
key a newer driver adds inside it survives an older AdminUI), and in `Plain` mode it is not touched at
|
||||
all — so flipping a driver to Plain to look at it and back does not lose the group id. The form never
|
||||
writes `RawTags`; the deploy artifact owns it.
|
||||
|
||||
> **This was a live-gate finding.** Through the P2 implementation `MqttDriverForm` still carried its P1
|
||||
> placeholder — switching Mode to `SparkplugB` rendered a "not available yet" notice and **no group-id
|
||||
> field**, so once Sparkplug ingest shipped there was no way to author a Sparkplug driver from the
|
||||
> AdminUI at all. Pinned by `MqttDriverFormModelTests`' Sparkplug cases.
|
||||
|
||||
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.) `MqttDriverForm`
|
||||
> defaults the field blank and raises this warning inline the moment a value is typed.
|
||||
|
||||
The form validates every knob against the driver's own `[Range]` bounds (port 1–65535, keep-alive /
|
||||
connect-timeout / backoffs ≥ 1 s, max-backoff ≥ min-backoff, `maxPayloadBytes` ≥ 1, QoS 0–2) and
|
||||
additionally **clamps** on serialize, so an operator who ignores the inline error still cannot persist
|
||||
a `connectTimeoutSeconds: 0` driver-brick.
|
||||
|
||||
## Tag Configuration
|
||||
|
||||
A tag binds in **one of two shapes**, and which one is decided by *which keys are present*, not by a
|
||||
`mode` key on the tag. `MqttTagDefinitionFactory` reads the Sparkplug tuple when any Sparkplug
|
||||
descriptor key is set, and the topic otherwise; the AdminUI's "Tag shape" selector infers the same way
|
||||
on reopen. The key names are single-sourced in `MqttTagConfigKeys` so the browse-commit mapper and the
|
||||
factory cannot drift.
|
||||
|
||||
### Plain shape
|
||||
|
||||
| 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.
|
||||
|
||||
### Sparkplug shape
|
||||
|
||||
| Key | Type | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `groupId` | string | — | **Required.** Must match the driver's `Sparkplug.GroupId` to ever receive a message |
|
||||
| `edgeNodeId` | string | — | **Required.** |
|
||||
| `deviceId` | string | *absent* | **Omit** for a metric published by the edge node itself (NBIRTH/NDATA). Absent is meaningfully different from `""` |
|
||||
| `metricName` | string | — | **Required.** The tag's stable binding key across rebirths. **May contain `/`** — `Node Control/Rebirth` and `Properties/Serial Number` are canonical Sparkplug names, and the whole name is one value, never split |
|
||||
| `dataType` | `DriverDataType` | *from the birth* | **Optional override.** Absent ⇒ take whatever type the birth certificate declared. The AdminUI's "(from birth certificate)" option removes the key entirely |
|
||||
| `isHistorized` / `historianTagname` | — | — | Server-side historization, unchanged from Plain |
|
||||
|
||||
`payloadFormat`, `jsonPath`, `qos` and `retainSeed` are **Plain-only** and meaningless here: a
|
||||
Sparkplug body is protobuf, and the subscription is one driver-wide `spBv1.0/{GroupId}/#`, not a
|
||||
per-tag filter.
|
||||
|
||||
`groupId` / `edgeNodeId` / `deviceId` are literal MQTT topic segments, so the AdminUI editor refuses
|
||||
`/`, `+` and `#` in them — a decoded incoming id can never contain one, so such a tag could never
|
||||
bind. `metricName` is deliberately **exempt**: it is not a topic segment.
|
||||
|
||||
```jsonc
|
||||
// A device metric.
|
||||
{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"FillCount"}
|
||||
|
||||
// A node-level metric — note deviceId is ABSENT, not blank.
|
||||
{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","metricName":"Temperature"}
|
||||
```
|
||||
|
||||
## Sparkplug B Ingest
|
||||
|
||||
One subscription (`spBv1.0/{GroupId}/#`) feeds a per-edge-node state machine
|
||||
(`SparkplugIngestor` + `SparkplugCodec` / `AliasTable` / `BirthCache` / `SequenceTracker`):
|
||||
|
||||
- **Births are the only self-describing message.** NBIRTH/DBIRTH name every metric and assign its
|
||||
alias; NDATA/DDATA carry an **alias and no name at all**. So the alias table is rebuilt *per birth*
|
||||
and tags bind **by name**, never by alias — aliases are per-birth and may be reused for a different
|
||||
metric after a rebirth.
|
||||
- **NBIRTH is node-scoped, DBIRTH is device-scoped**, and routing a DBIRTH through the node path
|
||||
wipes `bdSeq`. They are handled separately on purpose.
|
||||
- **Sequence tracking**: `seq` is a wrapping 0–255 counter. A detected gap raises a rebirth request
|
||||
when `RequestRebirthOnGap` is on, rather than silently continuing on stale metric state.
|
||||
- **Death → STALE.** An NDEATH whose `bdSeq` matches the live session (the bdSeq tie is what stops a
|
||||
stale death from killing a fresh session) marks every metric under that edge node `Bad` /
|
||||
`BadNoCommunication`. A DDEATH does the same for one device. The next birth restores them.
|
||||
- **Unsupported datatypes are skipped with a warning, never guessed**: `DataSet`, `Template`,
|
||||
`PropertySet`, `PropertySetList`, `Unknown`. `Int8`→`Int16` and `UInt8`→`UInt16` widen; `*Array`
|
||||
variants map to the element type with the array bit set.
|
||||
- **Ingest state is reset at every session/authoring seam** — a reconnect or a `ReinitializeAsync`
|
||||
must not carry a previous session's alias table into a new one.
|
||||
|
||||
### Rediscovery (Sparkplug)
|
||||
|
||||
`OnRediscoveryNeeded` fires only when a birth is **for an authored scope** *and* its metric-name set
|
||||
**differs** from the last one seen for that scope (order-insensitive, ordinal). Both conditions are
|
||||
anti-storm, not optimisations: rediscovery triggers an address-space rebuild, and an edge node
|
||||
re-births freely — on its own schedule, on every reconnect, and once per rebirth NCMD.
|
||||
|
||||
> ⚠️ **Nothing consumes the signal today.** No runtime component subscribes to
|
||||
> `IRediscoverable.OnRediscoveryNeeded`, and `DriverHostActor.HandleDiscoveredNodes` hard-returns —
|
||||
> discovered-node injection is dormant in v3. The served address space comes from the deploy artifact,
|
||||
> so **a DBIRTH introducing a new metric does not change the OPC UA tree**; you must redeploy. The
|
||||
> driver's decision is observable only as an Information log line
|
||||
> (`… requesting rediscovery.`). This is a v3 platform gap, not an MQTT one.
|
||||
|
||||
## 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.
|
||||
|
||||
**Sparkplug B browse serves a different tree from the same passive window**: observed NBIRTH/DBIRTH
|
||||
certificates are decoded into `Group → EdgeNode → [Device] → Metric` (a birth is the only Sparkplug
|
||||
message that names its metrics). Same node cap, same bounds, same "publishes nothing" guarantee.
|
||||
|
||||
The bespoke browser wins over the universal `DiscoveryDriverBrowser`, which would decline MQTT anyway
|
||||
(`SupportsOnlineDiscovery` is `false`).
|
||||
|
||||
### Browse-commit writes the address the factory reads
|
||||
|
||||
A committed leaf's address is a **descriptor**, not a single reference string, so
|
||||
`RawBrowseCommitMapper` reads it from the `AttributeInfo.AddressFields` the session states — Plain
|
||||
emits `topic`; Sparkplug emits `groupId` / `edgeNodeId` / `deviceId?` / `metricName`, the names
|
||||
`MqttTagConfigKeys` single-sources for both the mapper and `MqttTagDefinitionFactory`.
|
||||
|
||||
**It is never parsed out of the browse node id.** A Sparkplug node id is
|
||||
`{group}/{node}[/{device}]::{metric}` and a metric name legitimately contains `/`
|
||||
(`Node Control/Rebirth`), so splitting the id cannot recover the tuple. Which keys the session emits
|
||||
is also how the mapper learns the shape — the driver *type* reaching it is just `Mqtt`, and the mode
|
||||
lives on the driver config. A leaf whose attribute lookup returned nothing carries no address and is
|
||||
refused **at commit, in words**, rather than committed as a tag that deploys clean and then reports
|
||||
`BadNodeIdUnknown` forever.
|
||||
|
||||
### Request rebirth (Sparkplug only)
|
||||
|
||||
Births are never retained, so a healthy but quiet plant shows an **empty** picker tree until one
|
||||
lands. The `Request rebirth` affordance in the browse modal is the way to fill it on demand: it
|
||||
publishes a Sparkplug rebirth-request NCMD (`Node Control/Rebirth`) to the selected scope — the **one
|
||||
and only** browse action that publishes anything.
|
||||
|
||||
- **Scope** is the last node clicked in the tree. A device or metric resolves **up to its owning edge
|
||||
node** (Sparkplug has no device-scoped rebirth); a group fans out to every observed edge node
|
||||
beneath it and is refused **whole** past 32, publishing nothing.
|
||||
- **Two clicks, never one** — `Request rebirth…` arms a confirm panel naming the resolved scope and
|
||||
its consequence; changing the selection disarms it.
|
||||
- **Gated on `DriverOperator`**, enforced server-side in `BrowserSessionService.RequestRebirthAsync`
|
||||
(fail-closed) before the session is touched; the UI gate is defence in depth. A refusal — policy,
|
||||
over-wide group, unresolvable scope — is **shown**, not swallowed.
|
||||
- Offered only for a Sparkplug session (`IRebirthCapableBrowseSession.RebirthAvailable`); a Plain
|
||||
MQTT window publishes nothing, ever.
|
||||
|
||||
### Refresh
|
||||
|
||||
An observation window **accumulates**, but the tree renders **once**, when it is created — and
|
||||
`OpenAsync` returns as soon as the SUBSCRIBE is granted, without waiting for the birth-observation
|
||||
window. So the first render is normally empty on Plain (a topic that has not published yet is
|
||||
invisible) and *almost always* empty on Sparkplug (births are never retained). **Refresh** re-reads
|
||||
the root against the same session: nothing reconnects, nothing already observed is lost, and the tag
|
||||
selection is kept. Only the armed rebirth scope is cleared, because the node it named may no longer be
|
||||
in the rebuilt tree.
|
||||
|
||||
> **Also fixed by this gate, and not MQTT-specific:** every node label in the shared
|
||||
> `DriverBrowseTree` was an `<a href="#">`. `blazor.web.js`'s enhanced-navigation click interceptor
|
||||
> resolves a bare `#` against `<base href="/">`, so clicking a node **label** navigated the whole
|
||||
> AdminUI to `/` and destroyed the hosting modal (`@onclick:preventDefault` suppresses the browser's
|
||||
> default action, not Blazor's interceptor). The labels are now `<button type="button">`. This
|
||||
> affected **every** driver's picker since 2026-05-28; it surfaced here because choosing a rebirth
|
||||
> scope is the first flow that requires clicking a label rather than the ▶ toggle.
|
||||
|
||||
> ⚠️ **Known gap — a completely empty tree cannot arm a rebirth.** The rebirth scope is taken from the
|
||||
> last node clicked *in the tree*, so on a plant that has not birthed since the window opened there is
|
||||
> nothing to click and `Request rebirth…` stays disabled — the exact case the affordance exists for.
|
||||
> `MqttBrowseSession.ResolveRebirthTargets` already accepts a bare `{group}/{edgeNode}` for a node the
|
||||
> window has never seen (it calls that "the prime rebirth target"); what is missing is a UI path to
|
||||
> enter one, or to default the scope to the driver's own configured `GroupId`. Until then: use
|
||||
> **Refresh** after any birth, or author the tags by **Manual entry**, which is always available.
|
||||
|
||||
## 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` (**581 tests**): tag-config mapping (both shapes),
|
||||
JSONPath subset, strict-enum rejection, subscription indexing (incl. the wildcard-by-filter case),
|
||||
reconnect/rebuild branches, CONNACK classification, and the Sparkplug half — golden-payload decode,
|
||||
topic parse/format, datatype map, alias/birth-cache rebuild, sequence + bdSeq-death ties, the
|
||||
rediscovery change gate, and the browse session's "publishes nothing" assertion.
|
||||
- **Live (env-gated)** — `tests/Drivers/…Driver.Mqtt.IntegrationTests` (**15 tests**) against the
|
||||
Mosquitto broker + the C# Sparkplug edge-node simulator; skips cleanly when `MQTT_FIXTURE_ENDPOINT`
|
||||
is unset. See [Mqtt-Test-Fixture.md](Mqtt-Test-Fixture.md) and `infra/README.md` §3.
|
||||
- **AdminUI** — `MqttDriverFormModelTests` / `MqttTagConfigModelTests` cover both editors'
|
||||
round-trip, mode inference and validation. AdminUI has no bUnit, so the razor shells themselves are
|
||||
only ever proven by a live `/run` gate.
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- **Read-only.** No writes reach the broker; every node is ViewOnly. Sparkplug NCMD is used for
|
||||
**rebirth requests only** — never to command a device.
|
||||
- **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. This is also why `clientId` must stay unset (above).
|
||||
- **Unauthored traffic is ignored.** A busy broker is normal; the driver never auto-provisions a tag.
|
||||
|
||||
## Known gaps
|
||||
|
||||
| Gap | Effect | Notes |
|
||||
|---|---|---|
|
||||
| **Write-through (`IWritable`)** | Every MQTT node is read-only | Deferred to P3 — NCMD/DCMD + plain publish, optimistic-Good + self-correct on echo |
|
||||
| **Rediscovery is inert** | A DBIRTH introducing a metric does not change the OPC UA tree; redeploy to pick it up | v3 platform gap — nothing subscribes to `OnRediscoveryNeeded` and `DriverHostActor.HandleDiscoveredNodes` hard-returns. Driver-side decision is log-observable |
|
||||
| **Rebirth needs a non-empty tree** | `Request rebirth…` cannot be armed on a plant that has not birthed since the window opened | See [Refresh](#refresh). Session side already supports a bare `{group}/{edgeNode}` scope |
|
||||
| **`_canRebirth` is captured at browse-open** | A session reaped for idleness (2 min) still draws the button, and using it errors | The error is shown, not swallowed; reopen the browser |
|
||||
| **Primary-host STATE publishing** | `ActAsPrimaryHost` does nothing | Logs a startup warning rather than being silently inert — see the `Sparkplug` sub-object table |
|
||||
| **A metric whose name contains `/` cannot be browse-committed** | The commit is refused **whole**, in words (`Row N: Name must not contain '/'`) | The derived **tag Name** is a RawPath segment and may not contain `/`, while `metricName` legitimately may. This blocks the spec-mandatory `Node Control/Rebirth`. **Workaround: Manual entry** — author the tag with a `/`-free name and type the slashed `metricName` into the Sparkplug editor, which accepts it. Nothing is silently mis-bound |
|
||||
| **`DataSet` / `Template` / `PropertySet` metrics** | Skipped with a warning | v1-unsupported; never coerced into a guessed type |
|
||||
| **The tag editor writes `payloadFormat` into a Sparkplug blob** | Cosmetic only | `payloadFormat` is Plain-only; the factory routes on the Sparkplug keys and ignores it. Noise in the blob, not a mis-binding |
|
||||
@@ -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) | 🟢 **Built — live gate PASSED** (11 tasks, branch `feat/modbus-rtu-driver`, pending merge) | **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) | ✅ **COMPLETE** — P1 + P2 shipped, both live-gated (Tasks 0–26) | M–L | Mosquitto TLS+auth **+ C# Sparkplug edge-node simulator**, 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
|
||||
@@ -144,17 +144,76 @@ 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 — ✅ **COMPLETE** (P1 + P2, both live-gated)
|
||||
- **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→
|
||||
- **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, all done** (P1 plain MQTT = Tasks 0–14; P2 Sparkplug B = Tasks 15–26).
|
||||
- **Scope delivered:** 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).
|
||||
(vendored Tahu proto + `Grpc.Tools` codegen, birth/alias/seq-gap/rebirth state machine,
|
||||
death→STALE, birth-driven browse tree, scoped Request-rebirth NCMD, typed tag editor).
|
||||
**Write-through (`IWritable`) is deferred to P3** — every MQTT node materializes read-only.
|
||||
- **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 **project-owned C# Sparkplug edge-node
|
||||
simulator** (`--profile sparkplug`, group `OtOpcUaSim`, nodes `EdgeA`/`EdgeB`, `EdgeA` device
|
||||
`Filler1`) shipped with P2 and answers rebirth NCMDs.
|
||||
- **P2 status (2026-07-25):** Tasks 15–26 complete. **Offline 1528 passed / 0 failed**
|
||||
(581 driver · 809 AdminUI · 138 Core.Abstractions); **live 15/15** against the broker + simulator.
|
||||
- **P1 live gate found two AdminUI authoring gaps** (the gate's whole point — both invisible to 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`) that fails for *any* future `DriverTypeNames` entry
|
||||
missing from the picker.
|
||||
2. **FIXED —** `MqttDriverForm` / `MqttDeviceForm` did not exist, so the broker connection was
|
||||
unauthorable from the AdminUI. Both shipped after the P1 gate.
|
||||
- **P2 live gate found a third one, of the same family — FIXED:** `MqttDriverForm` still carried its
|
||||
P1 Sparkplug **placeholder**. Switching Mode to `SparkplugB` rendered a *"not available yet"* notice
|
||||
and **no Group ID field** — so once Sparkplug ingest shipped there was still **no way to author a
|
||||
Sparkplug driver from the AdminUI**, and `Sparkplug.GroupId` is the driver's entire subscription
|
||||
filter (`spBv1.0/{GroupId}/#`): blank ⇒ connected, `Healthy`, ingesting nothing. Fixed (all five
|
||||
Sparkplug keys, merge-not-replace on save, group-id segment validation) + 8 falsifiable
|
||||
`MqttDriverFormModelTests` cases. **Three gates, three instances of the same defect class: a
|
||||
hand-maintained AdminUI surface left behind by a driver-side feature.**
|
||||
- **P2 live gate — two open UI gaps, documented not fixed** (see `docs/drivers/Mqtt.md` §Known gaps):
|
||||
- **A completely empty browse tree cannot arm a rebirth.** The scope comes from a tree click, so on
|
||||
a plant that has not birthed since the window opened `Request rebirth…` stays disabled — the very
|
||||
case the affordance exists for. `MqttBrowseSession.ResolveRebirthTargets` already accepts a bare
|
||||
`{group}/{edgeNode}` ("the prime rebirth target"); only a UI path to enter one is missing.
|
||||
Mitigation shipped: a **Refresh** button on the browse tree (it re-reads the same session, which
|
||||
previously rendered exactly once at open and never again).
|
||||
- **P2 live gate also found a PRE-EXISTING, cross-driver AdminUI defect — FIXED:** every node label in
|
||||
the shared `DriverBrowseTree` was an `<a href="#">`. In a Blazor Web App, `blazor.web.js`'s
|
||||
enhanced-navigation click interceptor resolves a bare `#` against `<base href="/">`, so **clicking
|
||||
any browse-tree node label navigated the whole AdminUI to `/`**, tore down the circuit and destroyed
|
||||
the hosting modal — losing the browse session and the tag selection. `@onclick:preventDefault` does
|
||||
not help: it suppresses the browser's default action, not Blazor's own interceptor. The labels are
|
||||
now `<button type="button">`. It dates to the component's introduction (2026-05-28) and affects
|
||||
**every** driver's picker; it only surfaced now because Sparkplug's Request-rebirth is the first
|
||||
feature that requires clicking a *label* rather than the ▶ toggle (always a `<button>`, always fine).
|
||||
- **A metric name containing `/` cannot be browse-committed.** The derived raw **tag Name** is a
|
||||
RawPath segment and may not contain `/`, while `metricName` legitimately may — so the
|
||||
spec-mandatory `Node Control/Rebirth` is refused at commit (`Row N: Name must not contain '/'`).
|
||||
The refusal is loud and all-or-nothing, never a silently mis-bound tag, and **Manual entry** is
|
||||
the working path (the Sparkplug tag editor accepts a slashed metric name). Fixing it needs a
|
||||
name-sanitisation policy with a collision answer, so it was recorded rather than guessed at.
|
||||
- `_canRebirth` is captured at browse-open, so a reaped session still draws the button.
|
||||
- The tag editor injects the Plain-only `payloadFormat` key into a Sparkplug blob — cosmetic; the
|
||||
factory routes on the Sparkplug tuple and ignores it.
|
||||
- **Rediscovery is inert in v3 (platform gap, not MQTT's).** The Sparkplug driver raises
|
||||
`OnRediscoveryNeeded` on a changed birth metric-set, but **nothing subscribes to it** and
|
||||
`DriverHostActor.HandleDiscoveredNodes` hard-returns. A DBIRTH introducing a metric does **not**
|
||||
change the OPC UA tree — redeploy. Driver-side behaviour is log-observable only.
|
||||
- **Redundant-pair hazard (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`. Unset (the default) is correct. `MqttDriverForm` warns inline the moment a
|
||||
value is typed.
|
||||
- **Effort:** M–L, delivered. The MQTTnet-5/net10 + central-pinning risk is **retired** (Task 0's
|
||||
spike held through both phases); Sparkplug state-machine correctness was carried by golden-payload
|
||||
vectors + the live simulator.
|
||||
|
||||
---
|
||||
|
||||
@@ -164,7 +223,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 / Sparkplug B is
|
||||
COMPLETE** (P1 + P2, both live-gated); its only remaining work is optional — P3 write-through
|
||||
(`IWritable`: NCMD/DCMD + plain publish) and the two browse-UI gaps recorded above.
|
||||
|
||||
Update this file's Summary table and per-wave status whenever a deliverable changes state.
|
||||
|
||||
Reference in New Issue
Block a user