An Arduino firmware for the Seeed Studio XIAO BLE (nRF52840) that acts as a wireless bridge between a Kettler Ride 300 / situs fitness stationary trainer and Apple Watch. It makes the trainer's power and cadence data visible natively in Apple Watch workouts, without any proprietary app.
The board runs as a BLE central (connecting to the trainer) and a BLE peripheral (advertising to Apple Watch) simultaneously. Apple Watch sees it as a standard Cycling Power + Cadence sensor and picks it up automatically when starting an Indoor Cycling workout.
This project was built with the assistance of Claude Code. It is a personal side project, not a commercial product. I am sharing the code as I got stuck with this. Maybe there is a niche of 2-3 other people out there who would like to do this as well. This repository might help them to get started. Use at your own risk. No guarantees are made regarding reliability or safety for any purpose. I am not planning to extend/develop this much further. Only if for myself a need comes up to do so.
Released under the MIT License.
- Board: Seeed Studio XIAO BLE nRF52840
- Power: USB-C — plug into any USB wall adapter and leave it running permanently
- Connection to Mac: USB-C (for flashing firmware and the Serial Monitor during first-time pairing)
Once commissioned, no manual steps are needed:
- Plug in the bridge (or leave it always powered)
- Turn on the trainer — select the Bluetooth screen on the trainer display
- On Apple Watch, start an Indoor Cycling workout
- Within a few seconds the watch sees the bridge as a power/cadence sensor
- Start pedaling — power and cadence appear on the watch
The bridge auto-connects to the trainer and auto-reconnects if either side drops. A hardware watchdog resets the chip automatically if the BLE stack ever locks up.
The trainer requires a 6-digit PIN pairing the first time. After that, the bond is stored in flash and PIN entry is never needed again.
Steps:
- Set
CLEAR_BONDS 1at the top ofRide.ino, flash - Open the Serial Monitor at 115200 baud
- Power the trainer and select its Bluetooth screen
- The bridge connects and subscribes to the trainer's auth characteristic (
1531), triggering the PIN flow - The trainer display shows a 6-digit code — type it into Serial Monitor and press Enter
- Serial output confirms
[AUTH] Pairing complete — bond stored - Start pedaling to verify data flows
- Set back to
CLEAR_BONDS 0and flash again to ensure the bond isn't wiped on the next power up
If something goes wrong (e.g. trainer disconnects immediately with reason 0x13), set CLEAR_BONDS 1, reflash, then repeat from step 1.
./build.shRequires arduino-cli with the Seeeduino nRF52 board support installed. The script compiles, detects the USB port, and uploads. If it can't find the port, double-tap the reset button on the board to enter DFU mode and re-run.
brew install arduino-cli
arduino-cli config init
arduino-cli config add board_manager.additional_urls \
https://files.seeedstudio.com/arduino/package_seeeduino_boards_index.json
arduino-cli core update-index
arduino-cli core install Seeeduino:nrf52Connect at 115200 baud to see status. Output is minimal during normal riding.
| Prefix | When |
|---|---|
[BOOT] |
Startup |
[SCAN] |
Scanning for / found trainer |
[CONN] |
Trainer connected / disconnected, service discovery |
[HAND] |
Handshake characteristic (1531) subscribe status |
[AUTH] |
Pairing events (PIN prompt, complete, failed) |
[PERI] |
Apple Watch connected / disconnected / subscribed |
[DATA] |
Trainer data timeout (stopped pedaling) |
[IDLE] |
Status heartbeat every 10s when not riding |
Trainer (situs fitness / Kettler Ride 300)
│
│ BLE — central role on nRF52840
│ Scan for "situs fitness" → connect
│ Subscribe: 1531 (auth trigger), 2A63 (power), 2A5B (cadence)
│
nRF52840 (XIAO BLE)
│
│ BLE — peripheral role on nRF52840
│ Advertise as "Ride 300"
│ Cycling Power Service (0x1818) + CSC Service (0x1816)
│
Apple Watch (Indoor Cycling workout)
| Service UUID | Name |
|---|---|
1818 |
Cycling Power |
1816 |
Cycling Speed & Cadence |
00001530-1212-EFDE-1523-785FEABCD123 |
Vendor (auth) |
Characteristics subscribed:
| UUID | Service | Data |
|---|---|---|
2A63 |
1818 |
Power (sint16 W) + optional crank data |
2A5B |
1816 |
Crank revolutions (uint16) + crank event time (uint16, 1/1024s) |
00001531-1212-EFDE-1523-785FEABCD123 |
Vendor | Auth trigger — subscribe on connect to initiate PIN flow |
Advertised name: Ride 300
Cycling Power Service (0x1818)
| Characteristic | UUID | Properties | Value |
|---|---|---|---|
| CP Measurement | 2A63 |
Notify | 8 bytes — see packet layout below |
| CP Feature | 2A65 |
Read | 0x00000008 (crank revolution data supported) |
| Sensor Location | 2A5D |
Read | 0x0D (rear hub) |
Cycling Speed & Cadence Service (0x1816)
| Characteristic | UUID | Properties | Value |
|---|---|---|---|
| CSC Measurement | 2A5B |
Notify | 5 bytes — see packet layout below |
| CSC Feature | 2A5C |
Read | 0x0002 (crank revolution data supported) |
| Sensor Location | 2A5D |
Read | 0x0D (rear hub) |
Cycling Power Measurement 2A63 (8 bytes)
| Bytes | Field | Value |
|---|---|---|
| 0–1 | Flags | 0x0020 (crank revolution data present) |
| 2–3 | Instantaneous power | sint16, little-endian, watts |
| 4–5 | Cumulative crank revolutions | uint16, little-endian, wraps at 65535 |
| 6–7 | Last crank event time | uint16, little-endian, 1/1024 s units |
CSC Measurement 2A5B (5 bytes)
| Bytes | Field | Value |
|---|---|---|
| 0 | Flags | 0x02 (crank data present, no wheel data) |
| 1–2 | Cumulative crank revolutions | uint16, little-endian, wraps at 65535 |
| 3–4 | Last crank event time | uint16, little-endian, 1/1024 s units |
Source priority: crank event time is taken from 2A5B (CSC); 2A63 (CPS) is the authoritative source for power and is also used as fallback for crank event time if CSC reports zero.
Cumulative crank revolution counters are never reset between workouts — this is correct per BLE spec. Apple Watch computes cadence from the delta between successive values.
| Parameter | Value |
|---|---|
| Notify rate to Apple Watch | 4 Hz (250 ms cap) |
| Trainer data timeout | 5 s — stops notifications if no trainer packets |
| Idle status log | Every 10 s (Serial only, when not riding) |
| Watchdog timeout | 8 s — resets chip if loop stalls |
Leaving the bridge plugged into a USB wall adapter is the recommended setup. The board draws only a few milliamps at idle. Both BLE roles stay active continuously:
- Peripheral advertising restarts automatically if Apple Watch disconnects
- Central scanning restarts automatically if the trainer disconnects
- The hardware watchdog resets the chip if the BLE stack locks up (typically after days of uptime)
Use a stable wall adapter rather than a laptop USB port, which may cut power during sleep.
| Constant | Default | Purpose |
|---|---|---|
CLEAR_BONDS |
0 |
Set to 1 + reflash to wipe trainer bond (use only to fix stale pairing) |
kTrainerTimeoutMs |
5000 |
ms of silence before data stream is paused |
kNotifyIntervalMs |
250 |
Minimum ms between Apple Watch notifications (4 Hz cap) |
kStatusIntervalMs |
10000 |
ms between idle status log lines |