Compare commits

..
10 Commits
Author SHA1 Message Date
matzmanandClaude Opus 5.5 6e1bed79f0 Wrap up the session: iteration 2 implemented, phase 4 next
PROGRESS.md: current state, next session, open suggestions, log entry.
DESIGN.md and CLAUDE.md status lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:50:06 +02:00
matzmanandClaude Opus 5.5 6cb30913ca Check shipped libraries against the Frame; keep OpenSSL out of the bundle (iteration 2, step 7)
package-release.sh refuses a package whose ELF files need newer glibc or
OpenSSL symbol versions than the Frame has (D35); only the version needs
section counts. The Qt bundler resolved libmosquitto's dependencies and
shipped Debian's OpenSSL 3.5; libssl/libcrypto are now excluded, since
SteamOS provides OpenSSL 3. Phase 3 of iteration 2 is done; PROGRESS.md
has the manual checklist for phase 4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:37:12 +02:00
matzmanandClaude Opus 5.5 0bdcaddcd5 App commands and --ha-remove (iteration 2, step 6)
The platform accepts app commands (D43): flags that get a
QCoreApplication like the manifest commands and run prepare() and then
the new hook runCommand(). The app's --ha-remove clears the device's
retained topics, so HA deletes the device; uninstall.sh runs it after
stopping the app and before unregistering from SteamVR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:32:51 +02:00
matzmanandClaude Opus 5.5 148ae663cf TLS for the MQTT connection (iteration 2, step 5)
ca_file or the system's CAs (MOSQ_OPT_TLS_USE_OS_CERTS), client
certificate, broker certificate and host name verified, settings errors
reported before connecting. The client thread blocks SIGPIPE: with TLS,
a write to a connection the broker closed killed the process. The
reason of a failed handshake (libmosquitto's log) is appended to the
error. tst_mqtt covers TLS with test certificates in tests/data (D44).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:29:32 +02:00
matzmanandClaude Opus 5.5 943e90c1b1 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>
2026-10-06 16:18:17 +02:00
matzmanandClaude Opus 5.5 466743eff9 HA entity, sensor and device classes (iteration 2, step 3)
HaEntity describes an entity for device-based discovery, throttles its
state (SEN-14) and reports availability (SEN-15); HaSensor is the
`sensor` type. HaDevice publishes discovery, availability and states
after each connect and after HA's birth message, provides the last
will and removes itself from HA. Unit tests with a fake MQTT client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:12:43 +02:00
matzmanandClaude Opus 5.5 96df60e6bd 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>
2026-10-06 16:09:53 +02:00
matzmanandClaude Opus 5.5 e0229d6647 Find libmosquitto and ship it in release packages (iteration 2, step 1)
cmake/Mosquitto.cmake finds it through pkg-config; with
FRAMEASSISTANT_SHIP_MOSQUITTO=ON (all package presets) it installs
lib/libmosquitto.so.1 and Debian's copyright file and excludes the
library from the Qt bundler (D35). The build containers get
libmosquitto-dev, the test container the mosquitto broker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:44:20 +02:00
matzmanandClaude Opus 5.5 7810db5cbe Design of iteration 2: MQTT client, HA device, app commands
libmosquitto's own thread (D42), app commands in the platform for
--ha-remove (D43), committed TLS test certificates (D44).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:40:42 +02:00
matzmanandClaude Opus 5.5 cd62e57183 Close iteration 1 and record the requirements of iteration 2
Frame checks 3 and 4 are postponed (no device access). Iteration 2
publishes a dummy sensor instead of a real one (D41) and includes TLS,
--desktop-novr and --ha-remove.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:46:08 +02:00
44 changed files with 2862 additions and 72 deletions
+46 -13
View File
@@ -30,7 +30,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
it reports headset/environment sensors to Home Assistant, offers services Home Assistant can
call (e.g. notifications in the headset), and shows a Home Assistant dashboard as a SteamVR
dashboard tab. Status: the overlay platform works; the Home Assistant parts are being built
(implementation iteration 1: settings file; the web UI tab was dropped, see REQUIREMENTS.md D40).
(iteration 1, settings file, is done; the web UI tab was dropped, see REQUIREMENTS.md D40;
implementation iteration 2, MQTT and HA device, is implemented and awaits its manual tests).
**Start every session by reading `docs/PROGRESS.md`** (current state, next steps, log).
@@ -61,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)
@@ -74,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 `<name>-<id>.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 <path>`.
- Overlay and both desktop modes need a settings file and connect to its MQTT broker; pass a
test one with `--config <path>` (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.
@@ -107,19 +111,22 @@ 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, app commands).
- **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
command-line options (`QCommandLineOption` list) to the base constructor. It overrides
the hooks `prepare(QString*)` (first; mode and app key known, no engine/surfaces/SteamVR
yet; returning false ends `exec()` with exit code 1), `loading()` (backends + shared
engine exist, QML not loaded) and `started()` (everything runs). In the hooks, protected
command-line options and its commands (two `QCommandLineOption` lists, D38, D43) to the
base constructor. It overrides the hooks `prepare(QString*)` (first; mode and app key
known, no engine/surfaces/SteamVR yet; returning false ends `exec()` with exit code 1),
`loading()` (backends + shared engine exist, QML not loaded), `started()` (everything
runs) and `runCommand(name)` (mode `Command`, after `prepare()`, nothing else exists). In the hooks, protected
accessors give the running state: `mode()`, `appKey()`, `appName()`, `isOptionSet()`,
`optionValue()`, `engine()`, `surfaces()`, `surface()` (primary), `surface(id)`,
`vrRuntime()`, `navigation()`. The app is `src/Dashboard.h/.cpp`.
- The base constructor creates the `QGuiApplication` (`QCoreApplication` for manifest
commands), so derived members can be QObjects with timers.
- The base constructor creates the `QGuiApplication` (`QCoreApplication` for manifest and
app commands: decided from the raw `argv` before parsing), so derived members can be
QObjects with timers.
- All QML and surfaces are torn down **at the end of `exec()`**, while the derived object
and its members (e.g. QML singletons) still exist.
- `src/app/Surface` is **one view**: its QML root (from the app's module), renderer, render
@@ -201,7 +208,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
@@ -241,6 +257,11 @@ hides world surfaces itself. Keep the teardown rule: overlays, then SteamVR, the
the build tree and an install.
- The manifest passes `--log-file logs/<exe>.log` (`arguments`), so SteamVR-started instances
log to the install folder (or `build/<preset>/logs/` from the build tree).
- **libmosquitto** (MQTT, D14/D35) comes from pkg-config (`cmake/Mosquitto.cmake`). The Frame
has none, so `FRAMEASSISTANT_SHIP_MOSQUITTO=ON` (all package presets) installs it into `lib/`
as `libmosquitto.so.1`, with Debian's copyright file (it contains the EPL-2.0/EDL-1.0
texts), and adds it to `VRDASH_BUNDLE_EXCLUDE`. Include it after `BundleQt`, which sets that
list.
- `cmake/OpenVR.cmake` downloads only `openvr.h`, `LICENSE` and the matching
`bin/linux64|linuxarm64/libopenvr_api.so` into `third_party/openvr-<ver>/` (gitignored).
- **Deploying to the Frame uses its system Qt by default.** Bundling Qt is the fallback for
@@ -254,11 +275,18 @@ hides world surfaces itself. Keep the teardown rule: overlays, then SteamVR, the
targets, and with a `/usr` Qt it would bundle glibc.
- **`VRDASH_BUNDLE_EXCLUDE` must keep excluding the GPU/display stack** (libGL/EGL/GLX,
libdrm/gbm, Vulkan, Wayland, X11/xcb, libffi, glibc, libstdc++). These must come from SteamOS,
or GPU access breaks.
or GPU access breaks. With libmosquitto shipped, `cmake/Mosquitto.cmake` also excludes
`libssl`/`libcrypto`: the bundler resolves dependencies recursively (also through excluded
libraries) and otherwise ships Debian's OpenSSL 3.5; SteamOS has OpenSSL 3.
- **Release packages** (`scripts/package-release.sh [--bundle-qt]`) contain only the
app, `install.sh` (install/update: stops a running instance, registers,
launches), `uninstall.sh` and `README.txt`. `deploy-to-frame.sh` rsyncs the newest one to
the Frame and runs its `install.sh`.
launches), `uninstall.sh` (stops the app, `--ha-remove`, unregisters) and `README.txt`.
`deploy-to-frame.sh` rsyncs the newest one to the Frame and runs its `install.sh`.
- `package-release.sh` refuses a package whose ELF files *need* (version needs section, not
a library's own definitions) newer `GLIBC_*`/`OPENSSL_*` versions than the Frame has
(glibc 2.39, OpenSSL 3.2.1; D35). Raise the limits in the script only after checking the
Frame.
- About 11 of the binary's 12 MB are debug info (`RelWithDebInfo`).
- **Licenses:** MIT (`LICENSE`); `THIRD_PARTY_NOTICES.md` and OpenVR's license are installed to
`share/doc/frameassistant/`. Bundled builds also get the Debian copyright files of every bundled
library plus the referenced common license texts (collected via `dpkg -S` in
@@ -332,3 +360,8 @@ Check these when testing on the device, and update this list:
- The restructured app code (base class `DashboardApplication`, `Surface`, `Overlay` base,
active-surface navigation) has only been tested off the device so far.
- Several dashboard tabs from one process (works in `tst_surfaces`, not tried in SteamVR).
- Settings errors when started by SteamVR: the app exits and the log names the error (e.g.
missing `device.name`); covered by `tst_config` off the device (iteration 1 checks 3, 4).
- Iteration 2: the package with libmosquitto (system Qt and bundled) connects to the broker;
"Quit app" publishes `offline` (graceful exit); `uninstall.sh` removes the device from HA;
which CA store OpenSSL uses with `mqtt.tls.ca_file` empty.
+2
View File
@@ -56,6 +56,8 @@ include(Dashboard)
if(VRDASH_DEPLOY_QT)
include(BundleQt)
endif()
# After BundleQt: adds libmosquitto to its exclusion list when shipping it.
include(Mosquitto)
add_subdirectory(src)
add_subdirectory(resources)
+4
View File
@@ -30,6 +30,7 @@
"cacheVariables": {
"CMAKE_BUILD_TYPE": "RelWithDebInfo",
"VRDASH_DEPLOY_QT": "ON",
"FRAMEASSISTANT_SHIP_MOSQUITTO": "ON",
"VRDASH_BUILD_TESTS": "OFF"
}
},
@@ -41,6 +42,7 @@
"cacheVariables": {
"CMAKE_BUILD_TYPE": "RelWithDebInfo",
"VRDASH_DEPLOY_QT": "ON",
"FRAMEASSISTANT_SHIP_MOSQUITTO": "ON",
"VRDASH_BUILD_TESTS": "OFF"
}
},
@@ -52,6 +54,7 @@
"CMAKE_BUILD_TYPE": "RelWithDebInfo",
"VRDASH_DEPLOY_QT": "OFF",
"VRDASH_QML_BYTECODE_ONLY": "ON",
"FRAMEASSISTANT_SHIP_MOSQUITTO": "ON",
"VRDASH_BUILD_TESTS": "OFF"
}
},
@@ -64,6 +67,7 @@
"CMAKE_BUILD_TYPE": "RelWithDebInfo",
"VRDASH_DEPLOY_QT": "OFF",
"VRDASH_QML_BYTECODE_ONLY": "ON",
"FRAMEASSISTANT_SHIP_MOSQUITTO": "ON",
"VRDASH_BUILD_TESTS": "OFF"
}
}
+15 -6
View File
@@ -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:
@@ -86,7 +89,7 @@ FrameAssistant reads its settings from `~/.config/frameassistant/config.json`
| `mqtt.username`, `mqtt.password` | no | no login | |
| `mqtt.tls.enabled` | no | `false` | |
| `mqtt.tls.ca_file` | no | system certificates | CA of the broker's certificate |
| `mqtt.tls.cert_file`, `mqtt.tls.key_file` | no | | Client certificate |
| `mqtt.tls.cert_file`, `mqtt.tls.key_file` | no | | Client certificate (needs `ca_file`) |
| `homeassistant.discovery_prefix` | no | `homeassistant` | |
| `homeassistant.url` | no | | Home Assistant's address; reserved for the dashboard, not used yet |
| `notifications.timeout_s` | no | 10 | How long notifications stay |
@@ -96,6 +99,9 @@ only produce a warning. Keep the file private (`chmod 600`) when it holds a pass
## Development
- **Host build dependencies:** Qt 6 (≥ 6.5) and libmosquitto with its pkg-config file
(Arch: `mosquitto`, Debian: `libmosquitto-dev`); the `mosquitto` broker for the MQTT
integration tests. The containers (`docker/`) have everything.
- **App identity** (app key `net.matzman.frameassistant`, tab name, executable name
`frame-assistant`, texture size) is set in the root `CMakeLists.txt`. The executable must not
be named like a SteamVR process such as `vrdashboard` (CMake checks this).
@@ -119,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 <path>` | Settings file (default `~/.config/frameassistant/config.json`) |
| `--snapshot <png>` | Render one frame per surface off-screen and save it (`<name>-<id>.png` with several surfaces) |
| `--benchmark <frames>` | Render frames back to back with a full GPU wait each and print frame times |
@@ -128,13 +135,15 @@ How the pieces fit together: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
| `--max-fps <fps>` | Frame-rate cap (default 60); rendering only happens on change |
| `--install-manifest [--launch]` | Register with SteamVR (auto-start) and optionally start it now; works over SSH |
| `--uninstall-manifest` | Unregister |
| `--ha-remove` | Remove this device from Home Assistant (clears its retained MQTT topics; needs the settings and the broker) and exit; `uninstall.sh` calls it. Works over SSH |
| `--manifest <path>` | Manifest for the two options above (default: the app's own) |
| `--log-file <path>` | Also log to a file (relative paths: relative to the install folder) |
## 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
```
@@ -144,7 +153,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) |
+1
View File
@@ -6,6 +6,7 @@ This project is MIT-licensed (see `LICENSE`). `<project>` below is the CMake pro
| Component | License | How it is used |
|-----------|---------|----------------|
| [OpenVR SDK](https://github.com/ValveSoftware/openvr) (`libopenvr_api.so`, `openvr.h`) | BSD-3-Clause | Shipped in `lib/`; license in `share/doc/<project>/third-party/openvr.LICENSE` |
| [Eclipse Mosquitto](https://mosquitto.org/) client library (`libmosquitto.so.1`) | EPL-2.0 or EDL-1.0 (BSD-3-Clause) | Linked dynamically. Release packages ship Debian's build unmodified in `lib/`; its copyright file in `share/doc/<project>/third-party/libmosquitto1.copyright` |
| [Qt 6](https://www.qt.io/) (Core, Gui, Qml, Quick, Quick Controls, Wayland/XCB plugins) | LGPL-3.0 (Qt open-source license) | Linked dynamically. Default builds use the system's Qt (not shipped). Bundled builds ship Qt's shared libraries unmodified in `lib/`, `plugins/`, `qml/`: replace them to use another compatible Qt build |
**Bundled builds** (`--bundle-qt`) also ship further shared libraries that Qt depends on,
+41
View File
@@ -0,0 +1,41 @@
# Provides the imported target PkgConfig::MOSQUITTO (libmosquitto, the MQTT client
# library; docs/DESIGN.md, D14/D35).
#
# Found through pkg-config (Debian: libmosquitto-dev, Arch: mosquitto). The Frame has no
# libmosquitto, so package builds ship it like libopenvr_api.so: with
# FRAMEASSISTANT_SHIP_MOSQUITTO=ON the library is installed into lib/ under its soname
# (found through RPATH $ORIGIN/../lib), together with Debian's copyright file.
option(FRAMEASSISTANT_SHIP_MOSQUITTO "Install libmosquitto into lib/ (package builds)" OFF)
find_package(PkgConfig REQUIRED)
pkg_check_modules(MOSQUITTO REQUIRED IMPORTED_TARGET libmosquitto)
message(STATUS "libmosquitto: ${MOSQUITTO_VERSION}")
if(FRAMEASSISTANT_SHIP_MOSQUITTO)
find_library(MOSQUITTO_LIBRARY NAMES mosquitto HINTS ${MOSQUITTO_LIBRARY_DIRS} REQUIRED)
get_filename_component(_mosquitto_dir ${MOSQUITTO_LIBRARY} DIRECTORY)
# The soname has been libmosquitto.so.1 since 1.x; the file behind it is versioned.
set(_mosquitto_soname libmosquitto.so.1)
if(NOT EXISTS ${_mosquitto_dir}/${_mosquitto_soname})
message(FATAL_ERROR "${_mosquitto_dir}/${_mosquitto_soname} not found")
endif()
file(REAL_PATH ${_mosquitto_dir}/${_mosquitto_soname} _mosquitto_file)
install(FILES ${_mosquitto_file} DESTINATION ${CMAKE_INSTALL_LIBDIR}
RENAME ${_mosquitto_soname})
# Debian's copyright file (same path for every architecture through multiarch).
set(_mosquitto_copyright /usr/share/doc/libmosquitto1/copyright)
if(EXISTS ${_mosquitto_copyright})
install(FILES ${_mosquitto_copyright}
DESTINATION ${CMAKE_INSTALL_DOCDIR}/third-party
RENAME libmosquitto1.copyright)
else()
message(WARNING "${_mosquitto_copyright} not found: package lacks libmosquitto's license")
endif()
# Shipped by the rule above: the Qt bundler must not copy it a second time. Its
# OpenSSL comes from the system (SteamOS has OpenSSL 3; libmosquitto needs OPENSSL_3.0.0):
# the bundler resolves dependencies recursively and would bundle Debian's.
list(APPEND VRDASH_BUNDLE_EXCLUDE "^libmosquitto" "^libssl\\.so" "^libcrypto\\.so")
endif()
+1
View File
@@ -13,6 +13,7 @@ RUN dpkg --add-architecture arm64 \
qt6-base-dev qt6-base-dev-tools qt6-declarative-dev qt6-declarative-dev-tools \
qt6-base-dev:arm64 qt6-declarative-dev:arm64 \
libgl-dev:arm64 libegl-dev:arm64 libvulkan-dev:arm64 libxkbcommon-dev:arm64 \
libmosquitto-dev:arm64 \
qml6-module-qtquick:arm64 qml6-module-qtquick-controls:arm64 \
qml6-module-qtquick-layouts:arm64 qml6-module-qtquick-templates:arm64 \
qml6-module-qtqml-workerscript:arm64 qml6-module-qtquick-window:arm64 \
+1 -1
View File
@@ -9,7 +9,7 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates cmake ninja-build pkgconf file patchelf g++ \
qt6-base-dev qt6-base-dev-tools qt6-declarative-dev qt6-declarative-dev-tools \
libgl-dev libegl-dev libvulkan-dev libxkbcommon-dev \
libgl-dev libegl-dev libvulkan-dev libxkbcommon-dev libmosquitto-dev \
qml6-module-qtquick qml6-module-qtquick-controls qml6-module-qtquick-layouts \
qml6-module-qtquick-templates qml6-module-qtqml-workerscript qml6-module-qtquick-window \
qml6-module-qttest qt6-qpa-plugins qt6-wayland \
+3 -2
View File
@@ -1,9 +1,10 @@
# Test environment on top of docker/native.Dockerfile: Mesa Vulkan/GL drivers (incl. the
# lavapipe software rasterizer), Khronos validation layers and Xvfb. See scripts/container-test.sh.
# lavapipe software rasterizer), Khronos validation layers, Xvfb, and the mosquitto broker
# for the MQTT integration tests. See scripts/container-test.sh.
FROM frameassistant-build-native
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
mesa-vulkan-drivers vulkan-validationlayers vulkan-tools libgl1-mesa-dri \
xvfb xauth fonts-dejavu-core \
xvfb xauth fonts-dejavu-core mosquitto \
&& rm -rf /var/lib/apt/lists/*
+12 -2
View File
@@ -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
@@ -40,6 +41,9 @@ Your app derives from it:
`setDisplayName()`, `setQuickStyle()`, `setQmlModule()`). The app's own command-line
options go to the base constructor as a list of `QCommandLineOption`s; they are parsed and
listed in `--help` with the built-in ones, and read with `isOptionSet()`/`optionValue()`.
Its commands (flags such as `--ha-remove`) go there as a second list: like the manifest
commands they get a `QCoreApplication` (no display needed, works over SSH), and `exec()`
runs `prepare()` and then `runCommand(name)` in mode `Command` instead of a mode.
- **Hooks:**
- `prepare(QString* error)`: first, once mode and app key are known, before the QML
engine, the surfaces and SteamVR exist (not for manifest commands). Returning `false`
@@ -47,10 +51,13 @@ Your app derives from it:
- `loading()`: the shared QML engine, the surfaces and their render backends exist; QML
isn't loaded yet. Set up what the QML needs here.
- `started()`: everything runs. Connect to surfaces and input here.
- `runCommand(name)`: one of the app's commands, after `prepare()`; no QML engine,
surfaces or SteamVR. Its result is the exit code.
- **Accessors:** `mode()`, `appKey()`, `appName()`, `isOptionSet()`, `optionValue()`,
`engine()`, `surfaces()`, `surface()` (the first), `surface(id)`, `vrRuntime()`,
`navigation()`.
- **Lifetime:** the base constructor creates the `QGuiApplication`, so your members can be
- **Lifetime:** the base constructor creates the `QGuiApplication` (`QCoreApplication` for
commands), so your members can be
QObjects with timers. All QML and surfaces are torn down at the end of `exec()`, while your
object and its members still exist.
@@ -153,6 +160,9 @@ sequenceDiagram
- `libopenvr_api.so` next to the executable
- install rules and registration for Qt bundling
- the check against SteamVR process names
- **`cmake/Mosquitto.cmake`** (app, not platform) finds libmosquitto through pkg-config. With
`FRAMEASSISTANT_SHIP_MOSQUITTO=ON` (all package presets) it installs the library into `lib/`
under its soname plus Debian's copyright file, and excludes it from the Qt bundler.
- **App identity** (root `CMakeLists.txt`): `project()` name and version, `VRDASH_APP_KEY`,
`VRDASH_DISPLAY_NAME`, `VRDASH_EXECUTABLE_NAME`, texture size. CMake generates from them
`AppConfig.h` and the SteamVR manifest, with `binary_path_linux_arm` for ARM builds and
+192 -7
View File
@@ -1,7 +1,8 @@
# Design
Status: **in progress**, iteration 1, phase 2 (planning and design). High-level architecture
and MVP details done; next: platform changes and the implementation plan. Requirements and their decisions (D1–D15):
Status: **in progress**. High-level architecture and MVP details done; iteration 1
implemented. Iteration 2 (MQTT, HA device, UI-1): design approved and implemented 2026-10-06
("Iteration 2: MQTT and HA device"); next: phase 4 (testing). Requirements and their decisions (D1–D15):
[REQUIREMENTS.md](REQUIREMENTS.md). Design decisions start at D16.
## Process and threads
@@ -14,7 +15,7 @@ and MVP details done; next: platform changes and the implementation plan. Requir
| 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 | the MQTT client (libmosquitto): 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.
@@ -27,8 +28,9 @@ and MVP details done; next: platform changes and the implementation plan. Requir
HA device ◀── connectionChanged(state, error) ── MQTT client
```
- The MQTT client lives in its thread via `moveToThread()`; it creates its socket notifiers
and timers there, after the move.
- 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.
@@ -184,7 +186,7 @@ Notes for later:
## Open
1. Detailed plan for implementation iteration 1 (see [PROGRESS.md](PROGRESS.md), "Next steps").
1. Nothing open for iteration 2; points to verify are listed in its section.
## HA MQTT interface
@@ -260,6 +262,179 @@ Layout follows common practice (e.g. Zigbee2MQTT's `…/<device>/set`); one topi
- Display time: one value from the configuration, default 10 s (Appendix A). Per-notification
durations come with the JSON command.
## Iteration 2: MQTT and HA device
Implements the iteration 2 requirements ([PROGRESS.md](PROGRESS.md), "Next steps"; D41).
### Components
```
main thread MosquittoClient's thread
Dashboard (app)
├─ ConnectionStatus (QML singleton, UI-1) ◀─ signals ─┐
├─ HaDevice ── publish/subscribe ──▶ MqttClient ◀─────┼── callbacks (connect, message, disconnect)
│ └─ HaSensor "uptime" ◀── QTimer 10 s (app) │
└─ Config (immutable value, D19) │
```
MQTT and HA classes live in `frameassistant_lib` (tests link them); the platform stays free of
MQTT (D17).
| Class | File | Role |
|---|---|---|
| `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, D45)
- **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()`,
`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, 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:** `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` (and the client certificate, if configured);
the broker's certificate and host name are verified. With an empty `ca_file`, only
`MOSQ_OPT_TLS_USE_OS_CERTS`: libmosquitto's `tls_set()` insists on a CA file or directory,
so this option alone enables TLS, and **a client certificate needs `ca_file`**. Unreadable
files and a certificate without key are reported before connecting.
- **SIGPIPE:** with TLS, OpenSSL writes with `write()`, not `send(MSG_NOSIGNAL)`, so writing
to a connection the broker closed raised SIGPIPE and ended the process (found by
`tst_mqtt`). The client thread blocks SIGPIPE (`pthread_sigmask`); only it writes to the
socket.
- **Errors** are reported in English regardless of the locale (`strerrordesc_np()`). A failed
TLS handshake returns only "TLS error" (2.0) or "Protocol error" (2.1); libmosquitto's
error log line of the attempt is appended, e.g. "Protocol error: certificate verify
failed". 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
- **After each connect:** subscribe to `homeassistant/status`, then publish discovery, device
`online`, and every entity's state and availability.
- **HA birth message** (`homeassistant/status` = `online`): republish after a random 1–5 s
delay (D32).
- **Dummy sensor (D41):** `HaSensor` "uptime", seconds since start, fed by a 10 s `QTimer` in
the app.
- **Discovery payload** (example; manufacturer and model come from OpenVR in iteration 3):
```json
{
"dev": {"ids": ["frameassistant_wohnzimmer_frame"], "name": "Wohnzimmer Frame", "sw": "0.1.0"},
"o": {"name": "FrameAssistant", "sw": "0.1.0"},
"~": "frameassistant/wohnzimmer_frame",
"cmps": {
"uptime": {
"p": "sensor", "name": "Uptime",
"uniq_id": "frameassistant_wohnzimmer_frame_uptime",
"dev_cla": "duration", "unit_of_meas": "s", "ent_cat": "diagnostic",
"stat_t": "~/uptime/state",
"avty": [{"t": "~/availability"}, {"t": "~/uptime/availability"}], "avty_mode": "all"
}
},
"qos": 1
}
```
- To verify when implementing: that HA accepts the `~` base topic in device-based discovery.
### Modes
- The platform gets `DashboardMode::DesktopNoVr` (`--desktop-novr`). In iteration 2 it behaves
like `--desktop` (neither uses SteamVR yet; D22's optional SteamVR for `--desktop` comes in
iteration 3).
- `Dashboard::prepare()` loads the settings for overlay, desktop and desktop-novr.
`HaDevice` is created in `loading()` and connects in `started()`.
### App commands (D43)
Today the platform hard-codes its manifest commands: the base constructor scans the raw
`argv` for `--install-manifest`/`--uninstall-manifest`, creates a `QCoreApplication` instead of
a `QGuiApplication` (no display needed, works over SSH), and `exec()` calls
`runManifestCommand()`. This is opened up to the app:
- The base constructor takes the app's **command options** in addition to its other options
(D38). The raw-`argv` scan includes their names, so they also get a `QCoreApplication`.
- They are parsed with all other options: `--config`, `--log-file` work, `--help` lists them.
- `exec()`: after the manifest check, an app command runs `prepare()` and then a new hook
`virtual int runCommand(const QString& name)`, whose result is the exit code. No engine,
surfaces or SteamVR.
- The mode is `DashboardMode::Command` (so `prepare()` can tell); the name of the first app
command given goes to `runCommand()`. Tested in `tst_surfaces` (`surfaces_command`).
- Moving the manifest commands onto this mechanism is possible later, not part of iteration 2.
### `--ha-remove`
Against "ghost entities" (D32): retained discovery and states stay on the broker after an
uninstall, so HA would show the device as unavailable forever (and recreate it after a manual
delete).
- An app command (D43): loads the settings, connects, publishes **empty retained payloads**
to discovery (HA then deletes the device and its entities), device availability, and each
entity's state and availability topics; disconnects.
- Its client uses the client id `frameassistant_<device id>_remove` (doesn't knock a running
instance off the broker) and no will. The `HaDevice` is created only after the connection
is up: a device that sees the connection come up publishes itself first.
- Exit code 0 on success; 1 with an error if the broker can't be reached within 10 s, or the
removal isn't acknowledged within 10 s.
- `uninstall.sh`: `pkill` → `sleep 1` → `--ha-remove` (a failure doesn't stop the uninstall) →
`--uninstall-manifest`.
- Also usable by hand, e.g. after a device name change without `ha_unique_id`:
`--ha-remove --config <old settings>`.
- It doesn't keep the device away: the next start publishes everything again.
### Tests
| Test | Content |
|---|---|
| `tst_haentity` | discovery component (optional keys only when set), topics, throttling (several values within an interval → one state with the newest value; unchanged or reverted values not emitted), first value → state then available, cleared → unavailable |
| `tst_hadevice` (`tests/FakeMqttClient.h`) | topics, discovery prefix, last will, discovery payload, what is published after a connect and in which order, entity without value unavailable, changes while connected, nothing buffered and current state after reconnect, birth message (delayed; cancelled by a disconnect; other messages ignored), `removeFromHa()` |
| `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: connect with the test CA, wrong CA rejected with reason, system CAs (`ca_file` empty), retry after a TLS error (the case that ends libmosquitto's own loop), client certificate required (with/without), settings errors |
| `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 |
- TLS test certificates (CA, server and client certificates with keys, a second CA; EC P-256,
100-year validity) are committed to `tests/data/` (D44), with the commands that made them
in `tests/data/README.md`.
### Implementation order
Each step builds and has its tests green before the next:
1. libmosquitto in CMake and the containers (D35)
2. `MqttClient` + `MosquittoClient` + integration test
3. `HaEntity`/`HaSensor`/`HaDevice` + unit tests
4. App wiring, `--desktop-novr`, UI-1
5. TLS
6. App commands, `--ha-remove`, `uninstall.sh`
7. Packaging check (`readelf -V`), docs
## Configuration file (D34)
- Location: `~/.config/frameassistant/config.json` (`$XDG_CONFIG_HOME` respected); `--config
@@ -339,6 +514,12 @@ excluded from the Qt bundler, licence in `share/doc/frameassistant/`).
of MQTT (D17).
6. Guard: `package-release.sh` checks with `readelf -V` that shipped libraries need no newer
symbol versions than the Frame has (today at most `OPENSSL_3.2`, `GLIBC_2.39`).
Implemented for every ELF file in the package, reading only the version *needs* section
(a library's own definitions, e.g. libssl's `OPENSSL_3.5.0`, gave false positives).
7. Found when building the bundled package: the bundler resolves dependencies recursively,
also through excluded libraries, and shipped Debian's `libssl`/`libcrypto` (OpenSSL 3.5)
for libmosquitto. `cmake/Mosquitto.cmake` now excludes them too: OpenSSL comes from
SteamOS (3.2.1; libmosquitto needs `OPENSSL_3.0.0`).
## Test strategy
@@ -434,7 +615,7 @@ Collected 2026-10-05 for the notification design.
| D25 | 2026-10-05 | Two overlay kinds in the platform: dashboard tabs and world overlays (MVP: one placement, not interactive). Set `VROverlayFlags_IsPremultiplied`. Runtime surfaces and interactive world overlays later | Notifications need world overlays; Qt Quick renders premultiplied alpha |
| D26 | 2026-10-05 | One world overlay for all notifications; QML lays out the cards | No runtime surfaces, fewer resources; per-notification placement isn't required |
| D27 | 2026-10-05 | MVP notification placement: spawn in front (about 1 m, slightly below the line of sight), then stay | Research favours non-head-locked placement; no continuous tracking needed |
| D29 | 2026-10-05 | Threads: only the MQTT client runs in its own thread; everything else, incl. all OpenVR calls and the HA layer, in the main thread. New threads only for work that can block | Blocking connect must not stall rendering; OpenVR isn't documented as thread-safe; the narrowest possible thread boundary |
| D29 | 2026-10-05 | *How the MQTT thread is created: see D42.* Threads: only the MQTT client runs in its own thread; everything else, incl. all OpenVR calls and the HA layer, in the main thread. New threads only for work that can block | Blocking connect must not stall rendering; OpenVR isn't documented as thread-safe; the narrowest possible thread boundary |
| D30 | 2026-10-05 | Test strategy in four levels: unit tests with fakes at the boundaries, integration tests against a local mosquitto, UI/render tools, manual checklist (user's HA, the Frame) | Covers NFR-8; the narrow boundaries (D29, I/O wrappers) make fakes cheap; HA automation deferred |
| D31 | 2026-10-05 | Device id from optional `ha_unique_id` in the configuration, else derived from the device name; entity ids `frameassistant_<device id>_<entity>` | Renaming shouldn't have to create a new HA device |
| D32 | 2026-10-05 | One topic per entity under `frameassistant/<device id>/`, common-practice layout; discovery, availability and states retained; republish on HA's birth message with random delay; device + entity availability with `availability_mode: all`; clear retained topics on uninstall | HA prescribes no layout; the headset is often offline, so retained discovery keeps its entities defined; per-entity topics keep throttling and availability simple |
@@ -445,3 +626,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 | *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 |
+166 -10
View File
@@ -5,22 +5,54 @@ CLAUDE.md ("Working rules").
## Current state
- **Implementation iteration 1, phase 4 (testing on the Frame).** DSH-1 is done with a
negative result: HA's web UI via Qt WebEngine doesn't work on the Frame, so the "HA
Dashboard" tab and the WebEngine dependency were removed (D40). What remains of iteration 1
(platform hooks, settings file, `--config`, log file for SteamVR-started instances) is being
tested on the Frame with the system-Qt package (3.7 MB).
- **Frame test results so far (2026-10-05):**
- **Implementation iteration 2, phase 4 (testing), not started yet.** Requirements (see
"Next steps", D41) and design (DESIGN.md, "Iteration 2: MQTT and HA device", D42–D45)
approved; implementation (phase 3) done and committed on 2026-10-06.
- **Iteration 1 closed.** DSH-1 had a negative result: HA's web UI via Qt WebEngine doesn't
work on the Frame, so the "HA Dashboard" tab and the WebEngine dependency were removed
(D40). The rest of iteration 1 (platform hooks, settings file, `--config`, log file for
SteamVR-started instances) works on the Frame with the system-Qt package (3.7 MB).
- **Frame test results of iteration 1 (2026-10-05):**
| # | Check | Result |
|---|---|---|
| 1 | Started by SteamVR: process runs, log file from the manifest's `--log-file`, settings loaded, rendering with Vulkan (Turnip, Adreno 750) | ✓ |
| 2 | In VR: tab "Frame Assistant" visible and usable | ✓ |
| 3 | Settings without `device.name`, `./install.sh` → app exits, log says "Setting 'device.name' is required" | open |
| 4 | Settings restored, `./install.sh` → runs again | open |
| 3 | Settings without `device.name`, `./install.sh` → app exits, log says "Setting 'device.name' is required" | skipped (no device access), postponed |
| 4 | Settings restored, `./install.sh` → runs again | skipped (no device access), postponed |
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 (phase 3) done:** steps 1–7 of DESIGN.md. **Next: phase 4 (testing)**
with the manual checklist below, from the PC against the user's HA and broker; then on the
Frame.
- **Next session:** run the checklist below with the user (points 1–9 from the PC against
the user's HA and broker, 10–12 on the Frame), record the results, then phase 5
(assessment of iteration 2), then iteration 3 (OpenVR wrappers, sensors).
- **Open suggestions (not done, need the user's decision):**
- Strip the debug info from the package binary (11 of its 12 MB; package 5.0 MB instead of
about 3.7 MB before iteration 2).
- Package README.txt: drop "Home Assistant URL" from the set-up line (`homeassistant.url`
isn't used since D40).
- Iteration 3 decides whether the dummy sensor "Uptime" is removed from HA or kept as a
diagnostic entity (D41).
- **Manual checklist for iteration 2 (phase 4):**
| # | Check | Where | Result |
|---|---|---|---|
| 1 | Settings file for the PC (own device name, the user's broker), `--desktop-novr --config …`: tab shows "Connected" | PC | open |
| 2 | HA: device appears with the "Uptime" sensor (diagnostic), value rises every 10 s; HA log has no MQTT discovery warnings (NFR-7); `~` base topic accepted | PC + HA | open |
| 3 | Close the window → HA shows the entities unavailable at once (graceful exit) | PC + HA | open |
| 4 | `kill -9` → unavailable (last will) | PC + HA | open |
| 5 | Broker restart → tab "Not connected" + error, then "Connected"; HA values current again | PC + HA | open |
| 6 | HA restart → device republished after the birth message | PC + HA | open |
| 7 | Two settings files with different device names → two HA devices | PC + HA | open |
| 8 | Login and, if the broker offers it, TLS against the user's broker | PC + HA | open |
| 9 | `--ha-remove --config …` → device gone from HA | PC + HA | open |
| 10 | Package on the Frame: starts with SteamVR, connects, tab shows the status; "Quit app" → unavailable | Frame | open |
| 11 | `uninstall.sh` on the Frame removes the device from HA; `install.sh` brings it back | Frame | open |
| 12 | Iteration 1 checks 3 and 4 (settings errors when started by SteamVR) | Frame | open |
- **Next session:** checks 3 and 4, then record the results and go to phase 5 (assessment of
iteration 1 with the user). Then iteration 2 (MQTT, HA device, UI-1).
## Next steps
@@ -34,6 +66,36 @@ Requirements and design cover the whole MVP; it is **implemented in four small i
| 3 | OpenVR wrappers and the 13 sensor entities | `--desktop` with optional SteamVR (D22) | sensor values in HA on the Frame |
| 4 | notification manager, `notify` entity, world overlay | world overlay, premultiplied flag (D25) | notification from HA in VR, also in a game |
### Requirements of implementation iteration 2: MQTT, HA device, UI-1
Approved 2026-10-06. Goal: FrameAssistant connects to the user's broker, appears in HA as a
device that goes online and offline correctly, and the main tab shows the connection status.
No real sensors yet: a **dummy sensor** stands in for them (D41).
- **Dummy sensor "Uptime":** a `sensor` with the seconds since start, `entity_category:
diagnostic`, updated every 10 s. In the code a generic test entity whose value is set from
outside: the app feeds it a timer, unit tests set values directly. Ships in iteration 2
builds; iteration 3 decides whether it is removed from HA (component with only `p` once) or
kept as a diagnostic entity.
| Item | Acceptance criterion |
|---|---|
| CON-1, CFG-3 | Connects without login, with login, and with TLS (self-signed CA); connection state and errors are logged |
| CON-2 | The device with the dummy sensor appears in HA via device-based discovery, without configuration in HA |
| CON-3 | Online while running; normal exit and `kill -9` make it unavailable |
| CON-4 | After a broker restart it reconnects with backoff and republishes discovery and states; also republishes after HA's birth message |
| CON-5 | Two settings files with different device names give two HA devices |
| SEN-14 | The dummy sensor's updates are throttled |
| UI-1 | The main tab shows device name, broker, connected or not, and the last error, and follows a broker stop and start |
| Modes (D22) | Overlay, `--desktop` and the new `--desktop-novr` load the settings and connect; in iteration 2 the two desktop modes behave alike (neither uses SteamVR yet) |
| `--ha-remove` (D32) | Clears all retained topics, after which the device is gone from HA; `uninstall.sh` calls it |
| D35 | libmosquitto is in both packages; `package-release.sh`'s symbol-version check passes |
| Tests (D30) | Unit tests with a fake MQTT client; integration tests against a local mosquitto (connect, last will, reconnect, login, TLS) |
| Manual | CON, UI and `--ha-remove` checks from the PC against the user's HA (possible because nothing needs SteamVR). On the Frame: the package with libmosquitto runs; the postponed checks 3 and 4 of iteration 1 |
To find out during the iteration: where the Frame keeps its system certificates (default
for `mqtt.tls.ca_file`).
### Plan for implementation iteration 1: DSH-1 and configuration
Goal: a second dashboard tab **"HA Dashboard"** shows HA's web UI at the URL from the
@@ -77,6 +139,100 @@ Known limitation: the bundled-Qt package does not support WebEngine in this iter
## Log
### 2026-10-06: iteration 2 implemented
- Phase 1 (requirements, D41), phase 2 (design, D42–D44) and phase 3 (implementation in
seven steps, D45) of iteration 2, each approved by the user; details in the entries below.
The user allowed continuing between the steps of phase 3 without asking.
- Commits `cd62e57` (requirements) to `6cb3091` (step 7). All 13 automated tests pass in
`container-test.sh`; phase 4 (manual tests against HA and on the Frame) is next.
- Session ended here at the user's request.
### 2026-10-06: iteration 1 closed
- Frame checks 3 and 4 (settings error when started by SteamVR) skipped: the user had no
access to the device. Postponed to the next session on the Frame; listed in CLAUDE.md
under "Not yet verified on a Steam Frame".
- Phase 5 (assessment) of iteration 1: the user agreed to close it with checks 3 and 4
postponed and to start iteration 2.
- Requirements of iteration 2 approved (see "Next steps"). User's choices: a dummy sensor
instead of a real one (D41); TLS, `--desktop-novr` and `--ha-remove` in this iteration.
Since nothing in iteration 2 needs SteamVR (`--desktop` doesn't call `VR_Init`), the
MQTT/HA checks run from the PC; the Frame only for the package.
- Design of iteration 2 written (DESIGN.md). User's decisions: libmosquitto's own thread
(D42), app commands in the platform for `--ha-remove` (D43), committed TLS test
certificates (D44). Design approved; phase 3 started.
- Step 1: `cmake/Mosquitto.cmake` (pkg-config; `FRAMEASSISTANT_SHIP_MOSQUITTO=ON` in all
package presets installs `lib/libmosquitto.so.1` and Debian's copyright file, which already
contains the EPL-2.0/EDL-1.0 texts); `libmosquitto-dev(:arm64)` in the build containers,
`mosquitto` in the test container; THIRD_PARTY_NOTICES entry. Checked: host configure finds
2.1.2; `container-test.sh` 8/8, no Vulkan validation messages; the cross build stages
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).
- Step 3: `HaEntity` (description, discovery component, throttling, availability),
`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).
- Step 5: TLS in `MosquittoClient` (`ca_file` or the system's CAs, client certificate,
checks before connecting); test certificates in `tests/data/` (D44); 9 TLS cases in
`tst_mqtt`. Findings:
- **SIGPIPE crash:** with TLS, a write to a connection the broker closed killed the
process (OpenSSL uses `write()`). Fixed by blocking SIGPIPE in the client thread.
- libmosquitto's `tls_set()` needs a CA file or directory, so with system CAs only
`MOSQ_OPT_TLS_USE_OS_CERTS` is set, and a client certificate needs `ca_file`
(documented, reported as a settings error).
- A failed handshake returns only "TLS error"/"Protocol error"; the reason is in
libmosquitto's log, which is now appended.
- Checked: host 3 × 21/21; `container-test.sh` 12/12 (libmosquitto 2.0.21), no Vulkan
validation messages.
- Still open: which CA store the Frame's OpenSSL uses by default (for `ca_file` empty);
check on the device.
- Step 6: app commands in the platform (D43: second option list, `QCoreApplication`, mode
`Command`, hook `runCommand()`; `tst_surfaces` `surfaces_command`); `--ha-remove` in the
app; `uninstall.sh` stops the app, runs `--ha-remove` (failure doesn't stop it) and
unregisters. Checked end to end against a local mosquitto, without a display: retained
discovery, availability, state and entity availability before, none after (exit 0);
broker down → exit 1 after 10 s; missing settings → exit 1.
- Step 7: `package-release.sh` checks the symbol versions of every shipped ELF file against
the Frame (glibc 2.39, OpenSSL 3.2.1; D35); a test with a too low limit names the files
and refuses the package. Building the bundled package showed two problems, both fixed:
the check read version *definitions* too (false positives from libssl), and the bundler
shipped Debian's OpenSSL 3.5 for libmosquitto (now excluded; SteamOS provides OpenSSL).
Checked:
- system-Qt package 5.0 MB (`lib/libmosquitto.so.1`, its copyright file; the binary is
12 MB, 11 MB of it debug info); bundled package 45 MB, no OpenSSL, libmosquitto once;
both need at most `GLIBC_2.38`, `OPENSSL_3.0.0`.
- generated `uninstall.sh` with a stub binary and a stub `pkill`: stop, `--ha-remove`,
unregister; a failing `--ha-remove` prints the warning and still unregisters.
- `container-test.sh` 13/13, no Vulkan validation messages.
### 2026-10-05: requirements approved
- User approved REQUIREMENTS.md including the priorities (D13). Phase 2 started.
+1
View File
@@ -164,6 +164,7 @@ FrameAssistant.
| D20 | 2026-10-05 | New requirement UI-1 (status display in the main tab), MVP | Helps testing on the device; little effort |
| D28 | 2026-10-05 | DSH-1 split: DSH-1 (MVP) = HA web UI in a tab as an out-of-the-box test; DSH-2 (Should) = the full dashboard. The MVP (D8) now also contains DSH-1 | The user wants to see early whether the simple variant works |
| D40 | 2026-10-05 | DSH-1 done with a negative result: HA's web UI via Qt WebEngine doesn't work on the Frame (system WebEngine not loadable; bundling our own failed). The "HA Dashboard" tab and the WebEngine dependency are removed; the MVP no longer contains a dashboard. `homeassistant.url` stays reserved for DSH-2 | The out-of-the-box test answered its question; a WebEngine dependency would keep the whole app from starting on the Frame |
| D41 | 2026-10-06 | Implementation iteration 2 publishes a **dummy sensor** ("Uptime", diagnostic) instead of a real one; TLS, `--desktop-novr` and `--ha-remove` are part of iteration 2 | MQTT and HA discovery can be tested without real sensors; the same entity serves the unit tests; without SteamVR the HA checks run from the PC |
Design decisions (D16 onward) are in [DESIGN.md](DESIGN.md).
+32 -3
View File
@@ -50,6 +50,29 @@ cp -a "$stage/." "$out/"
find "$out/bin" -maxdepth 1 -type f -perm -u+x ! -name "$exe" -delete
cp "$root/LICENSE" "$out/"
# Nothing shipped may need newer glibc or OpenSSL symbol versions than the Frame has
# (docs/DESIGN.md, D35; checked on the Frame 2026-10-05: glibc 2.39, OpenSSL 3.2.1). Catches a
# Debian update of a shipped library (libmosquitto, bundled Qt) that SteamOS can't run.
frame_max=(GLIBC_2.39 OPENSSL_3.2.0)
too_new=0
while IFS= read -r -d '' file; do
[[ "$(file -b "$file")" == ELF* ]] || continue
# Only the "needs" section: a library's own version definitions (e.g. libssl defining
# OPENSSL_3.5.0) are not needed from the system.
for needed in $(readelf -V --wide "$file" |
awk '/^Version needs section/ {n = 1; next} /^Version/ {n = 0} n' |
grep -oE "Name: (GLIBC|OPENSSL)_[0-9.]+" | cut -d' ' -f2 | sort -u); do
for max in "${frame_max[@]}"; do
[[ "${max%%_*}" == "${needed%%_*}" ]] || continue
if [[ "$(printf '%s\n%s\n' "$needed" "$max" | sort -V | tail -1)" != "$max" ]]; then
echo "${file#"$out"/} needs $needed; the Frame has at most $max" >&2
too_new=1
fi
done
done
done < <(find "$out" -type f -print0)
(( too_new )) && { echo "Package not usable on the Frame" >&2; exit 1; }
cat > "$out/install.sh" <<EOF
#!/bin/sh
# Installs or updates $name: registers it with SteamVR (starts automatically with
@@ -64,9 +87,14 @@ EOF
cat > "$out/uninstall.sh" <<EOF
#!/bin/sh
# Unregisters $name from SteamVR and stops it. The folder can be deleted afterwards.
# Stops $name, removes its device from Home Assistant (clears its retained MQTT
# topics) and unregisters it from SteamVR. The folder can be deleted afterwards.
cd "\$(dirname "\$0")" || exit 1
pkill -f "/bin/$exe( |\\$)" 2>/dev/null
mkdir -p logs
# Stopped first, so it can't publish itself again; the broker sends its last will at once.
pkill -f "/bin/$exe( |\\$)" 2>/dev/null && sleep 1
./bin/$exe --ha-remove --log-file logs/install.log ||
echo "Could not remove the device from Home Assistant (see logs/install.log); unregistering anyway." >&2
exec ./bin/$exe --uninstall-manifest --log-file logs/install.log
EOF
chmod +x "$out/install.sh" "$out/uninstall.sh"
@@ -92,7 +120,8 @@ chmod +x "$out/install.sh" "$out/uninstall.sh"
echo "Install / update: ./install.sh (SteamVR must be running)"
echo " SteamVR remembers this folder's location: after moving it,"
echo " run ./install.sh again."
echo "Uninstall: ./uninstall.sh"
echo "Uninstall: ./uninstall.sh (also removes the device from Home Assistant;"
echo " needs the settings file and the MQTT broker)"
echo "Logs: logs/$exe.log (the app), logs/install.log (install.sh);"
echo " run ./bin/$exe --help for options"
echo
+7 -1
View File
@@ -52,9 +52,14 @@ message(STATUS "Vulkan backend: ${_vulkan_ok}")
# The app's own logic (no SteamVR, no rendering): linked by the app and its tests.
qt_add_library(frameassistant_lib STATIC
config/Config.h config/Config.cpp
mqtt/MqttClient.h
mqtt/MosquittoClient.h mqtt/MosquittoClient.cpp
ha/HaEntity.h ha/HaEntity.cpp
ha/HaSensor.h ha/HaSensor.cpp
ha/HaDevice.h ha/HaDevice.cpp
)
target_include_directories(frameassistant_lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(frameassistant_lib PUBLIC Qt6::Core)
target_link_libraries(frameassistant_lib PUBLIC Qt6::Core PkgConfig::MOSQUITTO)
target_compile_options(frameassistant_lib PRIVATE -Wall -Wextra)
# The app: its UI is the QML module "FrameAssistant" (src/qml/).
@@ -62,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;
};
+121 -6
View File
@@ -1,11 +1,25 @@
#include "Dashboard.h"
#include <QCommandLineOption>
#include <QCoreApplication>
#include <QDebug>
#include <QEventLoop>
#include <QTimer>
#include "ha/HaDevice.h"
#include "ha/HaSensor.h"
#include "mqtt/MosquittoClient.h"
namespace {
const QString kConfigOption = QStringLiteral("config");
const QString kHaRemoveCommand = QStringLiteral("ha-remove");
// 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;
// --ha-remove: how long it may take to connect, and then to publish.
constexpr int kRemoveTimeoutMs = 10000;
} // namespace
@@ -15,22 +29,123 @@ Dashboard::Dashboard(int& argc, char** argv)
{QCommandLineOption(kConfigOption,
QStringLiteral("Settings file (default: %1).")
.arg(Config::defaultPath()),
QStringLiteral("path"))}) {
QStringLiteral("path"))},
{QCommandLineOption(
kHaRemoveCommand,
QStringLiteral("Remove this device from Home Assistant (clears "
"its retained MQTT topics) and exit."))}) {
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 && mode() != DashboardMode::Command)
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;
}
MosquittoClient::Settings Dashboard::mqttSettings() const {
MosquittoClient::Settings settings;
settings.broker = m_config->mqtt;
settings.clientId = QStringLiteral("frameassistant_") + m_config->deviceId;
settings.will = HaDevice::lastWill(m_config->deviceId);
return settings;
}
HaDevice::Info Dashboard::deviceInfo() const {
return {.name = m_config->deviceName,
.id = m_config->deviceId,
.discoveryPrefix = m_config->discoveryPrefix,
.softwareVersion = QCoreApplication::applicationVersion()};
}
HaSensor* Dashboard::addEntities(HaDevice& device) {
return device.addSensor({.id = QStringLiteral("uptime"),
.name = QStringLiteral("Uptime"),
.deviceClass = QStringLiteral("duration"),
.unit = QStringLiteral("s"),
.category = QStringLiteral("diagnostic")});
}
void Dashboard::loading() {
if (!m_config) return;
m_mqtt = std::make_unique<MosquittoClient>(mqttSettings());
m_device = std::make_unique<HaDevice>(deviceInfo(), m_mqtt.get());
m_uptime = addEntities(*m_device);
const MqttConfig& broker = m_config->mqtt;
m_status.setDevice(m_config->deviceName,
QStringLiteral("%1:%2").arg(broker.host).arg(broker.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();
}
int Dashboard::runCommand(const QString& name) {
if (name == kHaRemoveCommand) return removeFromHa();
return DashboardApplication::runCommand(name);
}
int Dashboard::removeFromHa() {
MosquittoClient::Settings settings = mqttSettings();
// Its own client id, so it doesn't knock a running instance off the
// broker; no will, since it publishes nothing to keep.
settings.clientId += QStringLiteral("_remove");
settings.will = {};
MosquittoClient client(settings);
QEventLoop loop;
QTimer::singleShot(kRemoveTimeoutMs, &loop, &QEventLoop::quit);
connect(&client, &MqttClient::connectionChanged, &loop,
[&loop](MqttClient::State state) {
if (state == MqttClient::State::Connected) loop.quit();
});
client.connectToBroker();
loop.exec();
if (client.state() != MqttClient::State::Connected) {
qCritical().noquote() << "Cannot remove the device from Home Assistant: "
"no connection to the broker";
return 1;
}
// Created only now: a device that saw the connection come up would
// publish itself first.
HaDevice device(deviceInfo(), &client);
addEntities(device);
const bool published = device.removeFromHa();
const bool acknowledged = client.disconnectFromBroker(kRemoveTimeoutMs);
if (!published || !acknowledged) {
qCritical().noquote() << "Removing the device from Home Assistant failed";
return 1;
}
qInfo().noquote() << "Removed device" << m_config->deviceName
<< "(" + m_config->deviceId + ") from Home Assistant";
return 0;
}
+35 -2
View File
@@ -1,7 +1,16 @@
#pragma once
#include <QElapsedTimer>
#include <QTimer>
#include <memory>
#include "ConnectionStatus.h"
#include "app/DashboardApplication.h"
#include "config/Config.h"
#include "ha/HaDevice.h"
#include "mqtt/MosquittoClient.h"
class HaSensor;
// 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,35 @@ 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;
// --ha-remove (docs/DESIGN.md, "--ha-remove").
int runCommand(const QString& name) override;
private:
Config m_config;
// The settings for the MQTT client and the HA device.
MosquittoClient::Settings mqttSettings() const;
HaDevice::Info deviceInfo() const;
// Adds the entities to `device` (the same for the app and --ha-remove);
// returns the dummy sensor.
static HaSensor* addEntities(HaDevice& device);
int removeFromHa();
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};
};
+47 -10
View File
@@ -115,6 +115,7 @@ std::unique_ptr<RenderBackend> startBackend(GraphicsApi api, bool apiExplicit,
struct DashboardApplication::Options {
QString error; // set: exec() reports it and fails
bool manifestCommand = false;
QString appCommand; // name of the app command given, if any
bool install = false;
bool launch = false;
QString manifest;
@@ -138,10 +139,14 @@ 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:
return "benchmark";
case DashboardMode::Command:
return "command";
}
return "?";
}
@@ -168,7 +173,8 @@ void DashboardApplication::setDefaultQmlModule(const char* uri) {
}
DashboardApplication::DashboardApplication(
int& argc, char** argv, const QList<QCommandLineOption>& appOptions)
int& argc, char** argv, const QList<QCommandLineOption>& appOptions,
const QList<QCommandLineOption>& appCommands)
: m_options(std::make_unique<Options>()), m_qmlModule(defaultQmlModule()) {
// When launched by SteamVR, stderr is not a TTY and Qt would log to
// journald only; keep messages on stderr so they show up wherever the
@@ -176,14 +182,24 @@ DashboardApplication::DashboardApplication(
if (!qEnvironmentVariableIsSet("QT_FORCE_STDERR_LOGGING"))
qputenv("QT_FORCE_STDERR_LOGGING", "1");
// Manifest commands only talk to SteamVR: no GUI (and no display
// connection) needed, so they also work over SSH.
// Manifest commands only talk to SteamVR, app commands do their own thing:
// no GUI (and no display connection) needed, so they also work over SSH.
// Decided before parsing: the parser needs the application object.
Options& o = *m_options;
o.manifestCommand = std::any_of(argv + 1, argv + argc, [](const char* arg) {
return qstrcmp(arg, "--install-manifest") == 0 ||
qstrcmp(arg, "--uninstall-manifest") == 0;
});
if (o.manifestCommand)
const bool appCommand =
std::any_of(argv + 1, argv + argc, [&appCommands](const char* arg) {
for (const QCommandLineOption& command : appCommands) {
for (const QString& name : command.names()) {
if (QLatin1String(arg) == QStringLiteral("--") + name) return true;
}
}
return false;
});
if (o.manifestCommand || appCommand)
m_app = std::make_unique<QCoreApplication>(argc, argv);
else
m_app = std::make_unique<QGuiApplication>(argc, argv);
@@ -199,6 +215,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,10 +267,11 @@ 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});
for (const QCommandLineOption& option : appOptions) {
parser.addOptions({desktopOption, desktopNoVrOption, snapshotOption,
benchmarkOption, apiOption, keyOption, nameOption,
fpsOption, installOption, uninstallOption, manifestOption,
launchOption, logOption});
for (const QCommandLineOption& option : appOptions + appCommands) {
if (!parser.addOption(option))
o.error = QStringLiteral("App option --%1 clashes with another option")
.arg(option.names().constFirst());
@@ -276,6 +297,12 @@ DashboardApplication::DashboardApplication(
? resolvePath(parser.value(manifestOption))
: shareDir() + QStringLiteral("/manifest.vrmanifest");
if (o.manifestCommand) return;
for (const QCommandLineOption& command : appCommands) {
if (parser.isSet(command)) {
o.appCommand = command.names().constFirst();
return;
}
}
if (parser.isSet(apiOption)) {
if (!parseGraphicsApi(parser.value(apiOption), &o.graphicsApi))
@@ -291,7 +318,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;
@@ -329,6 +358,10 @@ void DashboardApplication::setQuickStyle(const QString& style) {
bool DashboardApplication::prepare(QString*) { return true; }
int DashboardApplication::runCommand(const QString& name) {
return fail(QStringLiteral("Command --%1 is not implemented").arg(name));
}
void DashboardApplication::loading() {}
void DashboardApplication::started() {}
@@ -391,7 +424,7 @@ int DashboardApplication::exec() {
QString error;
if (!validateConfig(&error)) return fail(error);
m_mode = o.mode;
m_mode = o.appCommand.isEmpty() ? o.mode : DashboardMode::Command;
m_appKey = !o.appKey.isEmpty() ? o.appKey
: !m_configAppKey.isEmpty()
? m_configAppKey
@@ -402,6 +435,7 @@ int DashboardApplication::exec() {
: QString::fromUtf8(AppConfig::kDisplayName);
QCoreApplication::setApplicationName(m_appName);
if (!prepare(&error)) return fail(error);
if (m_mode == DashboardMode::Command) return runCommand(o.appCommand);
if (!m_quickStyle.isEmpty()) QQuickStyle::setStyle(m_quickStyle);
m_engine = std::make_unique<QQmlEngine>();
@@ -411,6 +445,7 @@ int DashboardApplication::exec() {
int result = 1;
switch (o.mode) {
case DashboardMode::Desktop:
case DashboardMode::DesktopNoVr:
result = runDesktopPreview();
break;
case DashboardMode::Snapshot:
@@ -422,6 +457,8 @@ int DashboardApplication::exec() {
case DashboardMode::Overlay:
result = runOverlay();
break;
case DashboardMode::Command:
break; // handled above
}
// Still within exec(): the derived object (and what it owns) outlives all
// QML. Surfaces (their QML scenes, renderers, backends) before the engine.
+20 -2
View File
@@ -18,7 +18,17 @@ 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).
// Command: one of the app's commands runs (runCommand()); no GUI.
enum class DashboardMode {
Overlay,
Desktop,
DesktopNoVr,
Snapshot,
Benchmark,
Command
};
const char* dashboardModeName(DashboardMode mode);
// Base class of a dashboard app. It owns everything app- and surface-related:
@@ -57,8 +67,12 @@ class DashboardApplication : public QObject {
// `appOptions` are the app's own command-line options: parsed and listed
// in --help together with the built-in ones (names must not clash); read
// them with isOptionSet() / optionValue().
// `appCommands` are the app's commands (flags, e.g. --ha-remove): like the
// manifest commands they need no display (QCoreApplication), and exec()
// runs prepare() and then runCommand() instead of a mode.
DashboardApplication(int& argc, char** argv,
const QList<QCommandLineOption>& appOptions = {});
const QList<QCommandLineOption>& appOptions = {},
const QList<QCommandLineOption>& appCommands = {});
~DashboardApplication() override;
// Runs the mode selected on the command line; returns the exit code.
@@ -96,6 +110,10 @@ class DashboardApplication : public QObject {
virtual void loading();
// Everything runs; called right before the event loop starts.
virtual void started();
// Command mode: runs the app command `name` (an appCommands option, see
// the constructor) after prepare(); no QML engine, surfaces or SteamVR.
// Returns the exit code. The default fails.
virtual int runCommand(const QString& name);
// --- The running app (valid in the hooks and until exec() returns) ---
DashboardMode mode() const { return m_mode; }
+141
View File
@@ -0,0 +1,141 @@
#include "ha/HaDevice.h"
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QRandomGenerator>
#include <utility>
#include "ha/HaSensor.h"
namespace {
const QString kTopicRoot = QStringLiteral("frameassistant");
const QByteArray kOnline = "online";
const QByteArray kOffline = "offline";
QString baseTopicFor(const QString& deviceId) {
return kTopicRoot + QLatin1Char('/') + deviceId;
}
} // namespace
HaDevice::HaDevice(Info info, MqttClient* client, QObject* parent)
: QObject(parent), m_info(std::move(info)), m_client(client) {
m_birthTimer.setSingleShot(true);
connect(&m_birthTimer, &QTimer::timeout, this, &HaDevice::publishAll);
connect(m_client, &MqttClient::connectionChanged, this,
&HaDevice::onConnectionChanged);
connect(m_client, &MqttClient::messageReceived, this, &HaDevice::onMessage);
}
MqttClient::Will HaDevice::lastWill(const QString& deviceId) {
return {baseTopicFor(deviceId) + QStringLiteral("/availability"), kOffline,
true};
}
HaSensor* HaDevice::addSensor(HaEntity::Description description) {
auto* sensor = new HaSensor(std::move(description), this);
addEntity(sensor);
return sensor;
}
QString HaDevice::baseTopic() const { return baseTopicFor(m_info.id); }
QString HaDevice::discoveryTopic() const {
return m_info.discoveryPrefix + QStringLiteral("/device/") + m_info.id +
QStringLiteral("/config");
}
QString HaDevice::availabilityTopic() const {
return lastWill(m_info.id).topic;
}
QByteArray HaDevice::discoveryPayload() const {
const QString uniquePrefix = kTopicRoot + QLatin1Char('_') + m_info.id;
QJsonObject components;
for (const HaEntity* entity : m_entities)
components.insert(entity->description().id,
entity->discoveryComponent(uniquePrefix));
const QJsonObject payload{
{QStringLiteral("dev"),
QJsonObject{{QStringLiteral("ids"), QJsonArray{uniquePrefix}},
{QStringLiteral("name"), m_info.name},
{QStringLiteral("sw"), m_info.softwareVersion}}},
{QStringLiteral("o"),
QJsonObject{{QStringLiteral("name"), QStringLiteral("FrameAssistant")},
{QStringLiteral("sw"), m_info.softwareVersion}}},
{QStringLiteral("~"), baseTopic()},
{QStringLiteral("cmps"), components},
{QStringLiteral("qos"), 1}};
return QJsonDocument(payload).toJson(QJsonDocument::Compact);
}
bool HaDevice::removeFromHa() {
// Discovery first: HA deletes the device before its topics go empty.
bool ok = m_client->publish(discoveryTopic(), {}, true);
ok = m_client->publish(availabilityTopic(), {}, true) && ok;
for (const HaEntity* entity : m_entities) {
ok = m_client->publish(topic(entity->stateTopic()), {}, true) && ok;
ok = m_client->publish(topic(entity->availabilityTopic()), {}, true) && ok;
}
return ok;
}
void HaDevice::setBirthDelay(int minMs, int maxMs) {
m_birthDelayMinMs = minMs;
m_birthDelayMaxMs = maxMs;
}
void HaDevice::addEntity(HaEntity* entity) {
m_entities.append(entity);
// While disconnected these publishes fail; publishAll() catches up after
// the next connect.
connect(entity, &HaEntity::stateChanged, this,
[this, entity](const QByteArray& state) {
m_client->publish(topic(entity->stateTopic()), state, true);
});
connect(entity, &HaEntity::availabilityChanged, this,
[this, entity] { publishAvailability(entity); });
}
void HaDevice::publishAll() {
m_client->publish(discoveryTopic(), discoveryPayload(), true);
m_client->publish(availabilityTopic(), kOnline, true);
for (const HaEntity* entity : m_entities) {
// The state before the availability, so HA never shows "unknown".
if (entity->hasValue())
m_client->publish(topic(entity->stateTopic()), entity->state(), true);
publishAvailability(entity);
}
}
void HaDevice::publishAvailability(const HaEntity* entity) {
m_client->publish(topic(entity->availabilityTopic()),
entity->hasValue() ? kOnline : kOffline, true);
}
QString HaDevice::topic(const QString& relative) const {
return baseTopic() + QLatin1Char('/') + relative;
}
QString HaDevice::statusTopic() const {
return m_info.discoveryPrefix + QStringLiteral("/status");
}
void HaDevice::onConnectionChanged(MqttClient::State state) {
if (state != MqttClient::State::Connected) {
m_birthTimer.stop();
return;
}
m_client->subscribe(statusTopic());
publishAll();
}
void HaDevice::onMessage(const QString& topic, const QByteArray& payload) {
// HA (re)started: it may have lost what it knew. Spread the load of all
// devices answering at once (HA's documentation recommends a delay).
if (topic == statusTopic() && payload == kOnline)
m_birthTimer.start(int(QRandomGenerator::global()->bounded(
m_birthDelayMinMs, m_birthDelayMaxMs + 1)));
}
+68
View File
@@ -0,0 +1,68 @@
#pragma once
#include <QList>
#include <QObject>
#include <QString>
#include <QTimer>
#include "ha/HaEntity.h"
#include "mqtt/MqttClient.h"
class HaSensor;
// One HA device per headset (docs/DESIGN.md, D18, D31, D32): device-based
// discovery, availability, and the states of its entities over MQTT. Owns
// its entities.
//
// After each connect it subscribes to HA's status topic and publishes
// discovery, availability and all states; it does the same again a little
// later when HA announces that it (re)started.
class HaDevice : public QObject {
Q_OBJECT
public:
struct Info {
QString name; // shown in HA
QString id; // device id (D31)
QString discoveryPrefix = QStringLiteral("homeassistant");
QString softwareVersion;
};
// `client` must outlive the device.
HaDevice(Info info, MqttClient* client, QObject* parent = nullptr);
// The device's last will: its availability topic, "offline", retained.
// For the MQTT client's settings, which exist before the device.
static MqttClient::Will lastWill(const QString& deviceId);
HaSensor* addSensor(HaEntity::Description description);
const QList<HaEntity*>& entities() const { return m_entities; }
QString baseTopic() const; // frameassistant/<device id>
QString discoveryTopic() const;
QString availabilityTopic() const;
QByteArray discoveryPayload() const;
// Clears everything the device left on the broker (empty retained
// payloads): HA then deletes the device and its entities. Returns false if
// something couldn't be published (not connected).
bool removeFromHa();
// Republish after HA's birth message: random delay in [minMs, maxMs].
void setBirthDelay(int minMs, int maxMs);
private:
void addEntity(HaEntity* entity);
void publishAll();
void publishAvailability(const HaEntity* entity);
QString topic(const QString& relative) const;
QString statusTopic() const;
void onConnectionChanged(MqttClient::State state);
void onMessage(const QString& topic, const QByteArray& payload);
const Info m_info;
MqttClient* const m_client;
QList<HaEntity*> m_entities;
QTimer m_birthTimer;
int m_birthDelayMinMs = 1000;
int m_birthDelayMaxMs = 5000;
};
+82
View File
@@ -0,0 +1,82 @@
#include "ha/HaEntity.h"
#include <QJsonArray>
#include <utility>
HaEntity::HaEntity(QString platform, Description description, QObject* parent)
: QObject(parent),
m_platform(std::move(platform)),
m_description(std::move(description)) {
m_throttle.setSingleShot(true);
connect(&m_throttle, &QTimer::timeout, this, &HaEntity::emitState);
}
QJsonObject HaEntity::discoveryComponent(const QString& uniqueIdPrefix) const {
const Description& d = m_description;
// Abbreviated keys, as HA's MQTT discovery documents them.
QJsonObject component{
{QStringLiteral("p"), m_platform},
{QStringLiteral("name"), d.name},
{QStringLiteral("uniq_id"), uniqueIdPrefix + QLatin1Char('_') + d.id},
{QStringLiteral("stat_t"), QStringLiteral("~/") + stateTopic()},
// Available only while the device is online and the entity has a
// value (D32).
{QStringLiteral("avty"),
QJsonArray{
QJsonObject{{QStringLiteral("t"), QStringLiteral("~/availability")}},
QJsonObject{{QStringLiteral("t"),
QStringLiteral("~/") + availabilityTopic()}}}},
{QStringLiteral("avty_mode"), QStringLiteral("all")}};
if (!d.deviceClass.isEmpty())
component.insert(QStringLiteral("dev_cla"), d.deviceClass);
if (!d.unit.isEmpty())
component.insert(QStringLiteral("unit_of_meas"), d.unit);
if (!d.category.isEmpty())
component.insert(QStringLiteral("ent_cat"), d.category);
return component;
}
QString HaEntity::stateTopic() const {
return m_description.id + QStringLiteral("/state");
}
QString HaEntity::availabilityTopic() const {
return m_description.id + QStringLiteral("/availability");
}
void HaEntity::updateState(const QByteArray& state) {
const bool first = !m_hasValue;
m_state = state;
m_hasValue = true;
if (first) {
// A first value goes out at once, then the entity becomes available.
m_throttle.stop();
emitState();
emit availabilityChanged(true);
return;
}
if (m_throttle.isActive()) return; // emitState() picks up the newest one
const qint64 left = m_sinceEmit.isValid()
? m_description.throttleMs - m_sinceEmit.elapsed()
: 0;
if (left <= 0)
emitState();
else
m_throttle.start(int(left));
}
void HaEntity::clearState() {
if (!m_hasValue) return;
m_hasValue = false;
m_state.clear();
m_emittedState.clear();
m_throttle.stop();
emit availabilityChanged(false);
}
void HaEntity::emitState() {
if (!m_hasValue || m_state == m_emittedState) return;
m_emittedState = m_state;
m_sinceEmit.start();
emit stateChanged(m_state);
}
+74
View File
@@ -0,0 +1,74 @@
#pragma once
#include <QByteArray>
#include <QElapsedTimer>
#include <QJsonObject>
#include <QObject>
#include <QString>
#include <QTimer>
// One HA entity (docs/DESIGN.md, D23): the base class of every entity type.
// It describes the entity for discovery, holds its current state, throttles
// state changes (SEN-14) and knows whether it has a value (SEN-15: without
// one, HA shows it as unavailable).
//
// It knows nothing about MQTT: HaDevice publishes what it reports.
class HaEntity : public QObject {
Q_OBJECT
public:
struct Description {
// Topic level and suffix of the unique id, e.g. "uptime".
QString id;
// Name shown in HA, e.g. "Uptime".
QString name;
// Optional: HA device class, unit, entity category ("diagnostic", …).
QString deviceClass;
QString unit;
QString category;
// Minimum time between two published states (SEN-14).
int throttleMs = 1000;
};
// `platform`: the HA entity type, e.g. "sensor".
HaEntity(QString platform, Description description,
QObject* parent = nullptr);
const QString& platform() const { return m_platform; }
const Description& description() const { return m_description; }
// The component for device-based discovery; topics are relative to the
// device's base topic "~". `uniqueIdPrefix`: e.g.
// "frameassistant_<device id>".
virtual QJsonObject discoveryComponent(const QString& uniqueIdPrefix) const;
// Topics relative to the device's base topic.
QString stateTopic() const;
QString availabilityTopic() const;
bool hasValue() const { return m_hasValue; }
// The state as published; empty without a value.
QByteArray state() const { return m_hasValue ? m_state : QByteArray(); }
signals:
// A new state to publish, at most once per throttle interval (the newest
// one wins). Emitted before availabilityChanged(true) for a first value.
void stateChanged(const QByteArray& state);
void availabilityChanged(bool available);
protected:
// For the concrete types: set or clear the state.
void updateState(const QByteArray& state);
void clearState();
private:
void emitState();
const QString m_platform;
const Description m_description;
QByteArray m_state;
bool m_hasValue = false;
// The state last emitted, and when.
QByteArray m_emittedState;
QElapsedTimer m_sinceEmit;
QTimer m_throttle;
};
+12
View File
@@ -0,0 +1,12 @@
#include "ha/HaSensor.h"
#include <utility>
HaSensor::HaSensor(Description description, QObject* parent)
: HaEntity(QStringLiteral("sensor"), std::move(description), parent) {}
void HaSensor::setValue(const QVariant& value) {
updateState(value.toString().toUtf8());
}
void HaSensor::clearValue() { clearState(); }
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include <QVariant>
#include "ha/HaEntity.h"
// An HA `sensor`: a value with optional unit and device class.
class HaSensor : public HaEntity {
Q_OBJECT
public:
explicit HaSensor(Description description, QObject* parent = nullptr);
// Published as text (QVariant::toString()).
void setValue(const QVariant& value);
// No value: HA shows the sensor as unavailable (SEN-15).
void clearValue();
};
+318
View File
@@ -0,0 +1,318 @@
#include "mqtt/MosquittoClient.h"
#include <mosquitto.h>
#include <signal.h>
#include <QDebug>
#include <QFileInfo>
#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);
// 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(); });
}
// 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 withoutPeriod(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);
mosquitto_log_callback_set(m_mosq, &MosquittoClient::onLog);
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)
m_initError = errorText(rc, errno);
else if (broker.tls.enabled)
m_initError = setUpTls();
}
QString MosquittoClient::setUpTls() {
const MqttTlsConfig& tls = m_settings.broker.tls;
for (const QString& file : {tls.caFile, tls.certFile, tls.keyFile}) {
if (!file.isEmpty() && !QFileInfo(file).isReadable())
return QStringLiteral("TLS: cannot read %1").arg(file);
}
if (tls.certFile.isEmpty() != tls.keyFile.isEmpty())
return QStringLiteral("TLS: cert_file and key_file go together");
if (tls.caFile.isEmpty()) {
// The system's CA certificates. mosquitto_tls_set() insists on a CA
// file or directory, so this option alone enables TLS, and a client
// certificate can't be set without ca_file.
if (!tls.certFile.isEmpty())
return QStringLiteral("TLS: a client certificate needs ca_file");
const int rc = mosquitto_int_option(m_mosq, MOSQ_OPT_TLS_USE_OS_CERTS, 1);
return rc == MOSQ_ERR_SUCCESS
? QString()
: QStringLiteral("TLS: %1").arg(errorText(rc, errno));
}
const QByteArray ca = QFile::encodeName(tls.caFile);
const QByteArray cert = QFile::encodeName(tls.certFile);
const QByteArray key = QFile::encodeName(tls.keyFile);
// Verifies the broker's certificate and host name (libmosquitto's
// default).
const int rc =
mosquitto_tls_set(m_mosq, ca.constData(), nullptr,
cert.isEmpty() ? nullptr : cert.constData(),
key.isEmpty() ? nullptr : key.constData(), nullptr);
return rc == MOSQ_ERR_SUCCESS
? QString()
: QStringLiteral("TLS: %1").arg(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() {
// With TLS, OpenSSL writes with write(), not send(MSG_NOSIGNAL): writing
// to a connection the broker closed raises SIGPIPE, which would end the
// whole process. Blocked in this thread, the write fails with EPIPE
// instead. Only this thread writes to the socket (threaded mode).
sigset_t pipe;
sigemptyset(&pipe);
sigaddset(&pipe, SIGPIPE);
pthread_sigmask(SIG_BLOCK, &pipe, nullptr);
const QByteArray host = m_settings.broker.host.toUtf8();
int delayMs = m_settings.reconnectMinMs;
while (!m_stop) {
m_lastError.clear();
m_lastLogError.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;
QString error = m_lastError.isEmpty() ? errorText(rc, err) : m_lastError;
// libmosquitto logs the reason of a failed TLS handshake (e.g.
// "certificate verify failed"), but returns only "TLS error" (2.0) or
// "Protocol error" (2.1).
if (!m_lastLogError.isEmpty())
error += QStringLiteral(": ") + m_lastLogError;
setState(State::Disconnected, error);
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 = withoutPeriod(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));
}
void MosquittoClient::onLog(mosquitto*, void* self, int level,
const char* text) {
if (!(level & MOSQ_LOG_ERR)) return;
// E.g. "OpenSSL Error[0]: error:0A000086:SSL routines::certificate verify
// failed": keep the reason at the end.
QString line = QString::fromUtf8(text);
const qsizetype colon = line.lastIndexOf(QLatin1Char(':'));
if (colon >= 0) line = line.mid(colon + 1).trimmed();
static_cast<MosquittoClient*>(self)->m_lastLogError = line;
}
+86
View File
@@ -0,0 +1,86 @@
#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:
// Sets up TLS from the settings; returns an error text or nothing.
QString setUpTls();
// 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);
static void onLog(mosquitto* mosq, void* self, int level, const char* text);
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;
// libmosquitto's last error log line of this attempt (our thread only):
// the details of a TLS error.
QString m_lastLogError;
// 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);
};
+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 }
}
+20
View File
@@ -33,6 +33,11 @@ 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_command COMMAND tst_surfaces)
set_tests_properties(surfaces_command PROPERTIES ENVIRONMENT "VRDASH_TEST_COMMAND=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)
@@ -40,3 +45,18 @@ set_tests_properties(surfaces_opengl surfaces_vulkan surfaces_cli_key
qt_add_executable(tst_config tst_config.cpp)
target_link_libraries(tst_config PRIVATE frameassistant_lib Qt6::Test)
add_test(NAME config COMMAND tst_config)
# HA entities and the HA device (D23, D31, D32); the device with a fake MQTT client.
qt_add_executable(tst_haentity tst_haentity.cpp)
target_link_libraries(tst_haentity PRIVATE frameassistant_lib Qt6::Test)
add_test(NAME haentity COMMAND tst_haentity)
qt_add_executable(tst_hadevice tst_hadevice.cpp FakeMqttClient.h)
target_link_libraries(tst_hadevice PRIVATE frameassistant_lib Qt6::Test)
add_test(NAME hadevice COMMAND tst_hadevice)
# 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)
# TLS test certificates (D44).
target_compile_definitions(tst_mqtt PRIVATE TEST_DATA_DIR="${CMAKE_CURRENT_SOURCE_DIR}/data")
add_test(NAME mqtt COMMAND tst_mqtt)
+62
View File
@@ -0,0 +1,62 @@
#pragma once
#include <QList>
#include <QStringList>
#include "mqtt/MqttClient.h"
// MqttClient for unit tests: records what is published and subscribed;
// the test drives the connection state and incoming messages.
class FakeMqttClient : public MqttClient {
Q_OBJECT
public:
struct Message {
QString topic;
QByteArray payload;
bool retain = false;
};
using MqttClient::MqttClient;
void connectToBroker() override { ++connectCalls; }
bool disconnectFromBroker(int) override {
setState(State::Disconnected);
return true;
}
bool publish(const QString& topic, const QByteArray& payload,
bool retain) override {
if (m_state != State::Connected) return false;
published.append({topic, payload, retain});
return true;
}
bool subscribe(const QString& topic) override {
if (m_state != State::Connected) return false;
subscriptions.append(topic);
return true;
}
bool waitForPublished(int) override { return true; }
State state() const override { return m_state; }
// Test side.
void setState(State state, const QString& error = {}) {
m_state = state;
emit connectionChanged(state, error);
}
void deliver(const QString& topic, const QByteArray& payload) {
emit messageReceived(topic, payload);
}
// The messages published to `topic`, oldest first.
QList<Message> to(const QString& topic) const {
QList<Message> result;
for (const Message& message : published)
if (message.topic == topic) result.append(message);
return result;
}
QList<Message> published;
QStringList subscriptions;
int connectCalls = 0;
private:
State m_state = State::Disconnected;
};
+29
View File
@@ -0,0 +1,29 @@
# Test certificates
For the TLS cases in `tst_mqtt` (docs/DESIGN.md, D44). EC P-256, valid until 2126. Test use
only: the private keys are public.
| File | What |
|---|---|
| `ca.crt` | Test CA (its key was deleted after signing) |
| `server.crt`, `server.key` | Broker certificate from the test CA, for `localhost` and `127.0.0.1` |
| `client.crt`, `client.key` | Client certificate from the test CA |
| `other-ca.crt` | A second CA that signed nothing here: the wrong CA |
Made with:
```sh
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
-keyout ca.key -out ca.crt -days 36500 -subj "/CN=FrameAssistant test CA"
openssl req -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
-keyout server.key -out server.csr -subj "/CN=localhost"
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt \
-days 36500 -extfile <(printf "subjectAltName=DNS:localhost,IP:127.0.0.1\nextendedKeyUsage=serverAuth")
openssl req -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
-keyout client.key -out client.csr -subj "/CN=frameassistant-test-client"
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out client.crt \
-days 36500 -extfile <(printf "extendedKeyUsage=clientAuth")
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
-keyout other-ca.key -out other-ca.crt -days 36500 -subj "/CN=Other test CA"
rm server.csr client.csr ca.srl ca.key other-ca.key
```
+11
View File
@@ -0,0 +1,11 @@
-----BEGIN CERTIFICATE-----
MIIBmTCCAT+gAwIBAgIUDYqH6JFwdNkUywBv5SyHxuuFc3owCgYIKoZIzj0EAwIw
ITEfMB0GA1UEAwwWRnJhbWVBc3Npc3RhbnQgdGVzdCBDQTAgFw0yNjEwMDYxNDE4
NDBaGA8yMTI2MDkxMjE0MTg0MFowITEfMB0GA1UEAwwWRnJhbWVBc3Npc3RhbnQg
dGVzdCBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABEYTVW0Vs72geJmDYYTO
qCxisH73KxOpqQIFd8dZguzP3Ymu64T1xi6/7BbnPmtvKDP3KFDb7EzHTFQ9l32s
qWajUzBRMB0GA1UdDgQWBBRLda21NY4WNcdtMQUU20dpFjzLxTAfBgNVHSMEGDAW
gBRLda21NY4WNcdtMQUU20dpFjzLxTAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49
BAMCA0gAMEUCIQCcWQf7cejgQw+xzZ/oDgUVSQ2s73wZ/N+b/XxD2goUswIgS/Y5
HeqcBO4HHqOVLbwRMhCWYbQlAhiLpaX26wyzIyM=
-----END CERTIFICATE-----
+11
View File
@@ -0,0 +1,11 @@
-----BEGIN CERTIFICATE-----
MIIBojCCAUegAwIBAgIUMl5Z4QsUdsD5cj2ZNcEWB1L4awAwCgYIKoZIzj0EAwIw
ITEfMB0GA1UEAwwWRnJhbWVBc3Npc3RhbnQgdGVzdCBDQTAgFw0yNjEwMDYxNDE4
NDBaGA8yMTI2MDkxMjE0MTg0MFowJTEjMCEGA1UEAwwaZnJhbWVhc3Npc3RhbnQt
dGVzdC1jbGllbnQwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQXzJWn1Vn4oghl
m077IKOEEX3VBFDMP7IGVBR6V19oHGgCbth9gEQXqPMr+8LYtK3UWYhk/VuFVP3X
abqlipIpo1cwVTATBgNVHSUEDDAKBggrBgEFBQcDAjAdBgNVHQ4EFgQUQjEJ7EHL
s4y78P14VB3keg8WCFIwHwYDVR0jBBgwFoAUS3WttTWOFjXHbTEFFNtHaRY8y8Uw
CgYIKoZIzj0EAwIDSQAwRgIhAJH+jXs/7SovwLZfT0BhlteFfCyhXuz6e39FbRjk
dkGmAiEA2IpZZlW+T3r71sHE+CJe8RpHdjDPjmij2nctps8Pdm4=
-----END CERTIFICATE-----
+5
View File
@@ -0,0 +1,5 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQggKrDK1CB3lOzi23P
XkvdV45GpZG4YMrAH0BVaB24liGhRANCAAQXzJWn1Vn4oghlm077IKOEEX3VBFDM
P7IGVBR6V19oHGgCbth9gEQXqPMr+8LYtK3UWYhk/VuFVP3XabqlipIp
-----END PRIVATE KEY-----
+11
View File
@@ -0,0 +1,11 @@
-----BEGIN CERTIFICATE-----
MIIBhzCCAS2gAwIBAgIUD73YYi99elTmyJ3drpvRqNus3McwCgYIKoZIzj0EAwIw
GDEWMBQGA1UEAwwNT3RoZXIgdGVzdCBDQTAgFw0yNjEwMDYxNDE4NDBaGA8yMTI2
MDkxMjE0MTg0MFowGDEWMBQGA1UEAwwNT3RoZXIgdGVzdCBDQTBZMBMGByqGSM49
AgEGCCqGSM49AwEHA0IABP6HT0Ey1ohM72ykQVIiNpd215blibaafbhlNbyyU/uD
4BCCuEWKkDF1wQwrLH/O+N8gjVUpymd8/dK9zGzjGPKjUzBRMB0GA1UdDgQWBBTB
dmiTSLe4jI9TKsp/+fFk/GoXWDAfBgNVHSMEGDAWgBTBdmiTSLe4jI9TKsp/+fFk
/GoXWDAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0gAMEUCIQCZzxaVM3+1
OcS0fovnWedcs2s4QRAQ/PGzr1BGt5wxVwIgGxzzOfsh7yz8QveA0oUkPkOJLsOq
K739i67tvYbRNl4=
-----END CERTIFICATE-----
+11
View File
@@ -0,0 +1,11 @@
-----BEGIN CERTIFICATE-----
MIIBrDCCAVKgAwIBAgIUMl5Z4QsUdsD5cj2ZNcEWB1L4av8wCgYIKoZIzj0EAwIw
ITEfMB0GA1UEAwwWRnJhbWVBc3Npc3RhbnQgdGVzdCBDQTAgFw0yNjEwMDYxNDE4
NDBaGA8yMTI2MDkxMjE0MTg0MFowFDESMBAGA1UEAwwJbG9jYWxob3N0MFkwEwYH
KoZIzj0CAQYIKoZIzj0DAQcDQgAEkIJRd0MoLtpP0X755ZbjYvV0Vp2rDECQZyC6
4bbwufvk2vm1+JUBusnnnSIfqRU5S3wH6pzSyWxZiuc1TXscrqNzMHEwGgYDVR0R
BBMwEYIJbG9jYWxob3N0hwR/AAABMBMGA1UdJQQMMAoGCCsGAQUFBwMBMB0GA1Ud
DgQWBBR7nqpN0AFa5jGDs9gq3vK3OvxpSTAfBgNVHSMEGDAWgBRLda21NY4WNcdt
MQUU20dpFjzLxTAKBggqhkjOPQQDAgNIADBFAiAwIGSpsMJmq3ExrtLq3PpNWbe/
HMnp7V7rmS8K8uWJpwIhAI8osCchJeKH7xg4yqfZXBxrU1FwagojEGQDk3HKEoIe
-----END CERTIFICATE-----
+5
View File
@@ -0,0 +1,5 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgP5sIs6digdS0/HXU
a8G16oZk3HZwfYHTjnAIm0APWpuhRANCAASQglF3Qygu2k/RfvnlluNi9XRWnasM
QJBnILrhtvC5++Ta+bX4lQG6yeedIh+pFTlLfAfqnNLJbFmK5zVNexyu
-----END PRIVATE KEY-----
+236
View File
@@ -0,0 +1,236 @@
// HaDevice with a fake MQTT client (docs/DESIGN.md, D31, D32): discovery
// payload, what is published after a connect, reconnect, HA's birth message,
// last will, removal.
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QTest>
#include "FakeMqttClient.h"
#include "ha/HaDevice.h"
#include "ha/HaSensor.h"
namespace {
using State = MqttClient::State;
HaDevice::Info info() {
return {.name = QStringLiteral("Wohnzimmer Frame"),
.id = QStringLiteral("wohnzimmer_frame"),
.softwareVersion = QStringLiteral("1.2.3")};
}
HaEntity::Description uptime() {
return {.id = QStringLiteral("uptime"),
.name = QStringLiteral("Uptime"),
.unit = QStringLiteral("s"),
.throttleMs = 0};
}
const QString kBase = QStringLiteral("frameassistant/wohnzimmer_frame");
const QString kDiscovery =
QStringLiteral("homeassistant/device/wohnzimmer_frame/config");
} // namespace
class TestHaDevice : public QObject {
Q_OBJECT
private slots:
void topics() {
FakeMqttClient client;
const HaDevice device(info(), &client);
QCOMPARE(device.baseTopic(), kBase);
QCOMPARE(device.discoveryTopic(), kDiscovery);
QCOMPARE(device.availabilityTopic(), kBase + "/availability");
}
void discoveryPrefixFromInfo() {
FakeMqttClient client;
HaDevice::Info i = info();
i.discoveryPrefix = QStringLiteral("ha");
HaDevice device(i, &client);
QCOMPARE(device.discoveryTopic(),
QStringLiteral("ha/device/wohnzimmer_frame/config"));
client.setState(State::Connected);
QCOMPARE(client.subscriptions, QStringList{QStringLiteral("ha/status")});
}
void lastWill() {
const MqttClient::Will will =
HaDevice::lastWill(QStringLiteral("wohnzimmer_frame"));
QCOMPARE(will.topic, kBase + "/availability");
QCOMPARE(will.payload, QByteArray("offline"));
QVERIFY(will.retain);
}
void discoveryPayload() {
FakeMqttClient client;
HaDevice device(info(), &client);
device.addSensor(uptime());
const QJsonObject payload =
QJsonDocument::fromJson(device.discoveryPayload()).object();
const QJsonObject dev = payload.value(QStringLiteral("dev")).toObject();
QCOMPARE(dev.value(QStringLiteral("ids")).toArray(),
QJsonArray{QStringLiteral("frameassistant_wohnzimmer_frame")});
QCOMPARE(dev.value(QStringLiteral("name")).toString(),
QStringLiteral("Wohnzimmer Frame"));
QCOMPARE(dev.value(QStringLiteral("sw")).toString(),
QStringLiteral("1.2.3"));
const QJsonObject origin = payload.value(QStringLiteral("o")).toObject();
QCOMPARE(origin.value(QStringLiteral("name")).toString(),
QStringLiteral("FrameAssistant"));
QCOMPARE(payload.value(QStringLiteral("~")).toString(), kBase);
QCOMPARE(payload.value(QStringLiteral("qos")).toInt(), 1);
const QJsonObject components =
payload.value(QStringLiteral("cmps")).toObject();
QCOMPARE(components.keys(), QStringList{QStringLiteral("uptime")});
QCOMPARE(components.value(QStringLiteral("uptime"))
.toObject()
.value(QStringLiteral("uniq_id"))
.toString(),
QStringLiteral("frameassistant_wohnzimmer_frame_uptime"));
}
// After a connect: subscription, then discovery, device availability,
// state and entity availability, all retained.
void publishesAllAfterConnect() {
FakeMqttClient client;
HaDevice device(info(), &client);
device.addSensor(uptime())->setValue(7);
QVERIFY(client.published.isEmpty()); // not connected yet
client.setState(State::Connected);
QCOMPARE(client.subscriptions,
QStringList{QStringLiteral("homeassistant/status")});
QStringList topics;
for (const FakeMqttClient::Message& m : client.published) {
topics.append(m.topic);
QVERIFY2(m.retain, qPrintable(m.topic));
}
QCOMPARE(topics, (QStringList{kDiscovery, kBase + "/availability",
kBase + "/uptime/state",
kBase + "/uptime/availability"}));
QCOMPARE(client.published[0].payload, device.discoveryPayload());
QCOMPARE(client.published[1].payload, QByteArray("online"));
QCOMPARE(client.published[2].payload, QByteArray("7"));
QCOMPARE(client.published[3].payload, QByteArray("online"));
}
// SEN-15: an entity without a value is published as unavailable.
void entityWithoutValueUnavailable() {
FakeMqttClient client;
HaDevice device(info(), &client);
device.addSensor(uptime());
client.setState(State::Connected);
QVERIFY(client.to(kBase + "/uptime/state").isEmpty());
const QList<FakeMqttClient::Message> availability =
client.to(kBase + "/uptime/availability");
QCOMPARE(availability.size(), 1);
QCOMPARE(availability.first().payload, QByteArray("offline"));
}
void valueChangesPublishedWhileConnected() {
FakeMqttClient client;
HaDevice device(info(), &client);
HaSensor* sensor = device.addSensor(uptime());
client.setState(State::Connected);
client.published.clear();
sensor->setValue(1);
QCOMPARE(client.published.size(), 2);
QCOMPARE(client.published[0].topic, kBase + "/uptime/state");
QCOMPARE(client.published[0].payload, QByteArray("1"));
QVERIFY(client.published[0].retain);
QCOMPARE(client.published[1].topic, kBase + "/uptime/availability");
QCOMPARE(client.published[1].payload, QByteArray("online"));
sensor->setValue(2);
QCOMPARE(client.published.size(), 3);
QCOMPARE(client.published[2].payload, QByteArray("2"));
sensor->clearValue();
QCOMPARE(client.published.size(), 4);
QCOMPARE(client.published[3].topic, kBase + "/uptime/availability");
QCOMPARE(client.published[3].payload, QByteArray("offline"));
}
// CON-4: nothing is buffered; after a reconnect everything is current.
void reconnectRepublishesCurrentState() {
FakeMqttClient client;
HaDevice device(info(), &client);
HaSensor* sensor = device.addSensor(uptime());
sensor->setValue(1);
client.setState(State::Connected);
client.setState(State::Disconnected, QStringLiteral("lost"));
client.published.clear();
client.subscriptions.clear();
sensor->setValue(2);
sensor->setValue(3);
QVERIFY(client.published.isEmpty());
client.setState(State::Connected);
QCOMPARE(client.subscriptions.size(), 1);
const QList<FakeMqttClient::Message> states =
client.to(kBase + "/uptime/state");
QCOMPARE(states.size(), 1);
QCOMPARE(states.first().payload, QByteArray("3"));
QCOMPARE(client.to(kDiscovery).size(), 1);
}
void birthMessageRepublishesAfterDelay() {
FakeMqttClient client;
HaDevice device(info(), &client);
device.setBirthDelay(50, 100);
device.addSensor(uptime())->setValue(1);
client.setState(State::Connected);
client.published.clear();
client.deliver(QStringLiteral("homeassistant/status"), "online");
QVERIFY(client.published.isEmpty()); // not at once
QTRY_COMPARE_WITH_TIMEOUT(client.to(kDiscovery).size(), 1, 2000);
QCOMPARE(client.to(kBase + "/uptime/state").size(), 1);
}
void otherMessagesIgnored() {
FakeMqttClient client;
HaDevice device(info(), &client);
device.setBirthDelay(0, 0);
client.setState(State::Connected);
client.published.clear();
client.deliver(QStringLiteral("homeassistant/status"), "offline");
client.deliver(QStringLiteral("other/status"), "online");
QTest::qWait(50);
QVERIFY(client.published.isEmpty());
}
void disconnectCancelsPendingBirthRepublish() {
FakeMqttClient client;
HaDevice device(info(), &client);
device.setBirthDelay(50, 50);
client.setState(State::Connected);
client.deliver(QStringLiteral("homeassistant/status"), "online");
client.setState(State::Disconnected);
client.published.clear();
QTest::qWait(150);
QVERIFY(client.published.isEmpty());
}
// --ha-remove: empty retained payloads on every topic, discovery first.
void removeFromHa() {
FakeMqttClient client;
HaDevice device(info(), &client);
device.addSensor(uptime());
QVERIFY(!device.removeFromHa()); // not connected
client.setState(State::Connected);
client.published.clear();
QVERIFY(device.removeFromHa());
QStringList topics;
for (const FakeMqttClient::Message& m : client.published) {
topics.append(m.topic);
QVERIFY(m.payload.isEmpty());
QVERIFY(m.retain);
}
QCOMPARE(topics, (QStringList{kDiscovery, kBase + "/availability",
kBase + "/uptime/state",
kBase + "/uptime/availability"}));
}
};
QTEST_GUILESS_MAIN(TestHaDevice)
#include "tst_hadevice.moc"
+150
View File
@@ -0,0 +1,150 @@
// HaEntity/HaSensor (docs/DESIGN.md, D23): discovery component, topics,
// throttling (SEN-14), availability without a value (SEN-15).
#include <QJsonArray>
#include <QSignalSpy>
#include <QTest>
#include "ha/HaSensor.h"
namespace {
HaEntity::Description uptime(int throttleMs = 1000) {
return {.id = QStringLiteral("uptime"),
.name = QStringLiteral("Uptime"),
.deviceClass = QStringLiteral("duration"),
.unit = QStringLiteral("s"),
.category = QStringLiteral("diagnostic"),
.throttleMs = throttleMs};
}
} // namespace
class TestHaEntity : public QObject {
Q_OBJECT
private slots:
void discoveryComponent() {
const HaSensor sensor(uptime());
const QJsonObject c = sensor.discoveryComponent(QStringLiteral("fa_dev"));
QCOMPARE(c.value(QStringLiteral("p")).toString(), QStringLiteral("sensor"));
QCOMPARE(c.value(QStringLiteral("name")).toString(),
QStringLiteral("Uptime"));
QCOMPARE(c.value(QStringLiteral("uniq_id")).toString(),
QStringLiteral("fa_dev_uptime"));
QCOMPARE(c.value(QStringLiteral("dev_cla")).toString(),
QStringLiteral("duration"));
QCOMPARE(c.value(QStringLiteral("unit_of_meas")).toString(),
QStringLiteral("s"));
QCOMPARE(c.value(QStringLiteral("ent_cat")).toString(),
QStringLiteral("diagnostic"));
QCOMPARE(c.value(QStringLiteral("stat_t")).toString(),
QStringLiteral("~/uptime/state"));
const QJsonArray availability = c.value(QStringLiteral("avty")).toArray();
QCOMPARE(availability.size(), 2);
QCOMPARE(availability[0].toObject().value(QStringLiteral("t")).toString(),
QStringLiteral("~/availability"));
QCOMPARE(availability[1].toObject().value(QStringLiteral("t")).toString(),
QStringLiteral("~/uptime/availability"));
QCOMPARE(c.value(QStringLiteral("avty_mode")).toString(),
QStringLiteral("all"));
}
void optionalKeysOnlyWhenSet() {
const HaSensor sensor(
{.id = QStringLiteral("x"), .name = QStringLiteral("X")});
const QJsonObject c = sensor.discoveryComponent(QStringLiteral("fa_dev"));
QVERIFY(!c.contains(QStringLiteral("dev_cla")));
QVERIFY(!c.contains(QStringLiteral("unit_of_meas")));
QVERIFY(!c.contains(QStringLiteral("ent_cat")));
}
void topics() {
const HaSensor sensor(uptime());
QCOMPARE(sensor.stateTopic(), QStringLiteral("uptime/state"));
QCOMPARE(sensor.availabilityTopic(), QStringLiteral("uptime/availability"));
}
// SEN-15: no value → unavailable; the first value → state, then available.
void firstValueMakesAvailable() {
HaSensor sensor(uptime());
QVERIFY(!sensor.hasValue());
QVERIFY(sensor.state().isEmpty());
QList<QString> events;
connect(&sensor, &HaEntity::stateChanged, this,
[&](const QByteArray& s) { events.append("state " + s); });
connect(&sensor, &HaEntity::availabilityChanged, this,
[&](bool a) { events.append(a ? "available" : "unavailable"); });
sensor.setValue(42);
QCOMPARE(events, (QList<QString>{"state 42", "available"}));
QVERIFY(sensor.hasValue());
QCOMPARE(sensor.state(), QByteArray("42"));
}
void clearValueMakesUnavailable() {
HaSensor sensor(uptime());
sensor.setValue(1);
QSignalSpy availability(&sensor, &HaEntity::availabilityChanged);
QSignalSpy states(&sensor, &HaEntity::stateChanged);
sensor.clearValue();
QCOMPARE(availability.size(), 1);
QCOMPARE(availability.first().first().toBool(), false);
QVERIFY(!sensor.hasValue());
sensor.clearValue(); // no change, no signal
QCOMPARE(availability.size(), 1);
// The same value as before the gap is a new first value.
sensor.setValue(1);
QCOMPARE(states.size(), 1);
QCOMPARE(availability.size(), 2);
QCOMPARE(availability.last().first().toBool(), true);
}
void unchangedValueNotEmitted() {
HaSensor sensor(uptime(0));
QSignalSpy states(&sensor, &HaEntity::stateChanged);
sensor.setValue(5);
sensor.setValue(5);
QCOMPARE(states.size(), 1);
}
// SEN-14: changes within the interval → one state, the newest, at its end.
void throttlesChanges() {
HaSensor sensor(uptime(300));
QSignalSpy states(&sensor, &HaEntity::stateChanged);
sensor.setValue(1);
QCOMPARE(states.size(), 1);
sensor.setValue(2);
sensor.setValue(3);
QCOMPARE(states.size(), 1);
QTest::qWait(100);
QCOMPARE(states.size(), 1);
QTRY_COMPARE_WITH_TIMEOUT(states.size(), 2, 1000);
QCOMPARE(states.last().first().toByteArray(), QByteArray("3"));
// Nothing more pending.
QTest::qWait(400);
QCOMPARE(states.size(), 2);
}
void changeAfterIntervalEmittedAtOnce() {
HaSensor sensor(uptime(100));
QSignalSpy states(&sensor, &HaEntity::stateChanged);
sensor.setValue(1);
QTest::qWait(150);
sensor.setValue(2);
QCOMPARE(states.size(), 2);
}
// Back to the emitted value within the interval: nothing to publish.
void revertWithinIntervalNotEmitted() {
HaSensor sensor(uptime(200));
QSignalSpy states(&sensor, &HaEntity::stateChanged);
sensor.setValue(1);
sensor.setValue(2);
sensor.setValue(1);
QTest::qWait(400);
QCOMPARE(states.size(), 1);
}
};
QTEST_GUILESS_MAIN(TestHaEntity)
#include "tst_haentity.moc"
+508
View File
@@ -0,0 +1,508 @@
// 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, TLS (certificates in tests/data/, D44). 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 data(const char* file) {
return QStringLiteral(TEST_DATA_DIR "/") + QLatin1String(file);
}
// Broker configuration for a TLS listener with the test CA's server
// certificate.
QString tlsListener(bool requireClientCertificate = false) {
return QStringLiteral(
"allow_anonymous true\ncafile %1\ncertfile %2\nkeyfile %3\n"
"require_certificate %4\n")
.arg(data("ca.crt"), data("server.crt"), data("server.key"),
requireClientCertificate ? QStringLiteral("true")
: QStringLiteral("false"));
}
MosquittoClient::Settings tlsSettings(int port, const char* caFile) {
MosquittoClient::Settings s;
s.broker.host = QStringLiteral("127.0.0.1");
s.broker.port = port;
s.broker.tls.enabled = true;
if (caFile) s.broker.tls.caFile = data(caFile);
s.clientId = QStringLiteral("tls");
s.reconnectMinMs = 100;
s.reconnectMaxMs = 500;
return s;
}
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));
}
}
void tlsConnects() {
Broker broker(m_dir.path());
QVERIFY(broker.start(tlsListener()));
MosquittoClient a(tlsSettings(broker.port(), "ca.crt"));
Recorder ra(&a);
QVERIFY(connectClient(a, ra, QStringLiteral("test/#")));
QVERIFY(a.publish(QStringLiteral("test/x"), "secret", false));
QTRY_COMPARE_WITH_TIMEOUT(ra.messages.size(), 1, kTimeoutMs);
QVERIFY(ra.errors.isEmpty());
}
// The broker's certificate isn't from the given CA: no connection, and
// the reason says why.
void tlsWrongCaRejected() {
Broker broker(m_dir.path());
QVERIFY(broker.start(tlsListener()));
MosquittoClient a(tlsSettings(broker.port(), "other-ca.crt"));
Recorder ra(&a);
a.connectToBroker();
QTRY_VERIFY_WITH_TIMEOUT(!ra.errors.isEmpty(), kTimeoutMs);
QVERIFY2(ra.errors.first().contains(QStringLiteral("verify failed")),
qPrintable(ra.errors.first()));
QTest::qWait(300);
QVERIFY(!ra.states.contains(MqttClient::State::Connected));
}
// Without ca_file the system's CAs are used (they don't know the test
// CA). Fails the same way, which shows that TLS is on.
void tlsWithSystemCertificates() {
Broker broker(m_dir.path());
QVERIFY(broker.start(tlsListener()));
MosquittoClient a(tlsSettings(broker.port(), nullptr));
Recorder ra(&a);
a.connectToBroker();
QTRY_VERIFY_WITH_TIMEOUT(!ra.errors.isEmpty(), kTimeoutMs);
QVERIFY2(ra.errors.first().contains(QStringLiteral("verify failed")),
qPrintable(ra.errors.first()));
}
// A TLS error ends libmosquitto's own loop for good (D45); ours retries.
// First a plain listener on the port (handshake fails), then TLS.
void retriesAfterTlsError() {
Broker broker(m_dir.path());
QVERIFY(broker.start());
MosquittoClient a(tlsSettings(broker.port(), "ca.crt"));
Recorder ra(&a);
a.connectToBroker();
QTRY_VERIFY_WITH_TIMEOUT(!ra.errors.isEmpty(), kTimeoutMs);
broker.stop();
QVERIFY(broker.start(tlsListener()));
QTRY_COMPARE_WITH_TIMEOUT(a.state(), MqttClient::State::Connected,
kTimeoutMs);
}
void tlsClientCertificate_data() {
QTest::addColumn<bool>("withCertificate");
QTest::newRow("with") << true;
QTest::newRow("without") << false;
}
void tlsClientCertificate() {
QFETCH(bool, withCertificate);
Broker broker(m_dir.path());
QVERIFY(broker.start(tlsListener(true)));
MosquittoClient::Settings s = tlsSettings(broker.port(), "ca.crt");
if (withCertificate) {
s.broker.tls.certFile = data("client.crt");
s.broker.tls.keyFile = data("client.key");
}
MosquittoClient a(s);
Recorder ra(&a);
a.connectToBroker();
if (withCertificate) {
QTRY_COMPARE_WITH_TIMEOUT(a.state(), MqttClient::State::Connected,
kTimeoutMs);
} else {
QTRY_VERIFY_WITH_TIMEOUT(!ra.errors.isEmpty(), kTimeoutMs);
QVERIFY(!ra.states.contains(MqttClient::State::Connected));
}
}
// Settings errors show up at once, without a connection attempt.
void tlsSettingsErrors_data() {
QTest::addColumn<QString>("caFile");
QTest::addColumn<QString>("certFile");
QTest::addColumn<QString>("keyFile");
QTest::addColumn<QString>("error");
QTest::newRow("missing CA file")
<< QStringLiteral("/no/such/ca.crt") << QString() << QString()
<< QStringLiteral("TLS: cannot read /no/such/ca.crt");
QTest::newRow("certificate without key")
<< data("ca.crt") << data("client.crt") << QString()
<< QStringLiteral("TLS: cert_file and key_file go together");
QTest::newRow("client certificate without CA file")
<< QString() << data("client.crt") << data("client.key")
<< QStringLiteral("TLS: a client certificate needs ca_file");
}
void tlsSettingsErrors() {
QFETCH(QString, caFile);
QFETCH(QString, certFile);
QFETCH(QString, keyFile);
QFETCH(QString, error);
MosquittoClient::Settings s = tlsSettings(freePort(), nullptr);
s.broker.tls.caFile = caFile;
s.broker.tls.certFile = certFile;
s.broker.tls.keyFile = keyFile;
MosquittoClient a(s);
Recorder ra(&a);
a.connectToBroker();
QTRY_COMPARE_WITH_TIMEOUT(ra.errors.size(), 1, kTimeoutMs);
QCOMPARE(ra.errors.first(), error);
QCOMPARE(a.state(), MqttClient::State::Disconnected);
}
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"
+47 -5
View File
@@ -4,10 +4,16 @@
// 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).
// VRDASH_TEST_COMMAND=1: the app command --test-command instead of
// --snapshot: prepare(), then runCommand() without GUI, engine or surfaces;
// its result is exec()'s.
// Exit codes: 0 pass, 1 fail, 77 skipped (no GPU context).
#include <QCommandLineOption>
#include <QDir>
#include <QGuiApplication>
#include <QImage>
#include <QTemporaryDir>
#include <cstdio>
@@ -32,7 +38,9 @@ class TwoSurfaceApp : public DashboardApplication {
QStringLiteral("An app option."),
QStringLiteral("value")),
QCommandLineOption(QStringLiteral("test-flag"),
QStringLiteral("An app flag, never set."))}) {
QStringLiteral("An app flag, never set."))},
{QCommandLineOption(QStringLiteral("test-command"),
QStringLiteral("An app command."))}) {
setQmlModule(QStringLiteral("SurfacesTest"));
setDisplayName(QStringLiteral("Test App"));
setAppKey(QStringLiteral("config.key"));
@@ -46,10 +54,12 @@ class TwoSurfaceApp : public DashboardApplication {
}
QString expectedKey; // --app-key overrides setAppKey()
DashboardMode expectedMode = DashboardMode::Snapshot;
bool failPrepare = false;
int prepareCalls = 0;
int loadingCalls = 0;
int startedCalls = 0;
QStringList commands;
bool ok = true;
protected:
@@ -58,7 +68,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")) &&
@@ -91,6 +101,15 @@ class TwoSurfaceApp : public DashboardApplication {
"texture size from config");
ok &= check(vrRuntime() == nullptr, "no VR outside overlay mode");
}
int runCommand(const QString& name) override {
commands.append(name);
ok &= check(mode() == DashboardMode::Command, "mode in runCommand()");
ok &= check(engine() == nullptr && surfaces().isEmpty(),
"no engine and no surfaces for a command");
ok &= check(!qobject_cast<QGuiApplication*>(QCoreApplication::instance()),
"no GUI application for a command");
return 7;
}
void started() override {
++startedCalls;
const QList<Surface*> all = surfaces();
@@ -108,10 +127,20 @@ 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 command = qEnvironmentVariableIntValue("VRDASH_TEST_COMMAND");
const bool failPrepare =
qEnvironmentVariableIntValue("VRDASH_TEST_PREPARE_FAIL");
std::vector<std::string> args = {argv[0], "--snapshot", snapshot,
"--test-value", "42"};
qEnvironmentVariableIntValue("VRDASH_TEST_PREPARE_FAIL") || desktopNoVr;
std::vector<std::string> args = {argv[0], "--test-value", "42"};
if (desktopNoVr) {
args.emplace_back("--desktop-novr");
} else if (command) {
args.emplace_back("--test-command");
} else {
args.emplace_back("--snapshot");
args.emplace_back(snapshot);
}
if (cliKey) {
args.emplace_back("--app-key");
args.emplace_back("cli.key");
@@ -125,7 +154,20 @@ int main(int argc, char* argv[]) {
app.expectedKey =
cliKey ? QStringLiteral("cli.key") : QStringLiteral("config.key");
app.failPrepare = failPrepare;
if (desktopNoVr) app.expectedMode = DashboardMode::DesktopNoVr;
if (command) app.expectedMode = DashboardMode::Command;
const int result = app.exec();
if (command) {
bool ok = app.ok;
ok &= check(result == 7, "exec() returns runCommand()'s result");
ok &= check(
app.prepareCalls == 1 && app.loadingCalls == 0 && app.startedCalls == 0,
"prepare(), but no other hooks for a command");
ok &= check(app.commands == QStringList{QStringLiteral("test-command")},
"runCommand() once with the command's name");
fprintf(stderr, ok ? "PASS\n" : "FAILED\n");
return ok ? 0 : 1;
}
if (failPrepare) {
bool ok = app.ok;
ok &= check(result == 1, "exec() returns 1 when prepare() fails");