This document covers the specific FTMS (Fitness Machine Service) characteristics and behaviors relevant to this project.
FTMS is a Bluetooth SIG standard (assigned number 0x1826) for fitness equipment. It defines how apps communicate with smart bikes, treadmills, rowers, etc.
Specification: Fitness Machine Service (FTMS) v1.0
| Characteristic | UUID | Properties | Purpose |
|---|---|---|---|
| Fitness Machine Feature | 0x2ACC | Read | Declares supported features |
| Indoor Bike Data | 0x2AD2 | Notify | Sends telemetry to app |
| Training Status | 0x2AD3 | Read, Notify | Current training state |
| Supported Resistance Level Range | 0x2AD6 | Read | Min/max/step for resistance |
| Fitness Machine Control Point | 0x2AD9 | Write, Indicate | Receives commands from app |
| Fitness Machine Status | 0x2ADA | Notify | Reports state changes |
| Service | UUID | Purpose |
|---|---|---|
| Generic Access | 0x1800 | Device name, appearance |
| Device Information | 0x180A | Manufacturer, model, firmware |
This read-only characteristic tells Kinomap what our device supports. It's a pair of 32-bit bitfields.
Bit Feature We set?
─── ───────────────────────────── ───────
0 Average Speed Supported No
1 Cadence Supported Yes
2 Total Distance Supported No
3 Inclination Supported Yes
4 Elevation Gain Supported No
5 Pace Supported No
6 Step Count Supported No
7 Resistance Level Supported Yes
8 Stride Count Supported No
9 Expended Energy Supported No
10 Heart Rate Supported No
11 Metabolic Equivalent Supported No
12 Elapsed Time Supported Yes
13 Remaining Time Supported No
14 Power Measurement Supported Yes
15 Force on Belt Supported No
16 User Data Retention Supported No
Bit Feature We set?
─── ──────────────────────────────────── ───────
0 Speed Target Supported No
1 Inclination Target Supported Yes ← KEY
2 Resistance Target Supported Yes ← KEY
3 Power Target Supported No
4 Heart Rate Target Supported No
5 Targeted Expended Energy Supported No
6 Targeted Steps Supported No
7 Targeted Strides Supported No
8 Targeted Distance Supported No
9 Targeted Training Time Supported No
10 Targeted Time in 2 HR Zones No
11 Targeted Time in 3 HR Zones No
12 Targeted Time in 5 HR Zones No
13 Indoor Bike Simulation Supported Yes ← KEY
14 Wheel Circumference Config No
15 Spin Down Control Supported No
16 Targeted Cadence Config Supported No
Notification characteristic sent at ~1Hz. Variable-length based on which fields are present, controlled by a flags field.
Bit Field Size We include?
─── ───────────────────────────── ─────── ───────────
0 More Data (inverted logic) - 0 (= speed present)
1 Average Speed Present uint16 No
2 Instantaneous Cadence uint16 Yes
3 Average Cadence uint16 No
4 Total Distance uint24 No
5 Resistance Level sint16 Yes
6 Instantaneous Power sint16 Yes
7 Average Power sint16 No
8 Expended Energy uint16x3 No
9 Heart Rate uint8 No
10 Metabolic Equivalent uint8 No
11 Elapsed Time uint16 Yes
12 Remaining Time uint16 No
Bytes Field Type Unit Resolution
───── ───────────────────── ──────── ──────────── ──────────
0-1 Flags uint16 - -
2-3 Instantaneous Speed uint16 km/h 0.01
4-5 Instantaneous Cadence uint16 rpm 0.5
6-7 Resistance Level sint16 unitless 1
8-9 Instantaneous Power sint16 watts 1
10-11 Elapsed Time uint16 seconds 1
This is where Kinomap sends commands. Write + Indicate characteristic.
| Opcode | Name | Parameters | Our action |
|---|---|---|---|
| 0x00 | Request Control | None | Grant control (respond 0x80, 0x00, 0x01) |
| 0x01 | Reset | None | Reset to initial state |
| 0x03 | Set Target Inclination | sint16 (0.1% resolution) | Map to resistance level |
| 0x04 | Set Target Resistance Level | uint8 (0.1 resolution) | Map to resistance level |
| 0x07 | Start or Resume | None | Begin session |
| 0x08 | Stop or Pause | uint8 (1=stop, 2=pause) | End/pause session |
| 0x11 | Set Indoor Bike Simulation | sint16 wind, sint16 grade, uint8 crr, uint8 cw | Use grade for resistance |
Every control point write gets an indication response:
Byte 0: 0x80 (Response Code)
Byte 1: Request Opcode (echo back)
Byte 2: Result Code (0x01 = Success, 0x02 = Not Supported, 0x03 = Invalid Parameter)
This is the primary command Kinomap uses for video rides:
Byte 0: 0x11 (opcode)
Bytes 1-2: Wind Speed (sint16, 0.001 m/s resolution)
Bytes 3-4: Grade (sint16, 0.01% resolution) ← THIS IS WHAT WE USE
Byte 5: Crr (uint8, 0.0001 resolution) — rolling resistance coefficient
Byte 6: Cw (uint8, 0.01 kg/m resolution) — wind resistance coefficient
The grade value is the terrain inclination. Example values:
-500= -5.00% (downhill)0= 0.00% (flat)800= +8.00% (steep uphill)
Based on community testing:
- Kinomap prefers Indoor Bike Simulation (0x11) over Set Target Inclination (0x03) for video rides
- Grade values typically range from -10% to +15% for cycling videos
- Kinomap expects regular telemetry notifications (~1Hz) or it may disconnect
- Control request (0x00) is always sent first — must be accepted before other commands work
- Kinomap scans for FTMS Indoor Bike — the advertisement must include the correct service UUID and appearance
For Kinomap to discover the device:
Advertisement Type: Connectable, Undirected
Service UUIDs: 0x1826 (Fitness Machine Service)
Local Name: "RPi Resistance Bridge" (configurable)
Appearance: 0x0481 (Indoor Bike) — IMPORTANT for Kinomap filtering
Flags: 0x06 (General Discoverable, BR/EDR Not Supported)
On Linux (RPi), BLE GATT servers are created via BlueZ's D-Bus API:
- Register a GATT application with
org.bluez.GattManager1.RegisterApplication - Register an advertisement with
org.bluez.LEAdvertisingManager1.RegisterAdvertisement - Implement characteristic read/write/notify callbacks as D-Bus objects
The dbus-next library provides async Python bindings for this.
All multi-byte FTMS values are little-endian as per Bluetooth specification.
Inclination and grade values are signed (sint16). In Python:
import struct
grade_raw = struct.unpack('<h', data[3:5])[0] # signed int16, little-endian
grade_percent = grade_raw * 0.01