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 | | 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 | | 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 - OpenVR stays in the main thread: it isn't documented as thread-safe, and all events come
through one queue. 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 HA device ◀── connectionChanged(state, error) ── MQTT client
``` ```
- The MQTT client object lives in the main thread; libmosquitto calls its callbacks in its - The MQTT client object lives in the main thread; libmosquitto calls its callbacks in the
own thread, and they only emit these signals with copied values (D42, details in client's own thread, and they only emit these signals with copied values (D42, D45;
"Iteration 2: MQTT and HA device"). details in "Iteration 2: MQTT and HA device").
- Not needed as own threads: WebEngine (own processes), Qt Multimedia and QtNetwork - Not needed as own threads: WebEngine (own processes), Qt Multimedia and QtNetwork
(asynchronous), later D-Bus and `QProcess` for network/audio (asynchronous). Rendering stays (asynchronous), later D-Bus and `QProcess` for network/audio (asynchronous). Rendering stays
in the main thread as long as NFR-3 holds. 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 ### Components
``` ```
main thread libmosquitto thread main thread MosquittoClient's thread
Dashboard (app) Dashboard (app)
├─ ConnectionStatus (QML singleton, UI-1) ◀─ signals ─┐ ├─ ConnectionStatus (QML singleton, UI-1) ◀─ signals ─┐
├─ HaDevice ── publish/subscribe ──▶ MqttClient ◀─────┼── callbacks (connect, message, disconnect) ├─ HaDevice ── publish/subscribe ──▶ MqttClient ◀─────┼── callbacks (connect, message, disconnect)
@@ -282,39 +282,49 @@ MQTT (D17).
| Class | File | Role | | 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 | | `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*` | | `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) | | `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` | | `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 - **Thread (D45):** `MosquittoClient` starts one `std::thread` that loops: `mosquitto_connect_async()`
and manages its own thread (a pthread, not a `QThread`, no Qt event loop) for connect, (blocks for the DNS lookup), then `mosquitto_loop()` until the connection ends, then waits
network traffic, keep-alive and reconnect, and calls our callbacks there. 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 - **Callbacks only emit signals** with copied values. The `MosquittoClient` object lives in the
main thread, so Qt delivers them queued (D29's rules hold). main thread, so Qt delivers them queued (D29's rules hold).
- **Calls from the main thread** (`mosquitto_publish()`, `mosquitto_subscribe()`) are safe - **Calls from the main thread** (`mosquitto_publish()`, `mosquitto_subscribe()`,
while the loop runs: libmosquitto guards its state with mutexes. Needs a libmosquitto with `mosquitto_disconnect()`) are safe while the loop runs: libmosquitto guards its state with
thread support (Debian, Arch); otherwise `mosquitto_loop_start()` returns mutexes and wakes the loop through its socket pair.
`MOSQ_ERR_NOT_SUPPORTED`, which is logged as an error.
- **Client id** `frameassistant_<device id>`: two instances with the same id knock each other - **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 - **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 (also `pkill` from `install.sh`) closes its TCP connection, so the broker sends the will at
once. once.
- **Normal exit:** publish `offline`, wait up to 2 s for its acknowledgement, - **Normal exit:** `disconnectFromBroker(timeout)` publishes the will payload itself, waits up to
`mosquitto_disconnect()`, `mosquitto_loop_stop()` (joins the thread), `mosquitto_destroy()`. the timeout for all acknowledgements (`waitForPublished()`), sends DISCONNECT and joins the
After that no callback can arrive. thread. After that no callback can arrive.
- **Reconnect (CON-4):** `mosquitto_reconnect_delay_set(1 s, 60 s, exponential)`. Nothing is
buffered.
- **TLS:** `mosquitto_tls_set()` with `ca_file`; with an empty `ca_file`, - **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. `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 (Step 5; until then a TLS setting is reported as an error.)
also in the log file). - **Errors** are reported in English regardless of the locale (`strerrordesc_np()`). A DNS
- To verify when implementing: whether the loop also retries when the *first* failure has no reason: libmosquitto overwrites `getaddrinfo()`'s code in `errno` before it
`connect_async` fails (e.g. DNS error); if not, `MosquittoClient` retries that case itself. 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 ### 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_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_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 | | `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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 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). session on the Frame (iteration 2 testing needs it too).
- **Implementation** follows the order in DESIGN.md (steps 1–7). Step 1 (libmosquitto in - **Implementation** follows the order in DESIGN.md (steps 1–7). Steps 1 (libmosquitto in
CMake and the containers) done; next: step 2 (`MqttClient`, `MosquittoClient`, CMake and the containers) and 2 (`MqttClient`, `MosquittoClient`, `tst_mqtt`) done; next:
integration test). step 3 (`HaEntity`, `HaSensor`, `HaDevice`, unit tests).
## Next steps ## 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 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 calls it; `--as-needed`), which changes in step 2. On the host, the GUI tests can't run in
this session (no display). 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 ### 2026-10-05: requirements approved
+2
View File
@@ -52,6 +52,8 @@ message(STATUS "Vulkan backend: ${_vulkan_ok}")
# The app's own logic (no SteamVR, no rendering): linked by the app and its tests. # The app's own logic (no SteamVR, no rendering): linked by the app and its tests.
qt_add_library(frameassistant_lib STATIC qt_add_library(frameassistant_lib STATIC
config/Config.h config/Config.cpp config/Config.h config/Config.cpp
mqtt/MqttClient.h
mqtt/MosquittoClient.h mqtt/MosquittoClient.cpp
) )
target_include_directories(frameassistant_lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_include_directories(frameassistant_lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(frameassistant_lib PUBLIC Qt6::Core PkgConfig::MOSQUITTO) target_link_libraries(frameassistant_lib PUBLIC Qt6::Core PkgConfig::MOSQUITTO)
+248
View File
@@ -0,0 +1,248 @@
#include "mqtt/MosquittoClient.h"
#include <mosquitto.h>
#include <QDebug>
#include <algorithm>
#include <cerrno>
#include <chrono>
#include <cstring>
#include <utility>
namespace {
using Clock = std::chrono::steady_clock;
// How long the network loop may still run after a stop request, to send the
// DISCONNECT packet.
constexpr auto kStopGrace = std::chrono::seconds(2);
void initLibrary() {
static std::once_flag once;
std::call_once(once, [] { mosquitto_lib_init(); });
}
// A libmosquitto result as text; `err` is errno right after the call. In
// English regardless of the locale, like all other log and UI messages.
QString errorText(int rc, int err) {
if (rc == MOSQ_ERR_ERRNO) {
const char* text = strerrordesc_np(err);
return text ? QString::fromUtf8(text)
: QStringLiteral("System error %1").arg(err);
}
// libmosquitto loses getaddrinfo()'s code (errno is overwritten before it
// returns), so there is no reason to give.
if (rc == MOSQ_ERR_EAI)
return QStringLiteral("Cannot resolve the broker's host name");
return QString::fromUtf8(mosquitto_strerror(rc));
}
} // namespace
MosquittoClient::MosquittoClient(Settings settings, QObject* parent)
: MqttClient(parent), m_settings(std::move(settings)) {
initLibrary();
m_mosq = mosquitto_new(m_settings.clientId.toUtf8().constData(), true, this);
if (!m_mosq) {
m_initError = QStringLiteral("Cannot create the MQTT client: %1")
.arg(QString::fromLocal8Bit(strerror(errno)));
return;
}
// Our own thread drives the loop (not mosquitto_loop_start()).
mosquitto_threaded_set(m_mosq, true);
mosquitto_connect_callback_set(m_mosq, &MosquittoClient::onConnect);
mosquitto_disconnect_callback_set(m_mosq, &MosquittoClient::onDisconnect);
mosquitto_publish_callback_set(m_mosq, &MosquittoClient::onPublish);
mosquitto_message_callback_set(m_mosq, &MosquittoClient::onMessage);
const MqttConfig& broker = m_settings.broker;
int rc = MOSQ_ERR_SUCCESS;
if (!broker.username.isEmpty()) {
rc = mosquitto_username_pw_set(m_mosq, broker.username.toUtf8().constData(),
broker.password.isEmpty()
? nullptr
: broker.password.toUtf8().constData());
}
if (rc == MOSQ_ERR_SUCCESS && !m_settings.will.topic.isEmpty()) {
const Will& will = m_settings.will;
rc = mosquitto_will_set(m_mosq, will.topic.toUtf8().constData(),
int(will.payload.size()), will.payload.constData(),
1, will.retain);
}
if (rc == MOSQ_ERR_SUCCESS && broker.tls.enabled)
m_initError = QStringLiteral("TLS is not supported yet");
if (rc != MOSQ_ERR_SUCCESS) m_initError = errorText(rc, errno);
}
MosquittoClient::~MosquittoClient() {
stopThread();
if (m_mosq) mosquitto_destroy(m_mosq);
}
void MosquittoClient::connectToBroker() {
if (m_thread.joinable()) return;
if (!m_initError.isEmpty()) {
setState(State::Disconnected, m_initError);
return;
}
qInfo().noquote() << "MQTT: connecting to"
<< QStringLiteral("%1:%2")
.arg(m_settings.broker.host)
.arg(m_settings.broker.port)
<< "as" << m_settings.clientId;
m_stop = false;
setState(State::Connecting, {});
m_thread = std::thread(&MosquittoClient::run, this);
}
bool MosquittoClient::disconnectFromBroker(int timeoutMs) {
bool acknowledged = true;
if (m_state == State::Connected) {
const Will& will = m_settings.will;
if (!will.topic.isEmpty()) publish(will.topic, will.payload, will.retain);
acknowledged = waitForPublished(timeoutMs);
}
stopThread();
setState(State::Disconnected, {});
return acknowledged;
}
bool MosquittoClient::publish(const QString& topic, const QByteArray& payload,
bool retain) {
if (m_state != State::Connected) return false;
int mid = 0;
const int rc =
mosquitto_publish(m_mosq, &mid, topic.toUtf8().constData(),
int(payload.size()), payload.constData(), 1, retain);
if (rc != MOSQ_ERR_SUCCESS) {
qWarning().noquote() << "MQTT: cannot publish to" << topic << "-"
<< errorText(rc, errno);
return false;
}
std::lock_guard lock(m_mutex);
if (m_earlyAcks.erase(mid) == 0) m_unacked.insert(mid);
return true;
}
bool MosquittoClient::subscribe(const QString& topic) {
if (m_state != State::Connected) return false;
const int rc =
mosquitto_subscribe(m_mosq, nullptr, topic.toUtf8().constData(), 1);
if (rc != MOSQ_ERR_SUCCESS) {
qWarning().noquote() << "MQTT: cannot subscribe to" << topic << "-"
<< errorText(rc, errno);
return false;
}
return true;
}
bool MosquittoClient::waitForPublished(int timeoutMs) {
std::unique_lock lock(m_mutex);
return m_wake.wait_for(lock, std::chrono::milliseconds(timeoutMs),
[this] { return m_unacked.empty(); });
}
void MosquittoClient::run() {
const QByteArray host = m_settings.broker.host.toUtf8();
int delayMs = m_settings.reconnectMinMs;
while (!m_stop) {
m_lastError.clear();
// Blocks for the DNS lookup; the TCP connect itself completes in the
// loop below.
int rc =
mosquitto_connect_async(m_mosq, host.constData(),
m_settings.broker.port, m_settings.keepaliveS);
int err = errno;
if (rc == MOSQ_ERR_SUCCESS) {
// Runs until the connection ends; after a stop request only as long
// as it takes to send the DISCONNECT.
Clock::time_point stopDeadline = Clock::time_point::max();
do {
rc = mosquitto_loop(m_mosq, 1000, 1);
err = errno;
if (m_stop && stopDeadline == Clock::time_point::max())
stopDeadline = Clock::now() + kStopGrace;
} while (rc == MOSQ_ERR_SUCCESS && Clock::now() < stopDeadline);
}
if (m_stop) break;
// A connection that came up starts the backoff again.
if (m_state == State::Connected) delayMs = m_settings.reconnectMinMs;
setState(State::Disconnected,
m_lastError.isEmpty() ? errorText(rc, err) : m_lastError);
if (!sleepUnlessStopped(delayMs)) break;
delayMs = std::min(delayMs * 2, m_settings.reconnectMaxMs);
}
}
bool MosquittoClient::sleepUnlessStopped(int ms) {
std::unique_lock lock(m_mutex);
return !m_wake.wait_for(lock, std::chrono::milliseconds(ms),
[this] { return m_stop.load(); });
}
void MosquittoClient::stopThread() {
if (!m_thread.joinable()) return;
{
std::lock_guard lock(m_mutex);
m_stop = true;
}
m_wake.notify_all();
// Sends DISCONNECT and wakes the network loop.
mosquitto_disconnect(m_mosq);
m_thread.join();
}
void MosquittoClient::setState(State state, const QString& error) {
const State previous = m_state.exchange(state);
// Repeated failures with the same reason: report them once.
if (previous == state && error == m_reportedError) return;
m_reportedError = error;
switch (state) {
case State::Connected:
qInfo().noquote() << "MQTT: connected";
break;
case State::Disconnected:
if (error.isEmpty())
qInfo().noquote() << "MQTT: disconnected";
else
qWarning().noquote() << "MQTT: not connected -" << error;
break;
case State::Connecting:
break;
}
emit connectionChanged(state, error);
}
void MosquittoClient::onConnect(mosquitto*, void* self, int rc) {
auto* client = static_cast<MosquittoClient*>(self);
if (rc == 0) {
client->setState(State::Connected, {});
} else {
// The broker refused; the loop ends with MOSQ_ERR_CONN_REFUSED.
client->m_lastError = QString::fromUtf8(mosquitto_connack_string(rc));
}
}
void MosquittoClient::onDisconnect(mosquitto*, void* self, int rc) {
auto* client = static_cast<MosquittoClient*>(self);
if (rc != 0 && client->m_lastError.isEmpty())
client->m_lastError = errorText(rc, errno);
}
void MosquittoClient::onPublish(mosquitto*, void* self, int mid) {
auto* client = static_cast<MosquittoClient*>(self);
{
std::lock_guard lock(client->m_mutex);
if (client->m_unacked.erase(mid) == 0) client->m_earlyAcks.insert(mid);
}
client->m_wake.notify_all();
}
void MosquittoClient::onMessage(mosquitto*, void* self,
const mosquitto_message* message) {
auto* client = static_cast<MosquittoClient*>(self);
emit client->messageReceived(
QString::fromUtf8(message->topic),
QByteArray(static_cast<const char*>(message->payload),
message->payloadlen));
}
+80
View File
@@ -0,0 +1,80 @@
#pragma once
#include <atomic>
#include <condition_variable>
#include <mutex>
#include <set>
#include <thread>
#include "config/Config.h"
#include "mqtt/MqttClient.h"
struct mosquitto;
struct mosquitto_message;
// MqttClient on libmosquitto (docs/DESIGN.md, D42, D45).
//
// One thread of our own runs everything that can block: the DNS lookup, the
// connect, libmosquitto's network loop, and the reconnect with backoff. The
// libmosquitto callbacks run there and only emit signals with copied values.
// publish()/subscribe() from the main thread are safe: libmosquitto guards
// its state with mutexes (mosquitto_threaded_set()).
class MosquittoClient : public MqttClient {
Q_OBJECT
public:
struct Settings {
MqttConfig broker;
QString clientId;
Will will;
// Reconnect backoff: doubles from min to max after each failed attempt.
int reconnectMinMs = 1000;
int reconnectMaxMs = 60000;
int keepaliveS = 30;
};
explicit MosquittoClient(Settings settings, QObject* parent = nullptr);
~MosquittoClient() override;
void connectToBroker() override;
bool disconnectFromBroker(int timeoutMs) override;
bool publish(const QString& topic, const QByteArray& payload,
bool retain) override;
bool subscribe(const QString& topic) override;
bool waitForPublished(int timeoutMs) override;
State state() const override { return m_state; }
private:
// Thread body: connect, loop, back off, until m_stop.
void run();
// Sleeps up to `ms`; returns early (false) when stopping.
bool sleepUnlessStopped(int ms);
// Ends the thread and joins it.
void stopThread();
void setState(State state, const QString& error);
// libmosquitto callbacks (our thread).
static void onConnect(mosquitto* mosq, void* self, int rc);
static void onDisconnect(mosquitto* mosq, void* self, int rc);
static void onPublish(mosquitto* mosq, void* self, int mid);
static void onMessage(mosquitto* mosq, void* self,
const mosquitto_message* message);
const Settings m_settings;
mosquitto* m_mosq = nullptr;
QString m_initError; // set: the client can't work at all
std::thread m_thread;
std::atomic<State> m_state{State::Disconnected};
std::atomic<bool> m_stop{false};
// Why the last attempt failed (our thread only).
QString m_lastError;
// The error last reported by setState(), to report repeats only once.
QString m_reportedError;
// Guards m_stop's wakeup and the acknowledgement bookkeeping.
std::mutex m_mutex;
std::condition_variable m_wake;
// QoS 1 messages not yet acknowledged, and acknowledgements that arrived
// before publish() could record their message id.
std::set<int> m_unacked;
std::set<int> m_earlyAcks;
};
+54
View File
@@ -0,0 +1,54 @@
#pragma once
#include <QByteArray>
#include <QObject>
#include <QString>
// A connection to an MQTT broker (docs/DESIGN.md, "Iteration 2"). The HA layer
// only knows this interface; MosquittoClient implements it on libmosquitto,
// tests use a fake.
//
// Call it from the thread it lives in (the main thread). Its signals may be
// emitted from another thread: connect receivers that live in the main
// thread, so Qt queues them.
class MqttClient : public QObject {
Q_OBJECT
public:
enum class State { Disconnected, Connecting, Connected };
Q_ENUM(State)
// Published by the broker when the connection breaks, and by
// disconnectFromBroker() before it disconnects.
struct Will {
QString topic;
QByteArray payload;
bool retain = false;
};
using QObject::QObject;
// Starts connecting. Retries on its own, with backoff, until
// disconnectFromBroker(); connectionChanged() reports progress.
virtual void connectToBroker() = 0;
// Publishes the last will, waits up to `timeoutMs` for everything published
// to be acknowledged, and disconnects. Returns false on timeout.
virtual bool disconnectFromBroker(int timeoutMs) = 0;
// QoS 1. Only while connected (nothing is buffered); returns false
// otherwise.
virtual bool publish(const QString& topic, const QByteArray& payload,
bool retain) = 0;
// Subscriptions don't survive a reconnect: subscribe again after each
// connect.
virtual bool subscribe(const QString& topic) = 0;
// Waits up to `timeoutMs` until the broker has acknowledged everything
// published so far. Returns false on timeout.
virtual bool waitForPublished(int timeoutMs) = 0;
virtual State state() const = 0;
signals:
// `error` says why the connection failed or broke; empty otherwise.
void connectionChanged(MqttClient::State state, const QString& error);
void messageReceived(const QString& topic, const QByteArray& payload);
};
+5
View File
@@ -40,3 +40,8 @@ set_tests_properties(surfaces_opengl surfaces_vulkan surfaces_cli_key
qt_add_executable(tst_config tst_config.cpp) qt_add_executable(tst_config tst_config.cpp)
target_link_libraries(tst_config PRIVATE frameassistant_lib Qt6::Test) target_link_libraries(tst_config PRIVATE frameassistant_lib Qt6::Test)
add_test(NAME config COMMAND tst_config) add_test(NAME config COMMAND tst_config)
# MosquittoClient against a local mosquitto broker (D30 level 2); skips without one.
qt_add_executable(tst_mqtt tst_mqtt.cpp)
target_link_libraries(tst_mqtt PRIVATE frameassistant_lib Qt6::Test)
add_test(NAME mqtt COMMAND tst_mqtt)
+365
View File
@@ -0,0 +1,365 @@
// MosquittoClient against a real broker (docs/DESIGN.md, D30 level 2): a
// local mosquitto on a free port. Connect, messages, retained messages,
// reconnect, last will, login. Skips when mosquitto isn't installed.
//
// The binary is also its own helper process for the last-will test:
// `tst_mqtt --will-helper <port> <topic>` connects with a will and waits to be
// killed.
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <QCoreApplication>
#include <QFile>
#include <QProcess>
#include <QStandardPaths>
#include <QTemporaryDir>
#include <QTest>
#include <memory>
#include "mqtt/MosquittoClient.h"
namespace {
constexpr int kTimeoutMs = 10000;
QString findTool(const QString& name) {
QString path = QStandardPaths::findExecutable(name);
if (path.isEmpty())
path = QStandardPaths::findExecutable(name, {QStringLiteral("/usr/sbin")});
return path;
}
// A TCP port nobody listens on right now.
int freePort() {
const int fd = socket(AF_INET, SOCK_STREAM, 0);
sockaddr_in address{};
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
socklen_t length = sizeof(address);
bind(fd, reinterpret_cast<sockaddr*>(&address), length);
getsockname(fd, reinterpret_cast<sockaddr*>(&address), &length);
close(fd);
return ntohs(address.sin_port);
}
bool portOpen(int port) {
const int fd = socket(AF_INET, SOCK_STREAM, 0);
sockaddr_in address{};
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
address.sin_port = htons(port);
const bool open = ::connect(fd, reinterpret_cast<sockaddr*>(&address),
sizeof(address)) == 0;
close(fd);
return open;
}
// A mosquitto process with its own configuration.
class Broker {
public:
explicit Broker(const QString& dir) : m_dir(dir), m_port(freePort()) {}
~Broker() { stop(); }
int port() const { return m_port; }
// `extra`: further configuration lines; without them anonymous clients may
// connect.
bool start(const QString& extra = QStringLiteral("allow_anonymous true\n")) {
const QString config = m_dir + QStringLiteral("/mosquitto.conf");
QFile file(config);
if (!file.open(QIODevice::WriteOnly)) return false;
file.write(QStringLiteral("listener %1 127.0.0.1\npersistence false\n%2")
.arg(m_port)
.arg(extra)
.toUtf8());
file.close();
m_process = std::make_unique<QProcess>();
m_process->setProcessChannelMode(QProcess::ForwardedErrorChannel);
m_process->start(findTool(QStringLiteral("mosquitto")),
{QStringLiteral("-c"), config});
if (!m_process->waitForStarted()) return false;
QDeadlineTimer deadline(5000);
while (!portOpen(m_port)) {
if (deadline.hasExpired() || m_process->state() != QProcess::Running)
return false;
QThread::msleep(20);
}
return true;
}
void stop() {
if (!m_process) return;
m_process->terminate();
if (!m_process->waitForFinished(5000)) m_process->kill();
m_process->waitForFinished();
m_process.reset();
}
private:
QString m_dir;
int m_port;
std::unique_ptr<QProcess> m_process;
};
MosquittoClient::Settings settings(int port, const QString& clientId) {
MosquittoClient::Settings s;
s.broker.host = QStringLiteral("127.0.0.1");
s.broker.port = port;
s.clientId = clientId;
s.reconnectMinMs = 100;
s.reconnectMaxMs = 500;
return s;
}
// Collects a client's signals in the main thread (queued connections).
class Recorder : public QObject {
public:
explicit Recorder(MqttClient* client) {
connect(client, &MqttClient::connectionChanged, this,
[this](MqttClient::State state, const QString& error) {
states.append(state);
if (!error.isEmpty()) errors.append(error);
});
connect(client, &MqttClient::messageReceived, this,
[this](const QString& topic, const QByteArray& payload) {
messages.append({topic, payload});
});
}
QList<MqttClient::State> states;
QStringList errors;
QList<QPair<QString, QByteArray>> messages;
};
// Connects and waits for it; then subscribes to `topic` if given.
bool connectClient(MosquittoClient& client, Recorder& recorder,
const QString& topic = {}) {
client.connectToBroker();
if (!QTest::qWaitFor(
[&] {
return recorder.states.contains(MqttClient::State::Connected);
},
kTimeoutMs))
return false;
if (topic.isEmpty()) return true;
if (!client.subscribe(topic)) return false;
// No SUBACK signal: give the broker a moment to register it.
QTest::qWait(200);
return true;
}
int runWillHelper(int argc, char** argv) {
QCoreApplication app(argc, argv);
MosquittoClient::Settings s =
settings(QByteArray(argv[2]).toInt(), QStringLiteral("will-helper"));
s.will = {QString::fromUtf8(argv[3]), "offline", true};
MosquittoClient client(s);
QObject::connect(&client, &MqttClient::connectionChanged, &app,
[](MqttClient::State state) {
if (state == MqttClient::State::Connected) {
fputs("connected\n", stdout);
fflush(stdout);
}
});
client.connectToBroker();
return app.exec();
}
} // namespace
class TestMqtt : public QObject {
Q_OBJECT
private slots:
void initTestCase() {
if (findTool(QStringLiteral("mosquitto")).isEmpty())
QSKIP("mosquitto (the broker) is not installed");
QVERIFY(m_dir.isValid());
}
void connectsAndExchangesMessages() {
Broker broker(m_dir.path());
QVERIFY(broker.start());
MosquittoClient a(settings(broker.port(), QStringLiteral("a")));
MosquittoClient b(settings(broker.port(), QStringLiteral("b")));
Recorder ra(&a), rb(&b);
QVERIFY(connectClient(a, ra));
QCOMPARE(ra.states.first(), MqttClient::State::Connecting);
QCOMPARE(a.state(), MqttClient::State::Connected);
QVERIFY(connectClient(b, rb, QStringLiteral("test/#")));
QVERIFY(a.publish(QStringLiteral("test/x"), "hello", false));
QVERIFY(a.waitForPublished(kTimeoutMs));
QTRY_COMPARE_WITH_TIMEOUT(rb.messages.size(), 1, kTimeoutMs);
QCOMPARE(rb.messages.first().first, QStringLiteral("test/x"));
QCOMPARE(rb.messages.first().second, QByteArray("hello"));
QVERIFY(ra.errors.isEmpty());
}
void retainedMessageReachesLaterSubscriber() {
Broker broker(m_dir.path());
QVERIFY(broker.start());
MosquittoClient a(settings(broker.port(), QStringLiteral("a")));
Recorder ra(&a);
QVERIFY(connectClient(a, ra));
QVERIFY(a.publish(QStringLiteral("test/retained"), "kept", true));
QVERIFY(a.waitForPublished(kTimeoutMs));
MosquittoClient b(settings(broker.port(), QStringLiteral("b")));
Recorder rb(&b);
QVERIFY(connectClient(b, rb, QStringLiteral("test/retained")));
QTRY_COMPARE_WITH_TIMEOUT(rb.messages.size(), 1, kTimeoutMs);
QCOMPARE(rb.messages.first().second, QByteArray("kept"));
}
void publishFailsWhileDisconnected() {
MosquittoClient a(settings(freePort(), QStringLiteral("a")));
QVERIFY(!a.publish(QStringLiteral("test/x"), "lost", false));
QVERIFY(!a.subscribe(QStringLiteral("test/x")));
QVERIFY(a.waitForPublished(0));
}
// The broker isn't running yet: the error is reported, and the client
// connects once it runs.
void firstConnectRetriesUntilBrokerRuns() {
Broker broker(m_dir.path());
MosquittoClient a(settings(broker.port(), QStringLiteral("a")));
Recorder ra(&a);
a.connectToBroker();
QTRY_VERIFY_WITH_TIMEOUT(!ra.errors.isEmpty(), kTimeoutMs);
QCOMPARE(a.state(), MqttClient::State::Disconnected);
QVERIFY2(ra.errors.first().contains(QStringLiteral("refused")),
qPrintable(ra.errors.first()));
QVERIFY(broker.start());
QTRY_COMPARE_WITH_TIMEOUT(a.state(), MqttClient::State::Connected,
kTimeoutMs);
}
void reconnectsAfterBrokerRestart() {
Broker broker(m_dir.path());
QVERIFY(broker.start());
MosquittoClient a(settings(broker.port(), QStringLiteral("a")));
Recorder ra(&a);
QVERIFY(connectClient(a, ra));
broker.stop();
QTRY_COMPARE_WITH_TIMEOUT(a.state(), MqttClient::State::Disconnected,
kTimeoutMs);
QTRY_VERIFY_WITH_TIMEOUT(!ra.errors.isEmpty(), kTimeoutMs);
ra.states.clear();
QVERIFY(broker.start());
QTRY_VERIFY_WITH_TIMEOUT(ra.states.contains(MqttClient::State::Connected),
kTimeoutMs);
}
void unresolvableHostIsReported() {
MosquittoClient::Settings s = settings(1883, QStringLiteral("a"));
s.broker.host = QStringLiteral("no-such-host.invalid");
MosquittoClient a(s);
Recorder ra(&a);
a.connectToBroker();
QTRY_VERIFY_WITH_TIMEOUT(!ra.errors.isEmpty(), kTimeoutMs);
QVERIFY2(ra.errors.first().startsWith(QStringLiteral("Cannot resolve")),
qPrintable(ra.errors.first()));
}
// CON-3: a killed process (no DISCONNECT) → the broker publishes the will.
void lastWillWhenKilled() {
Broker broker(m_dir.path());
QVERIFY(broker.start());
MosquittoClient watcher(settings(broker.port(), QStringLiteral("w")));
Recorder rw(&watcher);
QVERIFY(connectClient(watcher, rw, QStringLiteral("test/availability")));
QProcess helper;
helper.start(
QCoreApplication::applicationFilePath(),
{QStringLiteral("--will-helper"), QString::number(broker.port()),
QStringLiteral("test/availability")});
QVERIFY(helper.waitForStarted());
// QTRY_* evaluates its expression again at the end: collect the output.
QByteArray output;
QTRY_VERIFY_WITH_TIMEOUT(
(output += helper.readAllStandardOutput()).contains("connected"),
kTimeoutMs);
QVERIFY(rw.messages.isEmpty());
helper.kill();
helper.waitForFinished();
QTRY_COMPARE_WITH_TIMEOUT(rw.messages.size(), 1, kTimeoutMs);
QCOMPARE(rw.messages.first().second, QByteArray("offline"));
}
// A normal exit publishes the will itself (retained), then disconnects.
void disconnectPublishesWill() {
Broker broker(m_dir.path());
QVERIFY(broker.start());
MosquittoClient::Settings s = settings(broker.port(), QStringLiteral("a"));
s.will = {QStringLiteral("test/availability"), "offline", true};
MosquittoClient a(s);
Recorder ra(&a);
QVERIFY(connectClient(a, ra));
QVERIFY(a.disconnectFromBroker(kTimeoutMs));
QCOMPARE(a.state(), MqttClient::State::Disconnected);
QTRY_COMPARE_WITH_TIMEOUT(ra.states.last(), MqttClient::State::Disconnected,
kTimeoutMs);
MosquittoClient b(settings(broker.port(), QStringLiteral("b")));
Recorder rb(&b);
QVERIFY(connectClient(b, rb, QStringLiteral("test/availability")));
QTRY_COMPARE_WITH_TIMEOUT(rb.messages.size(), 1, kTimeoutMs);
QCOMPARE(rb.messages.first().second, QByteArray("offline"));
}
void login_data() {
QTest::addColumn<QString>("password");
QTest::addColumn<bool>("accepted");
QTest::newRow("right") << QStringLiteral("secret") << true;
QTest::newRow("wrong") << QStringLiteral("wrong") << false;
}
void login() {
QFETCH(QString, password);
QFETCH(bool, accepted);
const QString passwd = findTool(QStringLiteral("mosquitto_passwd"));
if (passwd.isEmpty()) QSKIP("mosquitto_passwd is not installed");
const QString file = m_dir.path() + QStringLiteral("/passwd");
QFile::remove(file);
QCOMPARE(QProcess::execute(
passwd, {QStringLiteral("-c"), QStringLiteral("-b"), file,
QStringLiteral("frame"), QStringLiteral("secret")}),
0);
Broker broker(m_dir.path());
QVERIFY(broker.start(
QStringLiteral("allow_anonymous false\npassword_file %1\n").arg(file)));
MosquittoClient::Settings s = settings(broker.port(), QStringLiteral("a"));
s.broker.username = QStringLiteral("frame");
s.broker.password = password;
MosquittoClient a(s);
Recorder ra(&a);
a.connectToBroker();
if (accepted) {
QTRY_COMPARE_WITH_TIMEOUT(a.state(), MqttClient::State::Connected,
kTimeoutMs);
} else {
QTRY_VERIFY_WITH_TIMEOUT(!ra.errors.isEmpty(), kTimeoutMs);
QVERIFY2(ra.errors.first().contains(QStringLiteral("not authori")),
qPrintable(ra.errors.first()));
QVERIFY(!ra.states.contains(MqttClient::State::Connected));
}
}
private:
QTemporaryDir m_dir;
};
int main(int argc, char** argv) {
if (argc == 4 && qstrcmp(argv[1], "--will-helper") == 0)
return runWillHelper(argc, argv);
QCoreApplication app(argc, argv);
TestMqtt test;
return QTest::qExec(&test, argc, argv);
}
#include "tst_mqtt.moc"