Files
FrameAssistant/docs/DESIGN.md
T
matzmanandClaude Opus 5.5 148ae663cf TLS for the MQTT connection (iteration 2, step 5)
ca_file or the system's CAs (MOSQ_OPT_TLS_USE_OS_CERTS), client
certificate, broker certificate and host name verified, settings errors
reported before connecting. The client thread blocks SIGPIPE: with TLS,
a write to a connection the broker closed killed the process. The
reason of a failed handshake (libmosquitto's log) is appended to the
error. tst_mqtt covers TLS with test certificates in tests/data (D44).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:29:32 +02:00

42 KiB
Raw Blame History

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. 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 (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, "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):

    {
      "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.
  • 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.
  • Exit code 0 on success; ≠ 0 with a warning if the broker can't be reached 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).
{
  "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).

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, 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, 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: haptic alone was the least effective modality.

Platforms and tools:

  • Meta Quest design guidelines: 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: content to read goes centred and slightly below the line of sight; periphery for context, centre for focus.
  • XSOverlay (common SteamVR overlay tool): popups at a configurable offset from the head, smoothly following; 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