diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 2cefc59..1f0173c 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1,7 +1,7 @@ # Design Status: **in progress**, iteration 1, phase 2 (planning and design). High-level architecture -done; next: the details for the MVP. Requirements and their decisions (D1–D15): +and MVP details done; next: platform changes and the implementation plan. Requirements and their decisions (D1–D15): [REQUIREMENTS.md](REQUIREMENTS.md). Design decisions start at D16. ## Process and threads @@ -187,7 +187,159 @@ Notes for later: ## Open -1. MVP details (see [PROGRESS.md](PROGRESS.md), "Next steps"). +1. Detailed plan for implementation iteration 1 (see [PROGRESS.md](PROGRESS.md), "Next steps"). + +## 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__`, e.g. + `frameassistant_wohnzimmer_frame_headset_battery`. + +### Topics, retain, availability (D32) + +Layout follows common practice (e.g. Zigbee2MQTT's `…//set`); one topic per entity. + +| Purpose | Topic | Retained | +|---|---|---| +| Discovery (device-based, all entities under `cmps`) | `homeassistant/device//config` | yes | +| Device availability (last will: `offline`) | `frameassistant//availability` | yes | +| Entity availability (SEN-15) | `frameassistant///availability` | yes | +| State | `frameassistant///state` | yes | +| Attributes (e.g. app key of the running app) | `frameassistant///attributes` | yes | +| Command (`notify`, later `switch`, `button`, …) | `frameassistant///set` | no | +| HA birth message (subscribed) | `homeassistant/status` | — | + +- Discovery uses the base topic `~` = `frameassistant/`. +- 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. + +## Configuration file (D34) + +- Location: `~/.config/frameassistant/config.json` (`$XDG_CONFIG_HOME` respected); `--config + ` 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` | for DSH-1 only | — | missing: the dashboard tab shows a hint instead of the web UI | +| `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 is readable by others; otherwise file permissions + (`chmod 600`). +- Throttle intervals (SEN-14) are fixed per entity in the code for the MVP. +- A `config.example.json` ships in the package. +- 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 @@ -204,8 +356,24 @@ Four levels (D30), implementing NFR-8: 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). -- The NFR-3 measurement method is a detail (idea: compare SteamVR compositor statistics, e.g. - missed frames, with and without FrameAssistant). + +### NFR-3 measurement (D36) + +1. **FrameAssistant's own cost:** + + | Measure | Method | Target | + |---|---|---| + | CPU when idle (tabs closed, no notification) | process CPU time from `/proc//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) @@ -269,3 +437,10 @@ Collected 2026-10-05 for the notification design. | 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 | | 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__` | Renaming shouldn't have to create a new HA device | +| D32 | 2026-10-05 | One topic per entity under `frameassistant//`, 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 | diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index dda534c..6c04027 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -11,18 +11,18 @@ CLAUDE.md ("Working rules"). ## Next steps -Phase 2: the high-level architecture is done (D16–D30, [DESIGN.md](DESIGN.md)). Next, the -details for the MVP (D8, plus DSH-1 by D28), in this order: +Requirements and design cover the whole MVP; it is **implemented in four small iterations** +(D37), each with implementation, testing and assessment: -1. Topics and payloads: topic scheme, device and entity ids, discovery content, MVP entity - list, retain and availability. -2. MVP notification: command format and title (MQTT `notify` drops the title). -3. Configuration file format, incl. TLS/CA settings. -4. Shipping libmosquitto (build, package, licence text). -5. NFR-3 measurement method. +| Iteration | Content | Platform change | Checked by | +|---|---|---|---| +| 1 | DSH-1: HA web UI (Qt WebEngine) in a dashboard tab; configuration (D34) with start-up hook and `--config` | start-up hook (D21), WebEngine initialisation before the `QGuiApplication` | HA web UI in the tab on the Frame; result documented | +| 2 | MQTT client (own thread), HA device with discovery and availability, libmosquitto in the package, status display UI-1 | modes (D22) | device appears in HA, online/offline right, reconnect works | +| 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 | -Then the platform changes (start-up hook D21, desktop modes D22, world overlay D25, WebEngine -initialisation for DSH-1) and the implementation plan for iteration 1. +Next: the detailed plan for iteration 1 (files, classes, tests, order) for the user's +approval; that ends phase 2 for iteration 1. ## Log @@ -39,6 +39,12 @@ initialisation for DSH-1) and the implementation plan for iteration 1. present (relevant for Q2/Q3). - libmosquitto ABI check: Debian's 2.0.21 needs at most `OPENSSL_3.0.0`/`GLIBC_2.34`; the Frame has OpenSSL 3.2.1 and glibc 2.39. No libcjson dependency. D14 holds. +- MVP implemented in four iterations; DSH-1 first, at the user's wish (D37). +- NFR-3 measurement method, manual in the MVP (D36). MVP details done. +- Shipping libmosquitto (D35). +- Configuration file format (D34). +- MVP notification: `notify` entity with first line as title; JSON command later (D33). +- HA MQTT interface: ids (D31), topics, retain, availability (D32), MVP entity list. - Test strategy in four levels (D30). High-level architecture done. - Threads: MQTT client in its own thread, everything else incl. OpenVR in the main thread (D29). - DSH-1 split: web UI out-of-the-box test in the MVP, full dashboard as DSH-2 (D28).