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:
+37
-26
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user