Skip to content

feat: add Wahoo KickRun treadmill driver - #4834

Open
cagnulein wants to merge 9 commits into
masterfrom
feat/wahoo-kickrun-treadmill
Open

feat: add Wahoo KickRun treadmill driver#4834
cagnulein wants to merge 9 commits into
masterfrom
feat/wahoo-kickrun-treadmill

Conversation

@cagnulein

Copy link
Copy Markdown
Owner

Summary

  • Adds dedicated wahookickruntreadmill driver for the Wahoo KickRun treadmill (previously routed through horizonTreadmill which only supports inclination)
  • Protocol reverse-engineered from HCI btsnoop log: proprietary BLE over a single Write-Without-Response + Notify characteristic (handle 0x0023)
  • Implements the full init sequence (01 10 12 18 80 86 f0), device-ID handshake (87 + [4-byte ID]), and workout-mode unlock (19 00)
  • Speed command: 02 [μm/s LE24] 00 [flag] where flag = 0xFF for first command of session, 0x0A for subsequent; confirmed against captured traces at 2.4, 4.8 and 5.0 km/h
  • Handles the hardware challenge-response safety interlock: treadmill sends FD E0 01 [XX YY] after user presses the physical paddle, driver immediately echoes E0 [XX YY] to confirm
  • Telemetry parsed from characteristic 0x0026: FF 01 [tgt_b0 b1 b2] 00 … — bytes 2–4 are the commanded speed in μm/s LE24 (confirmed from trace)
  • Note: the physical paddle push before each speed change is enforced in treadmill firmware and cannot be bypassed via BLE configuration commands (all init commands are read-only queries or one-shot unlocks)

Test plan

  • Build compiles on Android/iOS/Linux/macOS
  • Device named KICKR RUN * is picked up by the new driver (not routed to horizonTreadmill)
  • Init sequence completes and connectedAndDiscovered fires
  • Speed commands reach the treadmill after paddle push
  • Telemetry updates Speed metric correctly
  • Reconnect after BLE drop resets state and re-inits

🤖 Generated with Claude Code

Implements the proprietary Wahoo KickRun BLE protocol reverse-engineered
from HCI snoop traces:

- Speed command: 02 [uint24-LE μm/s] 00 [flag] on the write+notify char
- Challenge-response: device sends FD E0 01 [code] after paddle push,
  driver echoes E0 [code] immediately without waiting
- Init sequence: capability queries 01 10 12 18 80 86 f0; 86 response
  carries a 4-byte device ID that is echoed back via cmd 87
- Workout unlock: 19 00 sent once before the first speed command
- Telemetry: FF 01 [tgt μm/s LE24] 00 [cur μm/s LE24] ... parsed for
  speed/distance/kcal

Removes KICKR RUN from the generic horizonTreadmill branch so the device
gets its own driver instead of falling back to FTMS-only control.

NOTE: the physical paddle push on the treadmill is still required for each
speed change - it is a hardware safety mechanism that generates the BLE
challenge code. QZ responds to the challenge automatically, eliminating
the ~6 s delay introduced by the Wahoo app.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@cagnulein

Copy link
Copy Markdown
Owner Author

Stephan Ben. by email

cagnulein and others added 8 commits July 21, 2026 14:57
…etter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…untreadmill

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The driver picked the first WriteNoResponse+Notify characteristic found
during service discovery, which is a026e002 (handle 0x0019) — the Wahoo
debug/console stream. The actual Wahoo command channel is a026e03e at
handle 0x0023, confirmed from both the QZ char-discovery log and the
HCI btsnoop trace of the official Wahoo app.

With the wrong char selected, all init/speed/workout-unlock writes were
silently accepted by the debug stream but never reached the treadmill
controller, so speed stayed zero and QZ entered the auto-pause loop.

Fix: match the command characteristic by UUID instead of by property
heuristic. The debug-stream UUID (a026e002) also has WriteNoResponse+
Notify, so a property-only search always returns the wrong one first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Override changeSpeed() to base subsequent rapid presses on the
  already-pending requestSpeed rather than the stale telemetry speed.
  This prevents multiple quick taps all sending the same speed value.

- After sending a BLE speed command, set Speed = requestSpeed optimistically
  and set paddlePending = true.  This makes the speed tile update immediately
  in the UI instead of waiting for the belt to physically move.

- While paddlePending is true, telemetry speed notifications are ignored
  so they don't overwrite the displayed commanded speed.  paddlePending is
  cleared when the KICKR RUN issues its paddle-push challenge (FD E0 01).

- Changed the speed-equality check to use abs() > 0.01 (km/h) to avoid
  repeated BLE writes caused by floating-point rounding in the telemetry
  µm/s conversion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant