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:
2026-08-20 20:25:25 +02:00
co-authored by Claude Opus 5
parent a0190095fb
commit 87e97e104f
+53 -6
View File
@@ -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 03, 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 47 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+