Connect the app to MQTT and show the connection status (iteration 2, step 4)

Dashboard creates the MQTT client and the HA device with the dummy
"Uptime" sensor (D41) in overlay and both desktop modes, connects in
started() and disconnects gracefully on aboutToQuit. The new platform
mode --desktop-novr behaves like --desktop for now (D22). The main tab
shows device, broker, connection state and last error (UI-1) through
the ConnectionStatus QML singleton.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 16:18:17 +02:00
co-authored by Claude Opus 5.5
parent 466743eff9
commit 943e90c1b1
15 changed files with 335 additions and 32 deletions
+1
View File
@@ -67,6 +67,7 @@ vrdash_add_dashboard(frameassistant
OUTPUT_NAME ${VRDASH_EXECUTABLE_NAME}
QML_URI FrameAssistant
SOURCES main.cpp Dashboard.h Dashboard.cpp
QML_SOURCES ConnectionStatus.h ConnectionStatus.cpp
QML_FILES
qml/Main.qml
qml/Card.qml)
+48
View File
@@ -0,0 +1,48 @@
#include "ConnectionStatus.h"
#include <QQmlEngine>
namespace {
ConnectionStatus* g_instance = nullptr;
} // namespace
ConnectionStatus::ConnectionStatus(QObject* parent) : QObject(parent) {
Q_ASSERT(!g_instance);
g_instance = this;
}
ConnectionStatus::~ConnectionStatus() { g_instance = nullptr; }
void ConnectionStatus::setDevice(const QString& deviceName,
const QString& broker) {
m_configured = true;
m_deviceName = deviceName;
m_broker = broker;
emit changed();
}
void ConnectionStatus::setConnection(MqttClient::State state,
const QString& error) {
switch (state) {
case MqttClient::State::Disconnected:
m_state = State::Disconnected;
break;
case MqttClient::State::Connecting:
m_state = State::Connecting;
break;
case MqttClient::State::Connected:
m_state = State::Connected;
break;
}
if (!error.isEmpty()) m_lastError = error;
emit changed();
}
ConnectionStatus* ConnectionStatus::create(QQmlEngine*, QJSEngine*) {
Q_ASSERT(g_instance);
// Owned by the app, not by the QML engine.
QJSEngine::setObjectOwnership(g_instance, QJSEngine::CppOwnership);
return g_instance;
}
+59
View File
@@ -0,0 +1,59 @@
#pragma once
#include <QtQml/qqmlregistration.h>
#include <QObject>
#include <QString>
#include "mqtt/MqttClient.h"
class QJSEngine;
class QQmlEngine;
// The connection status for the main tab (UI-1): device name, broker,
// connected or not, last error. A QML singleton owned by the app
// (Dashboard).
class ConnectionStatus : public QObject {
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
// False without settings (snapshot and benchmark modes).
Q_PROPERTY(bool configured READ configured NOTIFY changed)
Q_PROPERTY(QString deviceName READ deviceName NOTIFY changed)
// host:port
Q_PROPERTY(QString broker READ broker NOTIFY changed)
Q_PROPERTY(State state READ state NOTIFY changed)
// The most recent error; kept after a reconnect. Empty: none so far.
Q_PROPERTY(QString lastError READ lastError NOTIFY changed)
public:
enum class State { Disconnected, Connecting, Connected };
Q_ENUM(State)
// Not default-constructible, so QML uses create() instead of making its
// own instance.
explicit ConnectionStatus(QObject* parent);
~ConnectionStatus() override;
void setDevice(const QString& deviceName, const QString& broker);
void setConnection(MqttClient::State state, const QString& error);
bool configured() const { return m_configured; }
QString deviceName() const { return m_deviceName; }
QString broker() const { return m_broker; }
State state() const { return m_state; }
QString lastError() const { return m_lastError; }
// QML singleton factory: the instance the app created.
static ConnectionStatus* create(QQmlEngine*, QJSEngine*);
signals:
void changed();
private:
bool m_configured = false;
QString m_deviceName;
QString m_broker;
State m_state = State::Disconnected;
QString m_lastError;
};
+62 -5
View File
@@ -1,11 +1,20 @@
#include "Dashboard.h"
#include <QCommandLineOption>
#include <QCoreApplication>
#include <QDebug>
#include "ha/HaDevice.h"
#include "ha/HaSensor.h"
#include "mqtt/MosquittoClient.h"
namespace {
const QString kConfigOption = QStringLiteral("config");
// How often the dummy sensor's value is updated.
constexpr int kUptimeIntervalMs = 10000;
// How long a normal exit waits for the broker to acknowledge "offline".
constexpr int kDisconnectTimeoutMs = 2000;
} // namespace
@@ -19,18 +28,66 @@ Dashboard::Dashboard(int& argc, char** argv)
addSurface({.qmlRootType = QStringLiteral("Main")});
}
Dashboard::~Dashboard() = default;
bool Dashboard::prepare(QString* error) {
// Snapshot and benchmark only render the UI: no settings needed.
if (mode() != DashboardMode::Overlay && mode() != DashboardMode::Desktop)
if (mode() != DashboardMode::Overlay && mode() != DashboardMode::Desktop &&
mode() != DashboardMode::DesktopNoVr)
return true;
const QString path = isOptionSet(kConfigOption) ? optionValue(kConfigOption)
: Config::defaultPath();
QStringList warnings;
std::optional<Config> config = Config::load(path, error, &warnings);
m_config = Config::load(path, error, &warnings);
for (const QString& warning : warnings) qWarning().noquote() << warning;
if (!config) return false;
m_config = *config;
if (!m_config) return false;
qInfo().noquote() << "Settings from" << path << "- device"
<< m_config.deviceName << "(" + m_config.deviceId + ")";
<< m_config->deviceName << "(" + m_config->deviceId + ")";
return true;
}
void Dashboard::loading() {
if (!m_config) return;
const Config& config = *m_config;
MosquittoClient::Settings settings;
settings.broker = config.mqtt;
settings.clientId = QStringLiteral("frameassistant_") + config.deviceId;
settings.will = HaDevice::lastWill(config.deviceId);
m_mqtt = std::make_unique<MosquittoClient>(settings);
m_device = std::make_unique<HaDevice>(
HaDevice::Info{.name = config.deviceName,
.id = config.deviceId,
.discoveryPrefix = config.discoveryPrefix,
.softwareVersion = QCoreApplication::applicationVersion()},
m_mqtt.get());
m_uptime = m_device->addSensor({.id = QStringLiteral("uptime"),
.name = QStringLiteral("Uptime"),
.deviceClass = QStringLiteral("duration"),
.unit = QStringLiteral("s"),
.category = QStringLiteral("diagnostic")});
m_status.setDevice(
config.deviceName,
QStringLiteral("%1:%2").arg(config.mqtt.host).arg(config.mqtt.port));
connect(m_mqtt.get(), &MqttClient::connectionChanged, &m_status,
&ConnectionStatus::setConnection);
}
void Dashboard::started() {
if (!m_mqtt) return;
m_sinceStart.start();
m_uptime->setValue(0);
connect(&m_uptimeTimer, &QTimer::timeout, this,
[this] { m_uptime->setValue(m_sinceStart.elapsed() / 1000); });
m_uptimeTimer.start(kUptimeIntervalMs);
// A normal exit tells HA itself that the device goes offline; a crash or
// kill leaves that to the broker (last will).
connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit, this,
[this] {
m_uptimeTimer.stop();
m_mqtt->disconnectFromBroker(kDisconnectTimeoutMs);
});
m_mqtt->connectToBroker();
}
+25 -2
View File
@@ -1,8 +1,17 @@
#pragma once
#include <QElapsedTimer>
#include <QTimer>
#include <memory>
#include "ConnectionStatus.h"
#include "app/DashboardApplication.h"
#include "config/Config.h"
class HaDevice;
class HaSensor;
class MosquittoClient;
// The dashboard app. Its UI is the QML module set up in src/CMakeLists.txt
// (QML_URI FrameAssistant, files in src/qml/).
//
@@ -12,11 +21,25 @@ class Dashboard : public DashboardApplication {
Q_OBJECT
public:
Dashboard(int& argc, char** argv);
~Dashboard() override;
protected:
// Loads the settings (overlay and desktop mode; docs/DESIGN.md, D22).
// Loads the settings (overlay and desktop modes; docs/DESIGN.md, D22).
bool prepare(QString* error) override;
// With settings: creates the MQTT client and the HA device.
void loading() override;
// Connects to the broker and starts the sensors.
void started() override;
private:
Config m_config;
std::optional<Config> m_config; // none in snapshot/benchmark mode
// The device uses the client: declared after it, so destroyed before it.
std::unique_ptr<MosquittoClient> m_mqtt;
std::unique_ptr<HaDevice> m_device;
// Dummy sensor of iteration 2 (D41): seconds since start.
HaSensor* m_uptime = nullptr;
QTimer m_uptimeTimer;
QElapsedTimer m_sinceStart;
// QML singleton; a member, so it outlives all QML.
ConnectionStatus m_status{nullptr};
};
+14 -4
View File
@@ -138,6 +138,8 @@ const char* dashboardModeName(DashboardMode mode) {
return "SteamVR overlay";
case DashboardMode::Desktop:
return "desktop preview";
case DashboardMode::DesktopNoVr:
return "desktop preview without SteamVR";
case DashboardMode::Snapshot:
return "snapshot";
case DashboardMode::Benchmark:
@@ -199,6 +201,10 @@ DashboardApplication::DashboardApplication(
const QCommandLineOption desktopOption(
QStringLiteral("desktop"),
QStringLiteral("Show the UI in a desktop window (no SteamVR)."));
const QCommandLineOption desktopNoVrOption(
QStringLiteral("desktop-novr"),
QStringLiteral("Show the UI in a desktop window; never connects to "
"SteamVR."));
const QCommandLineOption snapshotOption(
QStringLiteral("snapshot"),
QStringLiteral("Render one frame offscreen to <png> and exit."),
@@ -247,9 +253,10 @@ DashboardApplication::DashboardApplication(
QStringLiteral(
"Also append log output to <path> (relative to the bundle root)."),
QStringLiteral("path"));
parser.addOptions({desktopOption, snapshotOption, benchmarkOption, apiOption,
keyOption, nameOption, fpsOption, installOption,
uninstallOption, manifestOption, launchOption, logOption});
parser.addOptions({desktopOption, desktopNoVrOption, snapshotOption,
benchmarkOption, apiOption, keyOption, nameOption,
fpsOption, installOption, uninstallOption, manifestOption,
launchOption, logOption});
for (const QCommandLineOption& option : appOptions) {
if (!parser.addOption(option))
o.error = QStringLiteral("App option --%1 clashes with another option")
@@ -291,7 +298,9 @@ DashboardApplication::DashboardApplication(
o.appKey = parser.value(keyOption);
o.appName = parser.value(nameOption);
if (parser.isSet(desktopOption)) {
if (parser.isSet(desktopNoVrOption)) {
o.mode = DashboardMode::DesktopNoVr;
} else if (parser.isSet(desktopOption)) {
o.mode = DashboardMode::Desktop;
} else if (parser.isSet(benchmarkOption)) {
o.mode = DashboardMode::Benchmark;
@@ -411,6 +420,7 @@ int DashboardApplication::exec() {
int result = 1;
switch (o.mode) {
case DashboardMode::Desktop:
case DashboardMode::DesktopNoVr:
result = runDesktopPreview();
break;
case DashboardMode::Snapshot:
+3 -1
View File
@@ -18,7 +18,9 @@ class QQmlEngine;
class RenderBackend;
class VrRuntime;
enum class DashboardMode { Overlay, Desktop, Snapshot, Benchmark };
// Desktop and DesktopNoVr both show desktop windows; DesktopNoVr promises
// never to connect to SteamVR (for apps that use SteamVR data otherwise).
enum class DashboardMode { Overlay, Desktop, DesktopNoVr, Snapshot, Benchmark };
const char* dashboardModeName(DashboardMode mode);
// Base class of a dashboard app. It owns everything app- and surface-related:
+9 -2
View File
@@ -17,6 +17,13 @@ using Clock = std::chrono::steady_clock;
// DISCONNECT packet.
constexpr auto kStopGrace = std::chrono::seconds(2);
// libmosquitto's texts end with a period, the others don't.
QString withoutPeriod(const char* text) {
QString result = QString::fromUtf8(text);
if (result.endsWith(QLatin1Char('.'))) result.chop(1);
return result;
}
void initLibrary() {
static std::once_flag once;
std::call_once(once, [] { mosquitto_lib_init(); });
@@ -34,7 +41,7 @@ QString errorText(int rc, int err) {
// 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));
return withoutPeriod(mosquitto_strerror(rc));
}
} // namespace
@@ -219,7 +226,7 @@ void MosquittoClient::onConnect(mosquitto*, void* self, int rc) {
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));
client->m_lastError = withoutPeriod(mosquitto_connack_string(rc));
}
}
+50 -2
View File
@@ -42,16 +42,64 @@ Pane {
}
}
// Placeholder until the real UI exists.
// UI-1: connection status.
Card {
title: qsTr("Home Assistant")
Layout.fillWidth: true
Layout.fillHeight: true
Label {
text: qsTr("Not connected yet.")
visible: !ConnectionStatus.configured
text: qsTr("No settings loaded.")
opacity: 0.7
}
GridLayout {
visible: ConnectionStatus.configured
columns: 2
columnSpacing: 32
rowSpacing: 12
Label { text: qsTr("Device"); opacity: 0.7 }
Label { text: ConnectionStatus.deviceName }
Label { text: qsTr("Broker"); opacity: 0.7 }
Label { text: ConnectionStatus.broker }
Label { text: qsTr("Status"); opacity: 0.7 }
RowLayout {
spacing: 12
Rectangle {
implicitWidth: 18
implicitHeight: 18
radius: 9
color: ConnectionStatus.state === ConnectionStatus.Connected
? Material.color(Material.Green)
: ConnectionStatus.state === ConnectionStatus.Connecting
? Material.color(Material.Amber)
: Material.color(Material.Red)
}
Label {
text: ConnectionStatus.state === ConnectionStatus.Connected
? qsTr("Connected")
: ConnectionStatus.state === ConnectionStatus.Connecting
? qsTr("Connecting…")
: qsTr("Not connected")
}
}
Label {
visible: ConnectionStatus.lastError !== ""
text: qsTr("Last error")
opacity: 0.7
}
Label {
visible: ConnectionStatus.lastError !== ""
text: ConnectionStatus.lastError
wrapMode: Text.Wrap
Layout.fillWidth: true
}
}
Item { Layout.fillHeight: true }
}