Skip to content

Commit e6d2cbc

Browse files
cagnuleinclaude
andcommitted
docs: add LLM guide for custom dashboards + fix WS message parsing
The guide (docs/custom-dashboard-guide.md) covers file layout, qml.qrc registration, shared asset paths, the WebSocket API with the correct {msg:"workout", content:{...}} envelope, full metric field reference for all device types, control commands, power zone table, minimal example, and a distribution checklist. Also fixes the WS message parser in bike-pro/app.js: data lives in msg.content, not at the message root level. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1640b6d commit e6d2cbc

2 files changed

Lines changed: 317 additions & 3 deletions

File tree

docs/custom-dashboard-guide.md

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
# QZ Custom Dashboard — Developer & LLM Guide
2+
3+
This document is written for an LLM (or human developer) who wants to create a custom web dashboard for QZ Fitness. Read it top-to-bottom before writing any code.
4+
5+
---
6+
7+
## What a custom dashboard is
8+
9+
QZ already runs a local HTTP + WebSocket server (the "inner QZWS" server, default port 6666). A custom dashboard is a folder of static web files (`index.html`, CSS, JS, images) that QZ serves through this existing server. When the user enables the feature in **Settings → General Options → Custom Dashboard**, QZ replaces the standard tile-based home screen with a full-screen `WebView` that loads your `index.html`. The top toolbar (Bluetooth status, start/stop/lap buttons) always stays visible above the dashboard.
10+
11+
---
12+
13+
## File layout
14+
15+
```
16+
src/inner_templates/<your-dashboard-name>/
17+
index.html ← required entry point
18+
style.css ← optional, referenced from index.html
19+
app.js ← optional, referenced from index.html
20+
... ← any other assets (images, fonts, etc.)
21+
```
22+
23+
Built-in dashboards live in `src/inner_templates/` and are compiled into the Qt resource system (`src/qml.qrc`). User-supplied dashboards live in `<QZ app-data dir>/dashboards/<name>/` and are served directly from the filesystem — no recompile needed.
24+
25+
**For built-in dashboards** (shipping with QZ): add every file to `src/qml.qrc`:
26+
```xml
27+
<file>inner_templates/<your-dashboard-name>/index.html</file>
28+
<file>inner_templates/<your-dashboard-name>/style.css</file>
29+
<file>inner_templates/<your-dashboard-name>/app.js</file>
30+
```
31+
32+
**For user/community dashboards**: just drop the folder into `<appdata>/dashboards/` — no code changes needed. The folder name becomes the selectable name in Settings.
33+
34+
---
35+
36+
## Accessing shared assets
37+
38+
The `chartjs/` folder is always available at the same origin as your dashboard. Reference Chart.js without bundling it:
39+
40+
```html
41+
<script src="../chartjs/chartjs.3.4.1.min.js"></script>
42+
```
43+
44+
Other available libraries in `../chartjs/`:
45+
- `chartjs-adapter-moment.js`
46+
- `chartjs-plugin-annotation.min.js`
47+
- `moment.js`
48+
- `jquery-3.6.0.min.js`
49+
50+
---
51+
52+
## WebSocket connection
53+
54+
QZ sends live metrics once per second over a WebSocket at:
55+
56+
```
57+
ws://localhost:<port>/
58+
```
59+
60+
The port is stored in QSettings under the key `template_inner_QZWS_port`. The easiest way to discover it at runtime is to read the page's own port (since the HTTP server and WS server share the same port), or probe common fallbacks:
61+
62+
```js
63+
function connectWS() {
64+
const port = parseInt(location.port, 10) || 6666;
65+
const ws = new WebSocket(`ws://localhost:${port}/`);
66+
ws.onopen = () => console.log('connected');
67+
ws.onclose = () => setTimeout(connectWS, 2000); // auto-reconnect
68+
ws.onmessage = (ev) => {
69+
const msg = JSON.parse(ev.data);
70+
if (msg.msg === 'workout') handleMetrics(msg.content);
71+
};
72+
}
73+
```
74+
75+
Always implement auto-reconnect with a 2-second delay — the server may not be ready when the page first loads.
76+
77+
---
78+
79+
## Message format
80+
81+
Every second QZ sends one JSON message:
82+
83+
```json
84+
{
85+
"msg": "workout",
86+
"content": { ...all metric fields... }
87+
}
88+
```
89+
90+
Only process messages where `msg.msg === "workout"`. Other `msg` values are internal QZ protocol messages; ignore them unless you need specific features (see "Sending commands" below).
91+
92+
---
93+
94+
## Metric fields reference (`msg.content`)
95+
96+
### Universal (all device types)
97+
98+
| Field | Type | Description |
99+
|---|---|---|
100+
| `deviceType` | int | 0 = treadmill, 1 = bike, 2 = elliptical, 3 = rower |
101+
| `deviceName` | string | Bluetooth device name |
102+
| `devicePaused` | bool | `true` while the workout is paused |
103+
| `elapsed_h` | int | Elapsed hours |
104+
| `elapsed_m` | int | Elapsed minutes (0–59) |
105+
| `elapsed_s` | int | Elapsed seconds (0–59) |
106+
| `lapelapsed_h/m/s` | int | Elapsed time for current lap |
107+
| `moving_h/m/s` | int | Moving time (excludes pauses) |
108+
| `remaining_time_h/m/s` | int | Time remaining in training program |
109+
| `row_remaining_time_h/m/s` | int | Time remaining in current training row |
110+
| `speed` | float | Current speed (unit depends on user setting) |
111+
| `speed_avg` | float | Session average speed |
112+
| `speed_lapavg` | float | Lap average speed |
113+
| `calories` | float | Total calories burned |
114+
| `distance` | float | Total distance (odometer) |
115+
| `heart` | float | Current heart rate (BPM) |
116+
| `heart_avg` | float | Session average HR |
117+
| `heart_max` | float | Session max HR |
118+
| `watts` | float | Current power output (W) |
119+
| `watts_avg` | float | Session average watts |
120+
| `watts_max` | float | Session max watts |
121+
| `kgwatts` | float | Power-to-weight (W/kg) |
122+
| `jouls` | float | Total energy in joules |
123+
| `elevation` | float | Total elevation gain |
124+
| `difficult` | float | Current difficulty multiplier |
125+
| `latitude` / `longitude` / `altitude` | float | GPS coordinates |
126+
| `workoutName` | string | Name of the loaded training program |
127+
| `workoutStartDate` | string | ISO date when workout started |
128+
| `instructorName` | string | Instructor name (Peloton integration) |
129+
| `nickName` | string | User nickname from Settings |
130+
| `autoresistance` | bool | ERG/auto-resistance mode active |
131+
| `nextrow` | int | Next row index in training program |
132+
| `pace_s/m/h` | int | Current pace |
133+
| `avgpace_s/m/h` | int | Average pace |
134+
| `maxpace_s/m/h` | int | Max pace |
135+
136+
### Bike-only fields
137+
138+
| Field | Type | Description |
139+
|---|---|---|
140+
| `cadence` | float | Pedalling cadence (RPM) |
141+
| `cadence_avg` | float | Session average cadence |
142+
| `resistance` | float | Current resistance level |
143+
| `inclination` | float | Simulated road grade (%) |
144+
| `peloton_resistance` | float | Peloton-mapped resistance (0–100) |
145+
| `power_zone` | float | Current power zone (1–7) |
146+
| `target_power` | float | ERG target power (W) |
147+
| `target_cadence` | float | Target cadence |
148+
| `target_resistance` | float | Target resistance level |
149+
| `target_power_zone` | float | Target power zone |
150+
| `req_power` | float | Last requested power |
151+
| `req_cadence` | float | Last requested cadence |
152+
| `gears` | int | Virtual gear position |
153+
| `cranks` | int | Cumulative crank revolutions |
154+
155+
### Treadmill-only fields
156+
157+
| Field | Type | Description |
158+
|---|---|---|
159+
| `cadence` | float | Step cadence (steps/min) |
160+
| `inclination` | float | Current treadmill incline (%) |
161+
| `inclination_avg` | float | Average incline |
162+
| `target_speed` | float | Target speed |
163+
| `target_inclination` | float | Target incline |
164+
| `stridelength` | float | Stride length (cm) |
165+
| `groundcontact` | float | Ground contact time (ms) |
166+
| `verticaloscillation` | float | Vertical oscillation (mm) |
167+
168+
### Rower-only fields
169+
170+
| Field | Type | Description |
171+
|---|---|---|
172+
| `cadence` | float | Stroke rate (strokes/min) |
173+
| `strokescount` | float | Total stroke count |
174+
| `strokeslength` | float | Stroke length |
175+
| `resistance` | float | Resistance level |
176+
| `target_pace_s/m/h` | int | Target pace |
177+
178+
### Elliptical-only fields
179+
180+
| Field | Type | Description |
181+
|---|---|---|
182+
| `cadence` | float | Stride cadence (RPM) |
183+
| `resistance` | float | Resistance level |
184+
| `inclination` | float | Ramp angle (%) |
185+
186+
---
187+
188+
## Sending commands to QZ
189+
190+
Your dashboard can send JSON commands back over the same WebSocket connection to control the device. All commands follow the pattern `{msg: "<command>", ...params}`.
191+
192+
### Control commands
193+
194+
```js
195+
// Set resistance (bike/rower/elliptical)
196+
ws.send(JSON.stringify({ msg: "setresistance", peloton_resistance: 42 }));
197+
198+
// Set power target (ERG mode)
199+
ws.send(JSON.stringify({ msg: "setpower", power: 250 }));
200+
201+
// Set cadence target
202+
ws.send(JSON.stringify({ msg: "setcadence", cadence: 90 }));
203+
204+
// Set speed (treadmill)
205+
ws.send(JSON.stringify({ msg: "setspeed", speed: 10.5 }));
206+
207+
// Set incline (treadmill)
208+
// (uses inclination field — value in %)
209+
ws.send(JSON.stringify({ msg: "setinclination", inclination: 5.0 }));
210+
211+
// Set difficulty multiplier
212+
ws.send(JSON.stringify({ msg: "setdifficult", difficult: 1.2 }));
213+
214+
// Set fan speed
215+
ws.send(JSON.stringify({ msg: "setfanspeed", fanspeed: 3 }));
216+
```
217+
218+
### Data request commands
219+
220+
```js
221+
// Request current settings object (response: msg = "R_getsettings")
222+
ws.send(JSON.stringify({ msg: "getsettings" }));
223+
224+
// Request session history array (response: msg = "R_getsessionarray")
225+
ws.send(JSON.stringify({ msg: "getsessionarray" }));
226+
227+
// Request training program list (response: msg = "R_loadtrainingprograms")
228+
ws.send(JSON.stringify({ msg: "loadtrainingprograms" }));
229+
```
230+
231+
Response messages arrive as `{msg: "R_<command>", content: ...}`.
232+
233+
---
234+
235+
## Power zones reference
236+
237+
QZ reports `power_zone` as a float (1.0–7.0). If you want to compute zones yourself from raw watts, use the standard 7-zone model relative to FTP:
238+
239+
| Zone | % of FTP | Name |
240+
|---|---|---|
241+
| 1 | < 55% | Recovery |
242+
| 2 | 55–75% | Endurance |
243+
| 3 | 75–90% | Tempo |
244+
| 4 | 90–105% | Threshold |
245+
| 5 | 105–120% | VO₂max |
246+
| 6 | 120–150% | Anaerobic |
247+
| 7 | > 150% | Sprint |
248+
249+
The user's FTP is stored in QSettings under the key `ftp` (default 200 W). You can retrieve it from the `settings` object sent by QZ in the `R_getsettings` response.
250+
251+
---
252+
253+
## Minimal working example
254+
255+
A complete dashboard in a single file, no dependencies:
256+
257+
```html
258+
<!DOCTYPE html>
259+
<html>
260+
<head>
261+
<meta charset="UTF-8">
262+
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
263+
<style>
264+
body { background: #111; color: #fff; font-family: system-ui; display: flex;
265+
flex-direction: column; align-items: center; justify-content: center;
266+
height: 100dvh; margin: 0; font-size: 24px; }
267+
#power { font-size: 96px; font-weight: 700; color: #ff9f0a; }
268+
</style>
269+
</head>
270+
<body>
271+
<div>Power</div>
272+
<div id="power">–</div>
273+
<div>watts</div>
274+
275+
<script>
276+
function connect() {
277+
const ws = new WebSocket(`ws://localhost:${location.port || 6666}/`);
278+
ws.onclose = () => setTimeout(connect, 2000);
279+
ws.onmessage = (ev) => {
280+
const msg = JSON.parse(ev.data);
281+
if (msg.msg === 'workout')
282+
document.getElementById('power').textContent = Math.round(msg.content.watts) || '';
283+
};
284+
}
285+
connect();
286+
</script>
287+
</body>
288+
</html>
289+
```
290+
291+
---
292+
293+
## Checklist before shipping a dashboard
294+
295+
- [ ] `index.html` exists at the root of the dashboard folder
296+
- [ ] WebSocket connects to `ws://localhost:${location.port}/` (no hardcoded port)
297+
- [ ] Auto-reconnect implemented (server may not be up when page first loads)
298+
- [ ] Only `msg.msg === "workout"` messages are processed for live metrics
299+
- [ ] All values guarded against `undefined` / `null` / `0` (device may not report all fields)
300+
- [ ] `user-scalable=no` in viewport meta (prevents unwanted pinch-zoom in WebView)
301+
- [ ] No `overflow: auto` on `body` / `html` — use `overflow: hidden` to avoid scroll bouncing on iOS
302+
- [ ] External CDN links avoided — QZ may run offline; bundle assets or use `../chartjs/` shared libs
303+
- [ ] Tested with simulated data before connecting to a real device
304+
305+
---
306+
307+
## Distributing a community dashboard
308+
309+
A dashboard is a plain folder. To share:
310+
1. Zip the folder: `zip -r my-dashboard.zip my-dashboard/`
311+
2. The recipient unzips it into `<QZ app-data>/dashboards/`
312+
3. The name appears automatically in **Settings → General Options → Custom Dashboard**
313+
314+
No restart required — the picker reads the filesystem at open time.

src/inner_templates/bike-pro/app.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -201,9 +201,9 @@ function connectWS(port) {
201201
wsSocket.onmessage = (ev) => {
202202
try {
203203
const msg = JSON.parse(ev.data);
204-
// QZ sends a flat object with all metrics
205-
if (typeof msg === 'object' && !Array.isArray(msg)) {
206-
applyData(msg);
204+
// QZ sends {msg: "workout", content: {...metrics...}}
205+
if (msg && msg.msg === 'workout' && msg.content) {
206+
applyData(msg.content);
207207
}
208208
} catch(_) {}
209209
};

0 commit comments

Comments
 (0)