MQTT client on libmosquitto with its own thread (iteration 2, step 2)

MqttClient is the interface the HA layer will use; MosquittoClient
implements it with one std::thread for DNS, connect, network loop and
reconnect with backoff (D45, amends D42: mosquitto_loop_start() blocks
the caller on DNS and stops silently on TLS/protocol errors).
tst_mqtt tests it against a local mosquitto: messages, retained
messages, first connect and reconnect, last will, login.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 16:09:53 +02:00
co-authored by Claude Opus 5.5
parent e0229d6647
commit 96df60e6bd
8 changed files with 805 additions and 29 deletions
+37 -26
View File
@@ -15,7 +15,7 @@ MQTT and HA device"), phase 3 (implementation). Requirements and their decisions
| Thread | Runs |
|---|---|
| main | Qt event loop, QML, off-screen rendering of all surfaces, SteamVR event polling and texture submission, **all OpenVR calls** (I/O wrappers), HA device and entities, managers |
| MQTT | libmosquitto's network loop (`mosquitto_loop_start()`, a thread libmosquitto creates and manages, D42): DNS lookup, TCP and TLS connect block for seconds when the broker is unreachable |
| MQTT | libmosquitto's connect and network loop, in one `std::thread` of `MosquittoClient` (D42, D45): DNS lookup, TCP and TLS connect block for seconds when the broker is unreachable |
- OpenVR stays in the main thread: it isn't documented as thread-safe, and all events come
through one queue.
@@ -28,9 +28,9 @@ MQTT and HA device"), phase 3 (implementation). Requirements and their decisions
HA device ◀── connectionChanged(state, error) ── MQTT client
```
- The MQTT client object lives in the main thread; libmosquitto calls its callbacks in its
own thread, and they only emit these signals with copied values (D42, details in
"Iteration 2: MQTT and HA device").
- The MQTT client object lives in the main thread; libmosquitto calls its callbacks in the
client's own thread, and they only emit these signals with copied values (D42, D45;
details in "Iteration 2: MQTT and HA device").
- Not needed as own threads: WebEngine (own processes), Qt Multimedia and QtNetwork
(asynchronous), later D-Bus and `QProcess` for network/audio (asynchronous). Rendering stays
in the main thread as long as NFR-3 holds.
@@ -269,7 +269,7 @@ Implements the iteration 2 requirements ([PROGRESS.md](PROGRESS.md), "Next steps
### Components
```
main thread libmosquitto thread
main thread MosquittoClient's thread
Dashboard (app)
├─ ConnectionStatus (QML singleton, UI-1) ◀─ signals ─┐
├─ HaDevice ── publish/subscribe ──▶ MqttClient ◀─────┼── callbacks (connect, message, disconnect)
@@ -282,39 +282,49 @@ MQTT (D17).
| Class | File | Role |
|---|---|---|
| `MqttClient` | `src/mqtt/MqttClient.h` | **Interface** (QObject): `connectToBroker()`, `publish(topic, payload, retain)`, `subscribe(topic)`; signals `connectionChanged(state, error)`, `messageReceived(topic, payload)`. Tests use a `FakeMqttClient` |
| `MqttClient` | `src/mqtt/MqttClient.h` | **Interface** (QObject): `connectToBroker()`, `disconnectFromBroker(timeout)` (publishes the will first), `publish(topic, payload, retain)`, `subscribe(topic)`, `waitForPublished(timeout)`, `state()`; signals `connectionChanged(state, error)`, `messageReceived(topic, payload)`. Tests use a `FakeMqttClient` |
| `MosquittoClient` | `src/mqtt/MosquittoClient.*` | Implementation on libmosquitto: login, TLS, last will, reconnect with backoff |
| `HaDevice` | `src/ha/HaDevice.*` | One HA device (D18): discovery, availability, republish on reconnect and HA birth, `removeFromHa()`. Owns its entities, uses an `MqttClient*` |
| `HaEntity`, `HaSensor` | `src/ha/HaEntity.*`, `HaSensor.*` | Base class (D23): description (id, name, device class, unit, category, throttle interval), discovery component, state and availability topics, throttling (SEN-14). `HaSensor::setValue()` / `clearValue()` (unavailable, SEN-15) |
| `ConnectionStatus` | `src/ConnectionStatus.*` (app, QML module) | QML singleton for UI-1: device name, broker, state (`Disconnected`/`Connecting`/`Connected`), last error. Member of `Dashboard` |
### MQTT client (D42)
### MQTT client (D42, D45)
- **Thread:** `mosquitto_connect_async()` plus `mosquitto_loop_start()`. libmosquitto creates
and manages its own thread (a pthread, not a `QThread`, no Qt event loop) for connect,
network traffic, keep-alive and reconnect, and calls our callbacks there.
- **Thread (D45):** `MosquittoClient` starts one `std::thread` that loops: `mosquitto_connect_async()`
(blocks for the DNS lookup), then `mosquitto_loop()` until the connection ends, then waits
with backoff (1 s doubling to 60 s, reset after a connection that came up) and starts over,
until stopped. `mosquitto_threaded_set()` tells libmosquitto that we drive the loop.
- Not `mosquitto_loop_start()` (D42 as first written), checked in libmosquitto 2.0.21's
source: `mosquitto_connect_async()` resolves the host with a blocking `getaddrinfo()` in
the *calling* thread, and `mosquitto_loop_forever()` returns on "fatal" errors (TLS,
protocol), after which libmosquitto's thread ends silently and never reconnects.
- Our own loop also reports the reason of every failed attempt; libmosquitto's internal
reconnect doesn't report its failures.
- **Callbacks only emit signals** with copied values. The `MosquittoClient` object lives in the
main thread, so Qt delivers them queued (D29's rules hold).
- **Calls from the main thread** (`mosquitto_publish()`, `mosquitto_subscribe()`) are safe
while the loop runs: libmosquitto guards its state with mutexes. Needs a libmosquitto with
thread support (Debian, Arch); otherwise `mosquitto_loop_start()` returns
`MOSQ_ERR_NOT_SUPPORTED`, which is logged as an error.
- **Calls from the main thread** (`mosquitto_publish()`, `mosquitto_subscribe()`,
`mosquitto_disconnect()`) are safe while the loop runs: libmosquitto guards its state with
mutexes and wakes the loop through its socket pair.
- **Client id** `frameassistant_<device id>`: two instances with the same id knock each other
off the broker (expected, D22). MQTT 3.1.1, QoS 1 for everything.
off the broker (expected, D22). MQTT 3.1.1, QoS 1 for everything, keep-alive 30 s.
- **Nothing is buffered:** `publish()` and `subscribe()` return false unless connected.
Subscriptions are lost on reconnect (clean session); `HaDevice` subscribes after each connect.
- **Last will:** `offline`, retained, on `frameassistant/<id>/availability`. A killed process
(also `pkill` from `install.sh`) closes its TCP connection, so the broker sends the will at
once.
- **Normal exit:** publish `offline`, wait up to 2 s for its acknowledgement,
`mosquitto_disconnect()`, `mosquitto_loop_stop()` (joins the thread), `mosquitto_destroy()`.
After that no callback can arrive.
- **Reconnect (CON-4):** `mosquitto_reconnect_delay_set(1 s, 60 s, exponential)`. Nothing is
buffered.
- **Normal exit:** `disconnectFromBroker(timeout)` publishes the will payload itself, waits up to
the timeout for all acknowledgements (`waitForPublished()`), sends DISCONNECT and joins the
thread. After that no callback can arrive.
- **TLS:** `mosquitto_tls_set()` with `ca_file`; with an empty `ca_file`,
`MOSQ_OPT_TLS_USE_OS_CERTS` (libmosquitto ≥ 2.0). Client certificate if configured.
- **Logging:** every connection state change and error with its reason (`qInfo`/`qWarning`, so
also in the log file).
- To verify when implementing: whether the loop also retries when the *first*
`connect_async` fails (e.g. DNS error); if not, `MosquittoClient` retries that case itself.
(Step 5; until then a TLS setting is reported as an error.)
- **Errors** are reported in English regardless of the locale (`strerrordesc_np()`). A DNS
failure has no reason: libmosquitto overwrites `getaddrinfo()`'s code in `errno` before it
returns. The log line "connecting to host:port" names the host.
- **Logging:** every connection state change and error (`qInfo`/`qWarning`, so also in the log
file); repeated failures with the same reason are reported once. Retries after a failure
stay "not connected" (no "connecting" flicker).
- Checked: a failed *first* connect (refused, DNS error) is retried (`tst_mqtt`).
### HA device
@@ -391,7 +401,7 @@ delete).
|---|---|
| `tst_haentity` | discovery component, topics, throttling (several values within an interval → one publish with the last value), unavailable without a value |
| `tst_hadevice` (fake client) | discovery payload, `online` and states after connect, republish after reconnect and birth, will configuration, `removeFromHa()` |
| `tst_mqtt` (integration; real `mosquitto` on a free port, QSKIP without it) | connect; login right and wrong; TLS with a test CA; reconnect after a broker restart; last will after `kill -9` of a small helper process |
| `tst_mqtt` (integration; real `mosquitto` on a free port, QSKIP without it) | connect and messages; retained message for a later subscriber; nothing published while disconnected; first connect retried until the broker runs; reconnect after a broker restart; unresolvable host; last will after `kill -9` of a helper process (the test binary itself, `--will-helper`); `disconnectFromBroker()` publishes the will; login right and wrong; TLS with a test CA (step 5) |
| `tst_surfaces` | `--desktop-novr`; app command: hook called, no surface created, exit code returned |
| Manual | from the PC against the user's HA: device appears, online/offline, broker restart, two devices, `--ha-remove`, UI-1 in `--desktop`. On the Frame: the package with libmosquitto runs |
@@ -595,6 +605,7 @@ Collected 2026-10-05 for the notification design.
| D37 | 2026-10-05 | The MVP is implemented in four iterations: 1 DSH-1 + configuration, 2 MQTT/HA device/UI-1, 3 sensors, 4 notifications | Small iterations (working rules); DSH-1 first because the user wants to see it early and it doesn't depend on MQTT |
| D38 | 2026-10-05 | App-specific command-line options: the platform's base constructor takes an optional list of extra options, parses them with its own and exposes their values | The command line is parsed in the base constructor, before the derived app can contribute (`--config`); backward compatible, `--help` stays complete |
| D39 | 2026-10-05 | *Superseded by D40 (REQUIREMENTS.md): tab and WebEngine removed.* DSH-1: `QtWebEngineQuick::initialize()` in `main.cpp` (no platform change); a named, persistent WebEngine profile under `~/.local/share/frameassistant/`; tab name "HA Dashboard" | WebEngine must be initialised before the `QGuiApplication`; the HA login must survive restarts |
| D42 | 2026-10-06 | The MQTT client's blocking work runs in **libmosquitto's own thread** (`mosquitto_connect_async()` + `mosquitto_loop_start()`), incl. its reconnect with backoff; callbacks only emit Qt signals with values to the main thread. Changes how D29 is carried out, not its rules | Same effect as an own `QThread` with much less code; libmosquitto's publish/subscribe are thread-safe while its loop runs |
| D42 | 2026-10-06 | *Carried out as amended by D45.* The MQTT client's blocking work runs in **libmosquitto's own thread** (`mosquitto_connect_async()` + `mosquitto_loop_start()`), incl. its reconnect with backoff; callbacks only emit Qt signals with values to the main thread. Changes how D29 is carried out, not its rules | Same effect as an own `QThread` with much less code; libmosquitto's publish/subscribe are thread-safe while its loop runs |
| D43 | 2026-10-06 | **App commands** in the platform: the app passes command options to the base constructor; they get a `QCoreApplication` like the manifest commands, and `exec()` runs `prepare()` and then the hook `runCommand(name)` | `--ha-remove` must work without a display (`uninstall.sh` over SSH); reuses the existing manifest-command path; `--config`/`--log-file`/`--help` stay consistent |
| D44 | 2026-10-06 | TLS test certificates are committed to `tests/data/` (100-year validity) | Deterministic tests; no openssl needed in the containers |
| D45 | 2026-10-06 | *Amends D42.* `MosquittoClient` runs connect, network loop and reconnect in **one `std::thread` of its own** instead of `mosquitto_loop_start()` | libmosquitto 2.0.21's source: `mosquitto_connect_async()` resolves DNS blocking in the calling thread, and the `loop_start` thread ends silently on TLS/protocol errors; our own loop also reports each failed attempt |
+14 -3
View File
@@ -22,9 +22,9 @@ CLAUDE.md ("Working rules").
The error itself is covered off the device by `tst_config`. Checks 3 and 4 run in the next
session on the Frame (iteration 2 testing needs it too).
- **Implementation** follows the order in DESIGN.md (steps 1–7). Step 1 (libmosquitto in
CMake and the containers) done; next: step 2 (`MqttClient`, `MosquittoClient`,
integration test).
- **Implementation** follows the order in DESIGN.md (steps 1–7). Steps 1 (libmosquitto in
CMake and the containers) and 2 (`MqttClient`, `MosquittoClient`, `tst_mqtt`) done; next:
step 3 (`HaEntity`, `HaSensor`, `HaDevice`, unit tests).
## Next steps
@@ -133,6 +133,17 @@ Known limitation: the bundled-Qt package does not support WebEngine in this iter
2.0.21 (aarch64) and the copyright file. The binary doesn't need the library yet (nothing
calls it; `--as-needed`), which changes in step 2. On the host, the GUI tests can't run in
this session (no display).
- User: no need to ask for permission to continue between the steps of this phase.
- Step 2: `MqttClient` interface, `MosquittoClient`, `tst_mqtt` (12 cases against a local
mosquitto). Reading libmosquitto 2.0.21's source showed that `mosquitto_loop_start()`
doesn't fit (blocking DNS in the caller, silent thread end on TLS/protocol errors); the
user chose an own `std::thread` (D45, amends D42). Findings: a failed first connect is
retried; libmosquitto loses the DNS error code; `strerror()` is localised (German on the
dev host), so errors use `strerrordesc_np()`. Test pitfall: `QTRY_*` evaluates its
expression once more at the end, so it must not consume data (`readAll…()`). Checked:
host 3 × 12/12; `container-test.sh` 9/9 (Debian's libmosquitto 2.0.21), no Vulkan
validation messages; cross build: private Qt symbols only `QVulkan*`, at most
`GLIBC_2.34`; the app binary links libmosquitto only from step 4 on (first use).
### 2026-10-05: requirements approved