diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 4a5e847..ebda221 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -140,7 +140,7 @@ hardware wins and the difference is called out below. | Async (notify) | `00000002-19CA-…` — device events | **Confirmed** | | Sync RX (write-without-response) | `00000003-19CA-…` — commands to device | **Confirmed** | | Sync TX (read/indicate) | `00000004-19CA-…` — responses | **Confirmed** | -| `00000100-…`, `00000101-…` (write/notify), `00000102-…` (write-without-response/notify) | undocumented, silent so far | **Confirmed present** | +| `00000100-…`, `00000101-…` (write/notify), `00000102-…` (write-without-response/notify) | **TI OAD firmware update — do not write.** Their `0x2901` descriptors name them: `Img Identify`, `Img Block`, `OAD Extended Control` | **Confirmed, read 2026-08-20** | | `00000006-…` | **absent** on our v2 | — | | Manufacturer ID | 2378 (`0x094A`) | **Confirmed** | | Device type byte | `0x0A` and `0x0B` — **one per pod, not a version marker** | **Confirmed** | @@ -217,9 +217,17 @@ open-ended and not obviously safe. **Virtual shifting is therefore emulated app- > that timeout — but a long ride is the real test. **Encryption — not required.** ✅ **A-3 holds for the v2.** The pod completed the handshake -and streamed button and battery events with **no key exchange and no encryption at all**. -The ECDH P-256 → HKDF → AES-256-CCM path documented below was therefore *not* needed, and -no crypto crate has been added. +and streamed button and battery events in cleartext, and the app has never sent a key. The +ECDH P-256 → HKDF → AES-256-CCM path documented below is therefore *not* needed, and no +crypto crate has been added. + +> **A pod offering a public key does not mean it wants encryption.** Captured on +> 2026-08-20: the `−` pod sent `ff 03 00 0a 21 02 …` — protobuf field 1, 33 bytes, a +> compressed P-256 point — **in the same session as 304 cleartext `0x23` button frames**. +> The key offer and the cleartext stream coexist. This cost an evening: a pod that had gone +> quiet for an unrelated reason was read as "it has switched to the encrypted path", twice, +> and neither reading survived contact with the capture. If a pod goes silent, the key +> frames are not the evidence — see the teardown leak in §7. | Stage | Mechanism | |-------|-----------| @@ -665,6 +673,35 @@ tests, and removes dependence on the trainer's internal mass assumptions. | SAF-8 | The shutdown sequence preempts in-flight BLE work. A connect or reconnect is abandoned, and every step of the reset is individually bounded, so SAF-2 finishes inside the NFR-9 budget rather than timing out | | SAF-9 | Every BLE link the app owns — trainer *and* controller — is explicitly closed on exit. A link the process merely abandons can leave the peripheral held and unreachable on the next launch (A-3) | +### 7.1 How SAF-9 was violated, and what it looked like *(fixed 2026-08-20)* + +Worth recording, because the symptom pointed everywhere except the cause. + +`Actor::teardown` unsubscribed every notifying characteristic before disconnecting, each +under its own `DISCONNECT_TIMEOUT` of 2 s. A Click carries **five** of them, so the exit +path could spend 10 s on optional work against the controller's 3 s shutdown budget. The +supervisor gave up first, the process exited, and `disconnect` was never reached — leaving +BlueZ holding the pod with **no application running**. Confirmed directly: both apps +stopped, `bluetoothctl devices Connected` still listing it. + +The next connect then inherited a half-dead session, and that is the part worth +remembering: **it streamed battery every ~5 s and no button frames at all.** A pod that +answers the handshake, reports battery, and ignores every press is indistinguishable from +broken hardware, and it was read that way — as a dead left pod (RISK-9), as a wrong bit +map, as pods filed into the wrong slot, and twice as a lapsed Zwift unlock. None of those +survived a capture. It also produced the connect failures that came with it, +`le-connection-abort-by-local` and `service discovery timed out`, and it came and went +depending on whether the previous exit had happened to time out. + +Three rules fall out of it: + +- **Nothing optional runs before the disconnect on an exit path.** Dropping the link clears + the peripheral's CCCDs anyway, so the unsubscribes were politeness toward a connection + about to be destroyed. The trainer's SAF-2 writes stay, because those are not optional. +- **A timed-out disconnect is logged with its consequence, not just its name.** The cost + lands on the *next* run, which is exactly why this hid for so long. +- **Android is the worse case.** A leaked link there survives the app being swiped away. + --- ## 8. Architecture @@ -720,7 +757,17 @@ resistance change. | **TASK-3** | **Characterise the D100 resistance curve** — required to map virtual gears onto it (FR-4.1) | | **TASK-4** | Android BLE spike (RISK-1). **Built, not yet proven on hardware:** the APK builds via btleplug's droidplug backend, and the manifest carries the Android 12+ runtime pair `BLUETOOTH_SCAN` + `BLUETOOTH_CONNECT` with `android:usesPermissionFlags="neverForLocation"` on the scan permission — which we can claim honestly only because every scan is filtered by service UUID — plus `ACCESS_FINE_LOCATION` capped at API 30 for older releases. `MainActivity` requests them at runtime before the first scan, not merely declares them. What remains is the actual spike: install on a phone and confirm a scan finds the D100 and both Click pods, and that a connect and a control-point write survive a screen-off | -> If TASK-0(d) fails on the left pod, resolve OQ-10 before building the button mapping. +> **TASK-0(d) satisfied — RISK-9 retired (2026-08-20).** Four connect/disconnect cycles +> against the left (`−`) pod with `probe zwift --buttons`: every button registers, on the +> bit §2.3.1 documents — D-pad `left`/`up`/`right`/`down` on bits 0–3, the `−` paddle on +> bit 8 (6, 30 and 14 presses across three rounds). The right pod was mapped the same way: +> `A`/`B`/`Y`/`Z` on bits 4–7 and the `+` paddle on bit 12, over 83 presses. Both halves of +> the bit table are now hardware-verified, and QS's `wontfix` for the left `−` does not +> reproduce here. **OQ-10 does not need answering** and the gradient mapping can stay on +> the left pod. +> +> The left pod did appear dead for most of an evening first. It was not: see the teardown +> leak in §7. ### Phase 1 — D100 + GUI + profiles ⭐ *(current focus)* @@ -760,7 +807,7 @@ FR-4.6 (ERG), FR-9.11/9.12, Android. | **RISK-6** | The unencrypted path (A-3) may not work on a v2, forcing full crypto immediately | Less schedule slack | TASK-0 tests it first; the crypto path is specified either way | | **RISK-7** | The unlock expires ~24 h, so a forgotten re-unlock blocks a ride | Frustration at session start | Detect and warn *before* the ride (FR-3.12), with a guided walkthrough (FR-3.10) | | **RISK-8** | D100 lacks `0x11` sim mode | Reduced fidelity | Low impact — the app owns the physics | -| **RISK-9** | **The left pod is where third-party support is weakest.** QZ has an open `wontfix` issue where the left Click's `−` never registers, and BikeControl's keep-alive covers only the right pod. All gradient and route control is mapped to the left pod | Gradient and route control lost; shifting-up survives | **TASK-0 proves both pods before the mapping is committed.** If the left pod is unreliable, remap onto the right pod and move gradient to a modifier gesture | +| ~~**RISK-9**~~ **RETIRED 2026-08-20** | The left pod was where third-party support was weakest — QZ has an open `wontfix` where the left Click's `−` never registers | — | **Did not reproduce.** Every button on both pods verified on its documented bit (§5.3). The one evening the left pod *did* look dead was the teardown leak in §7, not the pod | ### 10.1 On ANT+