Compare commits
10
Commits
6c441bd9a0
...
6e1bed79f0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e1bed79f0 | ||
|
|
6cb30913ca | ||
|
|
0bdcaddcd5 | ||
|
|
148ae663cf | ||
|
|
943e90c1b1 | ||
|
|
466743eff9 | ||
|
|
96df60e6bd | ||
|
|
e0229d6647 | ||
|
|
7810db5cbe | ||
|
|
cd62e57183 |
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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 \
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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};
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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(); }
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
```
|
||||
@@ -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-----
|
||||
@@ -0,0 +1,11 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBojCCAUegAwIBAgIUMl5Z4QsUdsD5cj2ZNcEWB1L4awAwCgYIKoZIzj0EAwIw
|
||||
ITEfMB0GA1UEAwwWRnJhbWVBc3Npc3RhbnQgdGVzdCBDQTAgFw0yNjEwMDYxNDE4
|
||||
NDBaGA8yMTI2MDkxMjE0MTg0MFowJTEjMCEGA1UEAwwaZnJhbWVhc3Npc3RhbnQt
|
||||
dGVzdC1jbGllbnQwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQXzJWn1Vn4oghl
|
||||
m077IKOEEX3VBFDMP7IGVBR6V19oHGgCbth9gEQXqPMr+8LYtK3UWYhk/VuFVP3X
|
||||
abqlipIpo1cwVTATBgNVHSUEDDAKBggrBgEFBQcDAjAdBgNVHQ4EFgQUQjEJ7EHL
|
||||
s4y78P14VB3keg8WCFIwHwYDVR0jBBgwFoAUS3WttTWOFjXHbTEFFNtHaRY8y8Uw
|
||||
CgYIKoZIzj0EAwIDSQAwRgIhAJH+jXs/7SovwLZfT0BhlteFfCyhXuz6e39FbRjk
|
||||
dkGmAiEA2IpZZlW+T3r71sHE+CJe8RpHdjDPjmij2nctps8Pdm4=
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,5 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQggKrDK1CB3lOzi23P
|
||||
XkvdV45GpZG4YMrAH0BVaB24liGhRANCAAQXzJWn1Vn4oghlm077IKOEEX3VBFDM
|
||||
P7IGVBR6V19oHGgCbth9gEQXqPMr+8LYtK3UWYhk/VuFVP3XabqlipIp
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -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-----
|
||||
@@ -0,0 +1,11 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBrDCCAVKgAwIBAgIUMl5Z4QsUdsD5cj2ZNcEWB1L4av8wCgYIKoZIzj0EAwIw
|
||||
ITEfMB0GA1UEAwwWRnJhbWVBc3Npc3RhbnQgdGVzdCBDQTAgFw0yNjEwMDYxNDE4
|
||||
NDBaGA8yMTI2MDkxMjE0MTg0MFowFDESMBAGA1UEAwwJbG9jYWxob3N0MFkwEwYH
|
||||
KoZIzj0CAQYIKoZIzj0DAQcDQgAEkIJRd0MoLtpP0X755ZbjYvV0Vp2rDECQZyC6
|
||||
4bbwufvk2vm1+JUBusnnnSIfqRU5S3wH6pzSyWxZiuc1TXscrqNzMHEwGgYDVR0R
|
||||
BBMwEYIJbG9jYWxob3N0hwR/AAABMBMGA1UdJQQMMAoGCCsGAQUFBwMBMB0GA1Ud
|
||||
DgQWBBR7nqpN0AFa5jGDs9gq3vK3OvxpSTAfBgNVHSMEGDAWgBRLda21NY4WNcdt
|
||||
MQUU20dpFjzLxTAKBggqhkjOPQQDAgNIADBFAiAwIGSpsMJmq3ExrtLq3PpNWbe/
|
||||
HMnp7V7rmS8K8uWJpwIhAI8osCchJeKH7xg4yqfZXBxrU1FwagojEGQDk3HKEoIe
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,5 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgP5sIs6digdS0/HXU
|
||||
a8G16oZk3HZwfYHTjnAIm0APWpuhRANCAASQglF3Qygu2k/RfvnlluNi9XRWnasM
|
||||
QJBnILrhtvC5++Ta+bX4lQG6yeedIh+pFTlLfAfqnNLJbFmK5zVNexyu
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user