From 943e90c1b11269c2e16af481349c5a03305d469d Mon Sep 17 00:00:00 2001 From: Matzman Date: Tue, 6 Oct 2026 16:18:17 +0200 Subject: [PATCH] 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 --- CLAUDE.md | 19 +++++++-- README.md | 15 ++++--- docs/ARCHITECTURE.md | 3 +- docs/PROGRESS.md | 21 ++++++++-- src/CMakeLists.txt | 1 + src/ConnectionStatus.cpp | 48 +++++++++++++++++++++++ src/ConnectionStatus.h | 59 ++++++++++++++++++++++++++++ src/Dashboard.cpp | 67 +++++++++++++++++++++++++++++--- src/Dashboard.h | 27 ++++++++++++- src/app/DashboardApplication.cpp | 18 +++++++-- src/app/DashboardApplication.h | 4 +- src/mqtt/MosquittoClient.cpp | 11 +++++- src/qml/Main.qml | 52 ++++++++++++++++++++++++- tests/CMakeLists.txt | 3 ++ tests/tst_surfaces.cpp | 19 +++++++-- 15 files changed, 335 insertions(+), 32 deletions(-) create mode 100644 src/ConnectionStatus.cpp create mode 100644 src/ConnectionStatus.h diff --git a/CLAUDE.md b/CLAUDE.md index 94ac2e3..df8e7d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,6 +62,7 @@ cmake --preset native-debug && cmake --build --preset native-debug # system Qt ctest --preset native-debug # all tests build/native-debug/bin/tst_inputbridge clickUsesGlCoordinates # one QtTest function build/native-debug/bin/frame-assistant --desktop # UI preview in a window, no SteamVR +build/native-debug/bin/frame-assistant --desktop-novr # same, and never SteamVR (from iteration 3 on the difference) build/native-debug/bin/frame-assistant --snapshot x.png # one frame via the real offscreen path scripts/container-build.sh cross # x86_64 → aarch64 against the Frame's system Qt (default) scripts/container-build.sh native # same for the host arch (use on the Frame) @@ -75,7 +76,9 @@ build/native-debug/bin/frame-assistant --graphics-api vulkan --benchmark 600 # - Use `--snapshot` and then read the PNGs to check UI or rendering changes without a headset. With several surfaces it writes `-.png` per surface (`-0` for an empty id). Snapshot and benchmark don't load the settings. -- Overlay and desktop mode need a settings file; pass a test one with `--config `. +- Overlay and both desktop modes need a settings file and connect to its MQTT broker; pass a + test one with `--config ` (e.g. `mosquitto -p 18883` and `"mqtt": {"host": + "127.0.0.1", "port": 18883}`). - `tst_render` runs once per backend (`VRDASH_TEST_API`, separate CTest entries, because the Qt graphics API is process-global). It QSKIPs without a GPU context or when Vulkan isn't compiled in. @@ -108,7 +111,8 @@ into Qt events. - `src/app/DashboardApplication` is the **base class of every app** (in `vrdashboard_core`): CLI, logging, SteamVR connection, backend selection, surfaces, controller navigation, and the - modes (overlay, `--desktop`, `--snapshot`, `--benchmark`, manifest install). + modes (overlay, `--desktop`, `--desktop-novr`, `--snapshot`, `--benchmark`, manifest + install). - **Inheritance for the app, components for the surfaces** (the user's decision, after trying composition): the app derives from `DashboardApplication`. In its constructor it configures (`addSurface`, optional `setAppKey`/`setDisplayName`) and passes its own @@ -202,7 +206,16 @@ into Qt events. they must be part of the QML module. The generated type registration includes their headers by file name only, so their directory is on the app's include path. - **Settings** (`src/config/Config`) are loaded once in `Dashboard::prepare()` (overlay and - desktop mode) and passed on as an immutable value (DESIGN.md, D19). + both desktop modes) and passed on as an immutable value (DESIGN.md, D19). +- **MQTT and HA** (DESIGN.md, "Iteration 2"): `Dashboard::loading()` creates `MosquittoClient` + (`src/mqtt/`) and `HaDevice` (`src/ha/`), `started()` connects; `aboutToQuit` disconnects + gracefully (publishes "offline"). The HA layer only knows the `MqttClient` interface; + tests use `tests/FakeMqttClient.h`. `MosquittoClient`'s callbacks run in its own + `std::thread` and may only emit signals with copied values (D45). +- **Error texts are English** regardless of the locale: `strerror()` is translated (German on + the dev host), so use `strerrordesc_np()`. +- **`QTRY_*` evaluates its expression once more at the end:** never consume data in it + (e.g. `readAllStandardOutput()`); accumulate into a variable instead. - **No Qt WebEngine** (REQUIREMENTS.md, D40): the Frame's system WebEngine can't be loaded, and bundling our own failed (STEAM-FRAME.md). Don't link it: the whole app would fail to start on the Frame. - **Check Frame libraries by loading, not by file:** before relying on a system library, check diff --git a/README.md b/README.md index de738ea..42c938a 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ the headset to Home Assistant in both directions: - **Dashboard:** a SteamVR dashboard tab to view and control Home Assistant from inside VR. > **Status:** early development. The overlay platform works on the Frame. The Home Assistant -> parts are being built: so far the settings file. See [docs/REQUIREMENTS.md](docs/REQUIREMENTS.md), +> parts are being built: so far the settings file and the MQTT connection (the device appears +> in Home Assistant with a test sensor, "Uptime"; the tab shows the connection status). See [docs/REQUIREMENTS.md](docs/REQUIREMENTS.md), > [docs/DESIGN.md](docs/DESIGN.md) and [docs/PROGRESS.md](docs/PROGRESS.md). ### Platform @@ -46,12 +47,14 @@ cmake --preset native-debug && cmake --build --preset native-debug ctest --preset native-debug build/native-debug/bin/frame-assistant --desktop # UI in windows, no SteamVR +build/native-debug/bin/frame-assistant --desktop-novr # same; never uses SteamVR build/native-debug/bin/frame-assistant --snapshot out.png # one frame through the VR path build/native-debug/bin/frame-assistant # as dashboard tab (SteamVR running) build/native-debug/bin/frame-assistant --install-manifest # register + auto-start with SteamVR ``` -Overlay and desktop mode need a settings file (below); `--snapshot` and `--benchmark` don't. +Overlay and both desktop modes need a settings file (below) and connect to the MQTT broker; +`--snapshot` and `--benchmark` don't. On the Steam Frame: @@ -122,7 +125,8 @@ How the pieces fit together: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). | Option | Meaning | |--------|---------| | *(none)* | Run as SteamVR dashboard overlay (SteamVR must be running) | -| `--desktop` | Show the UI in desktop windows, one per surface; no SteamVR | +| `--desktop` | Show the UI in desktop windows, one per surface; no SteamVR (from iteration 3: uses SteamVR if it runs) | +| `--desktop-novr` | Like `--desktop`, but never connects to SteamVR (e.g. on a PC, with its own `--config`) | | `--config ` | Settings file (default `~/.config/frameassistant/config.json`) | | `--snapshot ` | Render one frame per surface off-screen and save it (`-.png` with several surfaces) | | `--benchmark ` | Render frames back to back with a full GPU wait each and print frame times | @@ -137,7 +141,8 @@ How the pieces fit together: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). ## Testing ```sh -ctest --preset native-debug # unit + render tests (GPU tests skip without a GPU context) +ctest --preset native-debug # unit, MQTT and render tests (GPU tests skip without a GPU + # context, MQTT tests without the mosquitto broker) scripts/container-test.sh # all tests in Debian incl. Vulkan with validation layers VK_DRIVER=lvp scripts/container-test.sh # same on the lavapipe software driver ``` @@ -147,7 +152,7 @@ VK_DRIVER=lvp scripts/container-test.sh # same on the lavapipe software driver | Path | Content | |------|---------| | `src/main.cpp`, `src/Dashboard.*`, `src/qml/` | **The app** | -| `src/config/` | Settings file (`frameassistant_lib`) | +| `src/config/`, `src/mqtt/`, `src/ha/` | Settings file, MQTT client, HA device and entities (`frameassistant_lib`) | | `src/app/` | `DashboardApplication` (app base class), `Surface` (one view) | | `src/render/` | Off-screen Qt Quick renderer; Vulkan and OpenGL backends | | `src/vr/` | SteamVR connection, overlays (dashboard tab) | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 12a8ca7..355a367 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -3,7 +3,8 @@ A dashboard is **one process**. It renders QML scenes off-screen into textures and hands them to SteamVR as overlays. Input flows the other way: SteamVR reports laser, scrolling, keyboard and controller actions, and the app turns them into ordinary Qt events. The QML -never knows it is in VR, which is why it also runs in a plain desktop window (`--desktop`). +never knows it is in VR, which is why it also runs in a plain desktop window (`--desktop`, +`--desktop-novr`). ## Structure diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index d2fab4a..e4c29b9 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -23,9 +23,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). Steps 1 (libmosquitto in - CMake and the containers), 2 (`MqttClient`, `MosquittoClient`, `tst_mqtt`) and 3 - (`HaEntity`, `HaSensor`, `HaDevice`, unit tests) done; next: step 4 (app wiring, - `--desktop-novr`, UI-1). + CMake and the containers), 2 (`MqttClient`, `MosquittoClient`, `tst_mqtt`) 3 + (`HaEntity`, `HaSensor`, `HaDevice`, unit tests) and 4 (app wiring, `--desktop-novr`, + UI-1) done; next: step 5 (TLS). ## Next steps @@ -149,6 +149,21 @@ Known limitation: the bundled-Qt package does not support WebEngine in this iter `HaSensor`, `HaDevice` (discovery payload, publish all after connect and after HA's birth message with random delay, last will, `removeFromHa()`), `FakeMqttClient` for tests. `tst_haentity` 11/11, `tst_hadevice` 14/14 on the host. +- Step 4: platform mode `DesktopNoVr` (`--desktop-novr`, behaves like `--desktop` for now; + `tst_surfaces` `surfaces_desktop_novr`); `Dashboard` creates client and device in + `loading()`, connects in `started()`, feeds the dummy sensor every 10 s and disconnects + gracefully on `aboutToQuit`; `ConnectionStatus` QML singleton and the status card in + `Main.qml` (UI-1). Checked: + - end to end against a local mosquitto (`--desktop-novr`, watched with `mosquitto_sub`): + discovery, `online`, state and availability in the designed order; broker restart → + "connection lost", "connection refused", reconnect and full republish; `kill -9` → will + `offline`. (mosquitto also sends the will when it shuts down itself.) + - screenshots under Xvfb: unconfigured (snapshot), connected, not connected with last + error. + - `container-test.sh` 12/12, no Vulkan validation messages; cross build links + `libmosquitto.so.1`, at most `GLIBC_2.34`, private Qt symbols only `QVulkan*`. + - Not checked yet: the graceful exit through `aboutToQuit` in the app (only in + `tst_mqtt`); it needs a real window close or "Quit app" in SteamVR (manual test). ### 2026-10-05: requirements approved diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 30a4e5b..8fe812b 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -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) diff --git a/src/ConnectionStatus.cpp b/src/ConnectionStatus.cpp new file mode 100644 index 0000000..c0885aa --- /dev/null +++ b/src/ConnectionStatus.cpp @@ -0,0 +1,48 @@ +#include "ConnectionStatus.h" + +#include + +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; +} diff --git a/src/ConnectionStatus.h b/src/ConnectionStatus.h new file mode 100644 index 0000000..9047663 --- /dev/null +++ b/src/ConnectionStatus.h @@ -0,0 +1,59 @@ +#pragma once + +#include + +#include +#include + +#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; +}; diff --git a/src/Dashboard.cpp b/src/Dashboard.cpp index 81ade1c..3a85190 100644 --- a/src/Dashboard.cpp +++ b/src/Dashboard.cpp @@ -1,11 +1,20 @@ #include "Dashboard.h" #include +#include #include +#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::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(settings); + + m_device = std::make_unique( + 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(); +} diff --git a/src/Dashboard.h b/src/Dashboard.h index 10d6eed..72555bc 100644 --- a/src/Dashboard.h +++ b/src/Dashboard.h @@ -1,8 +1,17 @@ #pragma once +#include +#include +#include + +#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 m_config; // none in snapshot/benchmark mode + // The device uses the client: declared after it, so destroyed before it. + std::unique_ptr m_mqtt; + std::unique_ptr 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}; }; diff --git a/src/app/DashboardApplication.cpp b/src/app/DashboardApplication.cpp index 2efc05f..d1b0c8e 100644 --- a/src/app/DashboardApplication.cpp +++ b/src/app/DashboardApplication.cpp @@ -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 and exit."), @@ -247,9 +253,10 @@ DashboardApplication::DashboardApplication( QStringLiteral( "Also append log output to (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: diff --git a/src/app/DashboardApplication.h b/src/app/DashboardApplication.h index 72508dd..3fa2900 100644 --- a/src/app/DashboardApplication.h +++ b/src/app/DashboardApplication.h @@ -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: diff --git a/src/mqtt/MosquittoClient.cpp b/src/mqtt/MosquittoClient.cpp index fb5298e..ad534e5 100644 --- a/src/mqtt/MosquittoClient.cpp +++ b/src/mqtt/MosquittoClient.cpp @@ -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)); } } diff --git a/src/qml/Main.qml b/src/qml/Main.qml index f965540..394d02a 100644 --- a/src/qml/Main.qml +++ b/src/qml/Main.qml @@ -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 } } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8837bbd..165f5e3 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -33,6 +33,9 @@ add_test(NAME surfaces_cli_key COMMAND tst_surfaces --graphics-api opengl) set_tests_properties(surfaces_cli_key PROPERTIES ENVIRONMENT "VRDASH_TEST_CLI_KEY=1") add_test(NAME surfaces_prepare_fail COMMAND tst_surfaces --graphics-api opengl) set_tests_properties(surfaces_prepare_fail PROPERTIES ENVIRONMENT "VRDASH_TEST_PREPARE_FAIL=1") +add_test(NAME surfaces_desktop_novr COMMAND tst_surfaces) +set_tests_properties(surfaces_desktop_novr PROPERTIES + ENVIRONMENT "VRDASH_TEST_DESKTOP_NOVR=1;QT_QPA_PLATFORM=offscreen") set_tests_properties(surfaces_opengl surfaces_vulkan surfaces_cli_key PROPERTIES SKIP_RETURN_CODE 77) diff --git a/tests/tst_surfaces.cpp b/tests/tst_surfaces.cpp index 8c1ab1b..0fc2f09 100644 --- a/tests/tst_surfaces.cpp +++ b/tests/tst_surfaces.cpp @@ -4,6 +4,8 @@ // scene. No SteamVR involved. // VRDASH_TEST_PREPARE_FAIL=1: prepare() fails, so exec() must return 1 // without calling the other hooks. +// VRDASH_TEST_DESKTOP_NOVR=1: --desktop-novr instead of --snapshot; prepare() +// checks the mode and then fails (no window, no event loop). // Exit codes: 0 pass, 1 fail, 77 skipped (no GPU context). #include @@ -46,6 +48,7 @@ class TwoSurfaceApp : public DashboardApplication { } QString expectedKey; // --app-key overrides setAppKey() + DashboardMode expectedMode = DashboardMode::Snapshot; bool failPrepare = false; int prepareCalls = 0; int loadingCalls = 0; @@ -58,7 +61,7 @@ class TwoSurfaceApp : public DashboardApplication { ok &= check(loadingCalls == 0 && startedCalls == 0, "prepare() first"); ok &= check(engine() == nullptr && surfaces().isEmpty(), "prepare() before engine and surfaces"); - ok &= check(mode() == DashboardMode::Snapshot, "mode in prepare()"); + ok &= check(mode() == expectedMode, "mode in prepare()"); ok &= check(appKey() == expectedKey, "app key in prepare()"); ok &= check( isOptionSet(QStringLiteral("test-value")) && @@ -108,10 +111,17 @@ int main(int argc, char* argv[]) { QDir(dir.path()).filePath(QStringLiteral("shot.png")).toStdString(); // VRDASH_TEST_CLI_KEY=1: also pass --app-key (must win over setAppKey()). const bool cliKey = qEnvironmentVariableIntValue("VRDASH_TEST_CLI_KEY"); + const bool desktopNoVr = + qEnvironmentVariableIntValue("VRDASH_TEST_DESKTOP_NOVR"); const bool failPrepare = - qEnvironmentVariableIntValue("VRDASH_TEST_PREPARE_FAIL"); - std::vector args = {argv[0], "--snapshot", snapshot, - "--test-value", "42"}; + qEnvironmentVariableIntValue("VRDASH_TEST_PREPARE_FAIL") || desktopNoVr; + std::vector args = {argv[0], "--test-value", "42"}; + if (desktopNoVr) { + args.emplace_back("--desktop-novr"); + } else { + args.emplace_back("--snapshot"); + args.emplace_back(snapshot); + } if (cliKey) { args.emplace_back("--app-key"); args.emplace_back("cli.key"); @@ -125,6 +135,7 @@ int main(int argc, char* argv[]) { app.expectedKey = cliKey ? QStringLiteral("cli.key") : QStringLiteral("config.key"); app.failPrepare = failPrepare; + if (desktopNoVr) app.expectedMode = DashboardMode::DesktopNoVr; const int result = app.exec(); if (failPrepare) { bool ok = app.ok;