Write down what the pods actually did
Four findings from an evening on the hardware, three of which contradict what this document said. The undocumented `0000010x` characteristics are TI OAD firmware update: their `0x2901` descriptors read "Img Identify", "Img Block" and "OAD Extended Control". The table called them "undocumented, silent so far", which invites probing them to see what answers — and the answer is that `Img Block` takes firmware. They are silent because OAD says nothing until an update is running. Marked do-not-write. A pod offering a public key does not mean it wants encryption. The `−` pod sent a compressed P-256 point in the same session as 304 cleartext button frames. That was read twice as "it has switched to the encrypted path", and neither reading survived the capture it came from. The crypto section now says so, because the mistake is an easy one and it cost an evening. TASK-0(d) is satisfied and RISK-9 is retired. Every button on both pods registers on the bit §2.3.1 documents: D-pad 0-3 and `−` on bit 8 across four cycles, `A`/`B`/`Y`/`Z` on 4-7 and `+` on bit 12 over 83 presses. QZ's `wontfix` for the left `−` does not reproduce here, so OQ-10 needs no answer and gradient control stays on the left pod. The evening the left pod appeared dead was a SAF-9 violation, now §7.1. A half-closed link streams battery and no buttons, which is indistinguishable from broken hardware — and it was diagnosed as a dead pod, a wrong bit map, mis-filed pods and a lapsed unlock before anyone looked at what the previous run had failed to close. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+53
-6
@@ -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+
|
||||
|
||||
|
||||
Reference in New Issue
Block a user