|
| 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. |
0 commit comments