package-release.sh refuses a package whose ELF files need newer glibc or OpenSSL symbol versions than the Frame has (D35); only the version needs section counts. The Qt bundler resolved libmosquitto's dependencies and shipped Debian's OpenSSL 3.5; libssl/libcrypto are now excluded, since SteamOS provides OpenSSL 3. Phase 3 of iteration 2 is done; PROGRESS.md has the manual checklist for phase 4. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
633 lines
43 KiB
Markdown
633 lines
43 KiB
Markdown
# Design
|
||
|
||
Status: **in progress**. High-level architecture and MVP details done; iteration 1
|
||
implemented. Iteration 2 (MQTT, HA device, UI-1): design approved 2026-10-06 ("Iteration 2:
|
||
MQTT and HA device"), phase 3 (implementation). Requirements and their decisions (D1–D15):
|
||
[REQUIREMENTS.md](REQUIREMENTS.md). Design decisions start at D16.
|
||
|
||
## Process and threads
|
||
|
||
- **One process** started by SteamVR does everything: HA connection, sensors, services, all
|
||
overlays (D16).
|
||
- Several threads inside it for concurrent work (D16). **A thread is added only where work can
|
||
block** and would stall rendering and SteamVR events (D29):
|
||
|
||
| 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 | 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.
|
||
- The boundary is three signals with value arguments (queued connections):
|
||
|
||
```
|
||
main thread MQTT thread
|
||
HA device ── publish(topic, payload) ──▶ MQTT client
|
||
HA device ◀── messageReceived(topic, payload) ── MQTT client
|
||
HA device ◀── connectionChanged(state, error) ── MQTT client
|
||
```
|
||
|
||
- 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.
|
||
- Rules: no direct calls on objects of another thread (signals or
|
||
`QMetaObject::invokeMethod` with `Qt::QueuedConnection`); lambdas connected across threads
|
||
always get a context object; no `BlockingQueuedConnection`; only values, no shared
|
||
pointers, cross the boundary.
|
||
|
||
## Layers
|
||
|
||
Three layers; dependencies only go downward (D17).
|
||
|
||
```
|
||
Presentation QML (view and controller)
|
||
▼
|
||
Business logic entities, services, managers
|
||
▼
|
||
I/O HA connection (MQTT), SteamVR, system (network, audio)
|
||
```
|
||
|
||
- Qt Quick already enforces separating presentation from logic.
|
||
- The overlay platform (`vrdashboard_core`) spans two layers: overlays, rendering and input
|
||
are presentation infrastructure; SteamVR access for sensors and actions is I/O.
|
||
|
||
## HA channels
|
||
|
||
- **MQTT is the only channel for everything device-related:** entities, services, availability
|
||
(D24).
|
||
- MQTT can't show HA as a whole (other entities' states, service calls for them), which a
|
||
dashboard needs. The dashboard gets **its own channel, separate from MQTT**:
|
||
- Q2a, HA web UI in Qt WebEngine: the browser talks to HA itself (HTTP/WebSocket, HA's own
|
||
login); config: HA URL. **Failed on the Frame (D40)**: the system WebEngine can't be
|
||
loaded, a bundled one didn't work (STEAM-FRAME.md).
|
||
- Q2b, native QML dashboard: HA's WebSocket API with a token (Q3) as another I/O component;
|
||
config: HA URL and token.
|
||
- Both fit the three layers. The web UI test (DSH-1, D28) answered Q2a with "no"; Q2/Q3 are
|
||
decided for DSH-2, with the native dashboard as the candidate.
|
||
|
||
## Start-up and modes
|
||
|
||
- **Start-up hook (D21):** the platform (`DashboardApplication` in `vrdashboard_core`) gets a
|
||
third hook, e.g. `virtual bool prepare(QString* error)`. `exec()` calls it once mode and app
|
||
key are known, before the QML engine, the surfaces and `VR_Init`. On `false`, `exec()` ends
|
||
with exit code 1 and reports the error through the existing path (log and stderr). The
|
||
default returns `true`, so other apps built on the template don't change. FrameAssistant
|
||
loads its configuration there and decides from `mode()` whether it needs it (CFG-2).
|
||
- **Modes (D22):**
|
||
|
||
| Mode | Configuration + HA connection | SteamVR |
|
||
|---|---|---|
|
||
| overlay (default) | yes | required (overlay app) |
|
||
| `--desktop` | yes | connects as background app if SteamVR runs; otherwise VR entities are unavailable (SEN-15) |
|
||
| `--desktop-novr` | yes | never |
|
||
| `--snapshot`, `--benchmark` | no | no |
|
||
|
||
- `--desktop` on the Frame over RDP shows the live UI with real data without wearing the
|
||
headset. Stop the SteamVR-started instance by hand first: two instances with the same
|
||
device name would knock each other off the broker.
|
||
- `--config <path>` loads another configuration file instead of
|
||
`~/.config/frameassistant/config.json`, e.g. on a PC with its own device name (CON-5).
|
||
|
||
## Overlays
|
||
|
||
Two overlay kinds in the platform (D25):
|
||
|
||
| Overlay | Kind | Visibility | MVP |
|
||
|---|---|---|---|
|
||
| Main tab "Frame Assistant" (UI-1, later NOT-7, do-not-disturb) | dashboard tab | always there; SteamVR shows/hides it | ✓ |
|
||
| Notifications | world overlay | the app (notification manager) shows/hides it | ✓ |
|
||
| Status tab (SRV-6) | dashboard tab | exists only while cards exist (D9) | ✗ |
|
||
| HA dashboard (DSH-2) | dashboard tab | always there | ✗ (web UI test DSH-1 removed, D40) |
|
||
|
||
- **World overlays (MVP):** a new overlay kind next to the dashboard tab (CLAUDE.md, "Planned
|
||
extension"). The surface gets a kind and a placement; renders only while visible (NFR-3).
|
||
In the desktop modes (D22) a world overlay is a plain window the app shows and hides.
|
||
- **Premultiplied alpha:** Qt Quick renders premultiplied alpha; SteamVR assumes straight
|
||
alpha unless `VROverlayFlags_IsPremultiplied` is set, which the platform doesn't do yet.
|
||
Fully opaque/transparent pixels are right, semi-transparent ones (antialiasing, shadows)
|
||
would be too dark. Set the flag in the platform; verify on the device.
|
||
- **Runtime surfaces (later, SRV-6):** creating a surface at runtime repeats the start-up
|
||
steps for one surface. Destroying one while SteamVR runs is the risk: each surface has its
|
||
own backend (with Vulkan its own `VkDevice`), and SteamVR's client keeps objects on devices
|
||
it got textures from (the reason for the teardown order). Needs a spike on the Frame; ways
|
||
out: one device shared by all surfaces, or reuse instead of destroy.
|
||
- **Interactive world overlays (later, NOT-5):** the laser hits the whole overlay rectangle,
|
||
including transparent parts.
|
||
|
||
### Notifications (D26, D27)
|
||
|
||
- **One world overlay for all notifications** (D26). QML lays out the visible notifications
|
||
as cards inside it; transparent areas around them are invisible. No runtime surfaces needed,
|
||
one renderer and texture (NFR-3). One placement for all; per-notification placement is not
|
||
required (NOT-2 makes placement configurable, not per notification) and could later become
|
||
one overlay per placement.
|
||
- **MVP placement: spawn in front, then stay** (D27): about 1 m away, slightly below the line
|
||
of sight, positioned once when it appears. No continuous tracking. Lazy follow and hand
|
||
placement come with NOT-2.
|
||
- Placement techniques:
|
||
- head-locked: SteamVR keeps the overlay relative to the HMD; runs in the compositor, smooth.
|
||
- spawn in front, then stay: one absolute transform when shown.
|
||
- lazy follow: the app updates the transform continuously from the head pose; CPU cost and
|
||
jitter if updated too rarely.
|
||
- UI guidelines from research: Appendix A.
|
||
|
||
## Object classes
|
||
|
||
The app is HA-centric, so its building blocks follow HA's concepts (D18). Objects talk to each
|
||
other through Qt signals and slots (Qt's observer pattern). Senders don't know their receivers,
|
||
and signals across threads are delivered through the receiver's event loop.
|
||
|
||
| Class | Role | MVP |
|
||
|---|---|---|
|
||
| **HA entity** | One HA entity, both directions. Entity → HA: state and JSON attributes. HA → entity: commands; HA's MQTT integration turns its own services into them (e.g. `button.press`, `switch.turn_on`, `number.set_value`, `notify.send_message`). Observes value signals of other objects and publishes them; turns MQTT commands into signals other objects act on. **Native entity types are preferred** wherever their meaning fits (D23). | ✓ |
|
||
| **HA service** | **Fallback** where no entity type fits (D23): a JSON command on an own MQTT command topic (HA side: the standard `mqtt.publish` action), e.g. rich notifications or vibration with parameters. Results go back asynchronously, e.g. through an `event` entity. How the user wraps it in HA (scripts, automations, pyscript) is their choice; FrameAssistant only requires stock HA with MQTT (NFR-6). | depends on the NOT-1 choice |
|
||
| **HA device** | Owns the MQTT connection, discovery and availability, and owns all entity and service objects. One per headset (CON-5). | ✓ |
|
||
| **I/O wrappers** | Data sources and actuators; know nothing about HA. One wrapper object per OpenVR interface used (e.g. `IVRSystem`, `IVRApplications`, `IVRChaperone`, `IVRSettings`). System wrappers (network, audio) later. | OpenVR ✓, system ✗ |
|
||
| **Overlays** | View and controller of MVC (QML), as dashboard tabs or world overlays. Get their data (the model) from other objects. | ✓ |
|
||
| **Managers** | One object per stateful feature that manages and coordinates it, using entities, services and overlays. Notification manager (MVP); status card manager (SRV-6/7) later. | notifications ✓ |
|
||
| **HA web UI** | Was: Qt WebEngine for the dashboard tab (DSH-1). Dropped (D40); a native dashboard (DSH-2) would need its own object class. | ✗ |
|
||
| **Configuration** | Settings from the JSON file, loaded once by the app as an immutable value. Each component gets the part it needs through its constructor (D19). | ✓ |
|
||
| **App** | The managing object: owns all of the above as components and wires them together. | ✓ |
|
||
|
||
### Entities and services (D23)
|
||
|
||
- **One class per HA entity type** on a common base. The base does the discovery part, topics,
|
||
availability and throttling, so SEN-14, SEN-15 and NFR-7 are handled in one place.
|
||
- A concrete entity is an **instance with a description** (id, name, device class, unit,
|
||
throttle interval), not a class of its own.
|
||
- Our actions map to native types where the meaning fits:
|
||
|
||
| Action | Type | HA service |
|
||
|---|---|---|
|
||
| Sensors (SEN-*) | `sensor`, `binary_sensor` | — |
|
||
| Notification (NOT-1) | `notify` | `notify.send_message` |
|
||
| Screenshot, standby, quit app, show boundary (SRV-1/2/3) | `button` | `button.press` |
|
||
| Do-not-disturb (SRV-5) | `switch` | `switch.turn_on/off` |
|
||
| Volume (SRV-3) | `number` | `number.set_value` |
|
||
| Launch app by key (SRV-2) | `text` | `text.set_value` |
|
||
| Attention signal with duration/volume (SRV-1), to check | `siren` | `siren.turn_on` |
|
||
| Feedback (NOT-5, NOT-10) | `event` | — |
|
||
|
||
- **Wiring:** the app wires simple entities (I/O wrapper signal ↔ entity). Managers get their
|
||
entities and services from the HA device and wire them internally. The HA device owns them
|
||
all (D18).
|
||
- **MQTT `notify` drops the title:** HA's `async_send_message()` renders only `message` into
|
||
the payload (`homeassistant/components/mqtt/notify.py`). NOT-1 needs title and text; options
|
||
(MVP detail): first line is the title, JSON in the message, or an own service.
|
||
|
||
Notes for later:
|
||
- OpenVR delivers all events through one queue; one dispatcher (today `VrRuntime`) hands them
|
||
to the wrappers.
|
||
- OpenVR is not documented as thread-safe (relevant for the thread decision).
|
||
|
||
## Open
|
||
|
||
1. Nothing open for iteration 2; points to verify are listed in its section.
|
||
|
||
## HA MQTT interface
|
||
|
||
Checked against HA's [MQTT documentation](https://www.home-assistant.io/integrations/mqtt/)
|
||
(NFR-7). The documentation prescribes no topic layout and shows both per-entity topics and a
|
||
shared JSON state topic without preference.
|
||
|
||
### IDs (D31)
|
||
|
||
- **Device id:** `ha_unique_id` from the configuration if given, else derived from the device
|
||
name. HA allows `[a-zA-Z0-9_-]`, e.g. "Wohnzimmer Frame" → `wohnzimmer_frame`. With a
|
||
derived id, renaming the device makes HA see a new device; `ha_unique_id` avoids that.
|
||
- **Entity `unique_id`:** `frameassistant_<device id>_<entity>`, e.g.
|
||
`frameassistant_wohnzimmer_frame_headset_battery`.
|
||
|
||
### Topics, retain, availability (D32)
|
||
|
||
Layout follows common practice (e.g. Zigbee2MQTT's `…/<device>/set`); one topic per entity.
|
||
|
||
| Purpose | Topic | Retained |
|
||
|---|---|---|
|
||
| Discovery (device-based, all entities under `cmps`) | `homeassistant/device/<device id>/config` | yes |
|
||
| Device availability (last will: `offline`) | `frameassistant/<device id>/availability` | yes |
|
||
| Entity availability (SEN-15) | `frameassistant/<device id>/<entity>/availability` | yes |
|
||
| State | `frameassistant/<device id>/<entity>/state` | yes |
|
||
| Attributes (e.g. app key of the running app) | `frameassistant/<device id>/<entity>/attributes` | yes |
|
||
| Command (`notify`, later `switch`, `button`, …) | `frameassistant/<device id>/<entity>/set` | no |
|
||
| HA birth message (subscribed) | `homeassistant/status` | — |
|
||
|
||
- Discovery uses the base topic `~` = `frameassistant/<device id>`.
|
||
- One topic per entity: per-entity throttling (SEN-14) and availability (SEN-15) stay simple.
|
||
- Each entity has two availability topics (device, entity) with `availability_mode: all`: it
|
||
is available only while the device is online *and* the entity has a value.
|
||
- Discovery, availability and states are retained. The headset is often off (FrameAssistant
|
||
runs only with SteamVR); retained discovery keeps its entities defined in HA across HA
|
||
restarts, showing "unavailable" through the retained `offline`.
|
||
- On HA's birth message (`homeassistant/status` = `online`) discovery and all states are sent
|
||
again after a random delay (HA's documentation recommends the delay against broker load).
|
||
- Against "ghost entities": on uninstall, FrameAssistant clears its retained topics (empty
|
||
payloads), e.g. through a command `uninstall.sh` calls (`--ha-remove`; detail later).
|
||
- Removing an entity from a newer version: publish its component with only `p` once, then
|
||
omit it (HA's documented procedure).
|
||
|
||
### MVP entities
|
||
|
||
| Entity | Type | Device class | Requirement |
|
||
|---|---|---|---|
|
||
| Headset worn | `binary_sensor` | `occupancy` | SEN-1 |
|
||
| Standby | `binary_sensor` | — | SEN-2 |
|
||
| Headset battery | `sensor` | `battery` (%) | SEN-3 |
|
||
| Headset charging | `binary_sensor` | `battery_charging` | SEN-3 |
|
||
| Controller left/right connected | 2 × `binary_sensor` | `connectivity` | SEN-4 |
|
||
| Controller left/right battery | 2 × `sensor` | `battery` (%) | SEN-4 |
|
||
| Controller left/right charging | 2 × `binary_sensor` | `battery_charging` | SEN-4 |
|
||
| Running app | `sensor` (text), app key as attribute | — | SEN-5 |
|
||
| Dashboard open | `binary_sensor` | — | SEN-6 |
|
||
| Notification | `notify` | — | NOT-1 |
|
||
|
||
- 14 entities. Controllers are fixed as left and right (SEN-15: fixed set).
|
||
- Device info (`dev`): name = device name; manufacturer and model from OpenVR where available.
|
||
Origin (`o`): FrameAssistant and its version.
|
||
|
||
### MVP notification (D33)
|
||
|
||
- HA's MQTT `notify` entity passes only `message`; it drops `title`
|
||
(`async_send_message()` in `homeassistant/components/mqtt/notify.py`).
|
||
- **MVP: the `notify` entity with a convention: the first line of the message is the title**,
|
||
e.g. `notify.send_message` with `message: "Washing machine\nis done"`. A message without a
|
||
line break has no title. Native and selectable in HA's automation editor.
|
||
- **Later (with NOT-3 onward): an own JSON command** (`mqtt.publish`, e.g.
|
||
`{"title": …, "message": …, "priority": …}`) for everything the entity can't carry. Both
|
||
paths feed the same notification manager.
|
||
- 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
|
||
<path>` overrides it (D22).
|
||
|
||
```json
|
||
{
|
||
"device": {
|
||
"name": "Wohnzimmer Frame",
|
||
"ha_unique_id": "wohnzimmer_frame"
|
||
},
|
||
"mqtt": {
|
||
"host": "homeassistant.local",
|
||
"port": 1883,
|
||
"username": "frameassistant",
|
||
"password": "…",
|
||
"tls": {
|
||
"enabled": false,
|
||
"ca_file": "/path/to/ca.crt",
|
||
"cert_file": "…",
|
||
"key_file": "…"
|
||
}
|
||
},
|
||
"homeassistant": {
|
||
"discovery_prefix": "homeassistant",
|
||
"url": "http://homeassistant.local:8123"
|
||
},
|
||
"notifications": {
|
||
"timeout_s": 10
|
||
}
|
||
}
|
||
```
|
||
|
||
| Key | Required | Default | Purpose |
|
||
|---|---|---|---|
|
||
| `device.name` | **yes** | — | device name in HA (CFG-2) |
|
||
| `device.ha_unique_id` | no | derived from the name | D31 |
|
||
| `mqtt.host` | **yes** | — | CFG-3 |
|
||
| `mqtt.port` | no | 1883, 8883 with TLS | |
|
||
| `mqtt.username` / `password` | no | no login | |
|
||
| `mqtt.tls.enabled` | no | `false` | |
|
||
| `mqtt.tls.ca_file` | no | system certificates | own CA, e.g. a self-signed broker certificate |
|
||
| `mqtt.tls.cert_file` / `key_file` | no | none | client certificate, if the broker requires one |
|
||
| `homeassistant.discovery_prefix` | no | `homeassistant` | |
|
||
| `homeassistant.url` | no | — | reserved for the dashboard (DSH-2); not used yet (D40) |
|
||
| `notifications.timeout_s` | no | 10 | D33 |
|
||
|
||
- Errors (missing required values, invalid JSON, wrong types): exit with a message and exit
|
||
code ≠ 0 (CFG-2, via the start-up hook D21).
|
||
- Unknown keys: a warning in the log, no abort (catches typos without breaking across
|
||
versions).
|
||
- Password in plain text: warn if the file holds a password and is readable by others;
|
||
otherwise file permissions (`chmod 600`). (Implemented this way: a readable file without a
|
||
password is harmless.)
|
||
- Throttle intervals (SEN-14) are fixed per entity in the code for the MVP.
|
||
- A `config.example.json` ships in the package (`share/frameassistant/`); the package's
|
||
README.txt explains how to set it up.
|
||
- To check on the Frame when TLS is implemented: where the system certificates are
|
||
(`/etc/ssl/certs`?), for the `ca_file` default.
|
||
|
||
## Shipping libmosquitto (D35)
|
||
|
||
Modelled on OpenVR (`libopenvr_api.so` copied to `lib/`, found via RPATH `$ORIGIN/../lib`,
|
||
excluded from the Qt bundler, licence in `share/doc/frameassistant/`).
|
||
|
||
1. Found at build time through `pkg-config` (`libmosquitto.pc`; Debian and Arch).
|
||
2. Build containers: `libmosquitto-dev` (native) or `libmosquitto-dev:arm64` (cross); the
|
||
broker `mosquitto` in the test container for the integration tests (D30).
|
||
3. A CMake option (e.g. `FRAMEASSISTANT_SHIP_MOSQUITTO`) installs the real file behind
|
||
`libmosquitto.so.1` into `lib/`: on for the package presets (system Qt and bundled), off for
|
||
host builds, which use the system library. `libmosquitto` joins the Qt bundler's exclusion
|
||
list, like OpenVR, so it isn't shipped twice.
|
||
4. Licence: Debian's `libmosquitto1/copyright` into `share/doc/frameassistant/third-party/`,
|
||
plus the EPL-2.0/EDL-1.0 texts if that file lacks them (check when implementing); an entry
|
||
in `THIRD_PARTY_NOTICES.md`.
|
||
5. App-specific (e.g. `cmake/Mosquitto.cmake`, `src/CMakeLists.txt`); the platform stays free
|
||
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
|
||
|
||
Four levels (D30), implementing NFR-8:
|
||
|
||
| Level | What | How | Where |
|
||
|---|---|---|---|
|
||
| 1. Unit tests | configuration (CFG-2), discovery payloads, throttling (SEN-14), availability logic (SEN-15), entities, notification manager | QtTest with **fakes at the boundaries**: a fake MQTT client for the three signals (D29), fake I/O wrappers instead of OpenVR | host and test container, `ctest` |
|
||
| 2. Integration tests with a real broker | CON-1–4: connect, last will when killed, reconnect after broker restart, login/TLS | the test starts a local `mosquitto` on a free port (host: installed; test container: add the package) | host and test container, `ctest` |
|
||
| 3. UI and rendering | main tab and notification cards, transparency | existing tools: `--snapshot`, `tst_render`, desktop modes | host |
|
||
| 4. Manual | against the user's HA: entities appear, no deprecation warnings (NFR-7). On the Frame: sensors, notification in VR and in a game, DSH-1, premultiplied alpha, NFR-3 | checklist per iteration, results documented in `docs/` | user's HA, the Frame |
|
||
|
||
- OpenVR wrappers stay thin; logic goes into the testable parts behind them. The wrappers
|
||
themselves are only checked on the device (level 4).
|
||
- HA itself is tested manually for now (an HA container would be possible but costly);
|
||
revisit later (NFR-8 allows adjusting the strategy).
|
||
|
||
### NFR-3 measurement (D36)
|
||
|
||
1. **FrameAssistant's own cost:**
|
||
|
||
| Measure | Method | Target |
|
||
|---|---|---|
|
||
| CPU when idle (tabs closed, no notification) | process CPU time from `/proc/<pid>/stat` over 60 s | practically 0 (e.g. < 1 % of one core) |
|
||
| Rendering only while visible | renderer frame counter in the log | 0 frames while nothing is visible |
|
||
| GPU time per frame when visible | existing `--benchmark` | reference value |
|
||
|
||
2. **Impact on the game (A/B):** the same quiet scene (e.g. a game, standing still), 2 minutes
|
||
each: A without FrameAssistant (twice, to know the run-to-run noise), B with FrameAssistant
|
||
idle, C with a notification every 10 s. Compare dropped and reprojected frames and the
|
||
game's GPU time. Acceptance: B and C within A's noise.
|
||
3. **Tool:** in the MVP manually, with SteamVR's "Advanced Frame Timing". Later a measuring
|
||
mode (e.g. `frame-assistant --vr-stats 120`, background app, reads the compositor's
|
||
`GetCumulativeStats`/`GetFrameTimings` and prints a summary).
|
||
|
||
## Appendix A: notification UI in VR (research)
|
||
|
||
Collected 2026-10-05 for the notification design.
|
||
|
||
**Studies:**
|
||
- [Rzayev et al., CHI PLAY 2019](https://dl.acm.org/doi/abs/10.1145/3311350.3347190), 24
|
||
participants, tasks incl. gaming; placements head-up display (fixed in view), on-body,
|
||
floating, in-situ (embedded in the scene). On-body and floating: best understandability,
|
||
usability and presence. HUD: most intrusive and disturbing; only for urgent notifications.
|
||
- [Hsieh et al., CHI 2020](https://dl.acm.org/doi/10.1145/3313831.3376228), 40 participants;
|
||
displays on the HMD, on the controller, movable panel:
|
||
- Users dislike notifications blocking their focus region (HMD: inflexible location;
|
||
controller: blocks the work area). Recommendation: let users choose the location from a
|
||
set of peripheral positions.
|
||
- The more engaged the user, the more disruptive notifications feel. Then rather a small
|
||
peripheral indicator (e.g. unread count), content later.
|
||
- Don't reuse a modality the VR content already uses (e.g. game vibration hides
|
||
notification vibration). Urgent: more obtrusive means such as audio.
|
||
- Display time in the study was 12 s; longer is likely more disruptive.
|
||
- [NotifiVR, Ghosh et al. 2018](https://pubmed.ncbi.nlm.nih.gov/29543163): haptic alone was the
|
||
least effective modality.
|
||
|
||
**Platforms and tools:**
|
||
- [Meta Quest design guidelines](https://developers.meta.com/horizon/design/mr-design-guideline/):
|
||
avoid head-locked UI; anchor in space or "lazy follow" (follows the head delayed and
|
||
smoothed); about 1 m away, slightly below the line of sight. Users can set where
|
||
notifications appear.
|
||
- [visionOS](https://developer.apple.com/videos/play/wwdc2023/10078/): content to read goes
|
||
centred and slightly below the line of sight; periphery for context, centre for focus.
|
||
- [XSOverlay](https://xsoverlay.vercel.app/settings) (common SteamVR overlay tool): popups at a
|
||
configurable offset from the head, smoothly following;
|
||
[xsoverlay-tweak](https://github.com/chaixshot/xsoverlay-tweak) adds a "leash" mode (stays
|
||
until the head turns too far away).
|
||
|
||
**Consequences for FrameAssistant:**
|
||
1. Placement: not rigidly head-locked. Lazy follow/leash, spawn in front then stay, and hand
|
||
are well supported (matches NOT-2). Head-locked at most for critical notifications (NOT-8).
|
||
2. Position: about 1 m, below the line of sight or towards the periphery, not centred; offset
|
||
configurable.
|
||
3. Content: compact (title, short text), fade in/out, timeout around 10 s; longer content in
|
||
the main tab's list (NOT-7).
|
||
4. Several at once: few visible, stacked, newest in front (fits D26).
|
||
5. Modalities: sound as the main signal for important notifications (NOT-4/8), vibration only
|
||
as a supplement.
|
||
|
||
## Decisions
|
||
|
||
| # | Date | Decision | Reason |
|
||
|---|------|----------|--------|
|
||
| D16 | 2026-10-05 | **One process** started by SteamVR does everything; several threads inside it for concurrent work | Meets NFR-2 without extra work, no IPC; the platform already supports several surfaces per process |
|
||
| D17 | 2026-10-05 | **Three layers:** presentation (QML), business logic, I/O. The overlay platform spans presentation (overlays, rendering, input) and I/O (SteamVR access) | Simple and enough for the start; Qt Quick already enforces separating presentation from logic |
|
||
| D18 | 2026-10-05 | **HA-centric object classes:** HA entity, HA service, HA device, I/O wrappers, overlays, managers, HA web UI, configuration, app; communication through signals and slots | The app is HA-centric; signals/slots decouple objects and work across threads |
|
||
| D19 | 2026-10-05 | **Configuration is injected:** the app loads it once as an immutable value and passes each component the part it needs; no singleton | Explicit dependencies, trivial tests with own configs, thread-safe; the app creates all components anyway |
|
||
| D21 | 2026-10-05 | Start-up hook in the platform that may fail (`prepare()`), before the engine, surfaces and `VR_Init`; failure → exit code 1 | CFG-2 needs a clean abort; `loading()`/`started()` can't fail; backward compatible |
|
||
| D22 | 2026-10-05 | Overlay and both desktop modes load the configuration and connect to HA; `--desktop` also connects to SteamVR as a background app if it runs; `--desktop-novr` never does; `--snapshot`/`--benchmark` stay without configuration; new option `--config <path>` | Live development over RDP on the Frame with real data; PC development without SteamVR under its own device name |
|
||
| D23 | 2026-10-05 | **One class per HA entity type** on a common base; concrete entities are described instances. Native entity types (and the HA services the MQTT integration provides for them) are preferred; an own JSON command service only as fallback | Mirrors HA's model; discovery, availability and throttling in one place; actions are visible and selectable in HA without `mqtt.publish` |
|
||
| D24 | 2026-10-05 | MQTT for everything device-related; the dashboard gets its own channel to HA, chosen with Q2/Q3 | MQTT can't provide HA as a whole; both dashboard options fit the layers without affecting the MVP |
|
||
| 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 | *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 |
|
||
| D33 | 2026-10-05 | MVP notification through the `notify` entity, first line = title; own JSON command later for richer notifications; display time from the configuration (default 10 s) | MQTT `notify` drops the title; native action for the common case, extensible path later |
|
||
| D34 | 2026-10-05 | Configuration file format (sections `device`, `mqtt`, `homeassistant`, `notifications`); required: device name and broker host; unknown keys only warn | Covers CFG-1–3, D31, D33, DSH-1 with few required values |
|
||
| D35 | 2026-10-05 | Ship libmosquitto like OpenVR: pkg-config at build time, copied to `lib/` by a CMake option for package presets, excluded from the Qt bundler, licence shipped; `package-release.sh` checks symbol versions against the Frame | Not on the Frame; keeps the platform MQTT-free; a Debian update that breaks compatibility shows up when packaging |
|
||
| D36 | 2026-10-05 | NFR-3 measured as own cost (CPU idle, frames while hidden, GPU per frame) plus an A/B comparison of the game's compositor statistics; manual in the MVP, a `--vr-stats` mode later | Measurable acceptance for NFR-3 without extra code in the MVP |
|
||
| 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 |
|