Design: MVP details and implementation order
D31/D32: ids, topics, retain and availability for the HA MQTT interface, MVP entity list. D33: MVP notification via the notify entity. D34: configuration file format. D35: shipping libmosquitto. D36: NFR-3 measurement. D37: MVP implemented in four iterations, DSH-1 first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+179
-4
@@ -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_<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.
|
||||
|
||||
## 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` | 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/<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)
|
||||
|
||||
@@ -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_<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 |
|
||||
|
||||
Reference in New Issue
Block a user