Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 55 additions & 8 deletions PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,36 @@ OpenBikeControl defines standard button IDs for common actions. Device manufactu

#### Gear Shifting (0x01-0x0F)

| Button ID | Action | Description |
|-----------|------------|------------------------------------------|
| `0x01` | Shift Up | Increase virtual gear |
| `0x02` | Shift Down | Decrease virtual gear |
| `0x03` | Gear Set | Direct gear selection (use analog value) |
| Button ID | Action | Description |
|-----------|---------------|------------------------------------------------------------|
| `0x01` | Shift Up | Increase virtual gear |
| `0x02` | Shift Down | Decrease virtual gear |
| `0x03` | Gear Set | Direct gear selection, flat index (use analog value) |
| `0x04` | Chainring Set | Direct front chainring selection (use analog value) |
| `0x05` | Cassette Set | Direct rear cassette selection (use analog value) |

**Gear Selection Analog Values (0x03, 0x04, 0x05):**

The analog value encodes a direct, 1-based gear position, offset by +1 to avoid
colliding with the standard released/pressed values `0x00` and `0x01`:

- `0x00` = Released / no change
- `0x01` = Not used for gear selection; receivers MUST treat it as a no-op (same as `0x00`)
- `0x02-0xFF` = Gear position + 1 (`0x02` = gear 1, `0x03` = gear 2, ..., up to 254 positions)

The value is a direct index, **not** a percentage. Apps MUST NOT scale it and SHOULD
clamp values outside their supported gear range to the nearest valid gear.

**Flat vs. front/rear gearing:**

`0x03` addresses gears as a single linear sequence (1..N), which is how most trainer
apps model virtual shifting. Devices emulating a front/rear (N×M) drivetrain SHOULD
use `0x04` (chainring) and `0x05` (cassette), and MAY additionally include a flattened
`0x03` index in the same message for apps that only support the flat model (see
[Multiple Actions Per Button](#multiple-actions-per-button)). How an N×M drivetrain
flattens into a linear index (e.g. by concatenation, or ordered by effective gear
ratio) is the device's choice. Use the App Information message to determine which
button IDs the connected app supports.

#### Navigation (0x10-0x1F)

Expand All @@ -84,6 +109,16 @@ OpenBikeControl defines standard button IDs for common actions. Device manufactu
| `0x17` | Home | Return to home screen |
| `0x18` | Steer Left | Steer left in-game |
| `0x19` | Steer Right | Steer right in-game |
| `0x1A` | Brake | Apply brake (use analog value for strength) |

**Brake Analog Values:**
- `0x00` = Released / no braking
- `0x01` = Full braking (digital button press)
- `0x02-0xFF` = Analog braking strength (`0x02` = minimum, `0xFF` = maximum)

Braking is a single axis: trainer apps do not currently distinguish front and rear
brakes. Devices with two brake levers SHOULD report the stronger of the two levers.
`0x1B` is reserved for a second brake axis should the distinction become meaningful.

#### Social/Emotes (0x20-0x2F)

Expand All @@ -107,12 +142,24 @@ OpenBikeControl defines standard button IDs for common actions. Device manufactu
| `0x33` | Pause | Pause workout |
| `0x34` | Resume | Resume workout |
| `0x35` | Lap | Mark lap |
| `0x36` | Previous Internal | Skip to previous workout interval |
| `0x36` | Previous Interval | Skip to previous workout interval |
| `0x37` | U-Turn | Perform U-Turn |
| `0x38` | Change Mode | Toggle between modes, e.g. ERG and others |
| `0x39` | Take a break | Take a break |
| `0x3A` | Join another rider | "Teleport" to another rider |
| `0x3B` | Change route | Show route change selection |
| `0x3C` | Cruise Control | Toggle cruise control (use analog value for optional target power) |

**Cruise Control Analog Values:**
- `0x00` = Released
- `0x01` = Pressed — toggle cruise control on/off, engaging at the rider's current power
- `0x02-0x09` = Reserved; receivers MUST treat these values as a no-op
- `0x0A-0xFF` = Engage cruise control with an absolute target power of value × 5 watts (`0x64` = 500 W, `0xFF` = 1275 W)

Most devices SHOULD simply send `0x01` and let the app capture the rider's current
power as the setpoint; subsequent adjustments use `0x30`/`0x31` (Increase/Decrease
Difficulty). The absolute form is intended for devices with their own target-power UI.
Comment on lines +159 to +161
Apps SHOULD clamp target power values to the range they support.

#### View Controls (0x40-0x4F)

Expand Down Expand Up @@ -158,7 +205,7 @@ Hardware or software (such as BikeControl) can send **multiple actions per butto

A "Plus" button on a controller could send three different actions at once:
- `0x01` (Shift Up) - For apps that support virtual gear shifting
- `0x30` (ERG Up) - For apps that support ERG mode power adjustment
- `0x30` (Increase Difficulty) - For apps that support ERG mode power adjustment
- `0x14` (Select/Confirm) - For apps that use it for menu navigation

**Implementation:**
Expand All @@ -168,7 +215,7 @@ When a button is pressed, send a single button state message containing all rele
[0x01, 0x01, 0x01, 0x30, 0x01, 0x14, 0x01]
```

This means: Shift Up pressed (0x01, 0x01), ERG Up pressed (0x30, 0x01), and Select pressed (0x14, 0x01).
This means: Shift Up pressed (0x01, 0x01), Increase Difficulty pressed (0x30, 0x01), and Select pressed (0x14, 0x01).

**Benefits:**
- **Compatibility**: Works with any app that supports at least one of the actions
Expand Down
4 changes: 2 additions & 2 deletions examples/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,8 +211,8 @@ Both examples support the full OpenBikeControl button mapping:
- 0x21: Push to Talk

### Training Controls (0x30-0x3F)
- 0x30: ERG Up
- 0x31: ERG Down
- 0x30: Increase Difficulty
- 0x31: Decrease Difficulty
- 0x32: Skip Interval
- 0x33: Pause
- 0x34: Resume
Expand Down
28 changes: 25 additions & 3 deletions examples/python/protocol_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
0x01: "Shift Up",
0x02: "Shift Down",
0x03: "Gear Set",
0x04: "Chainring Set",
0x05: "Cassette Set",
Comment on lines 13 to +17
# Navigation (0x10-0x1F)
0x10: "Up",
0x11: "Down",
Expand All @@ -24,16 +26,18 @@
0x17: "Home",
0x18: "Steer Left",
0x19: "Steer Right",
0x1A: "Brake",
# Social/Emotes (0x20-0x2F)
0x20: "Emote",
0x21: "Push to Talk",
# Training Controls (0x30-0x3F)
0x30: "ERG Up",
0x31: "ERG Down",
0x30: "Increase Difficulty",
0x31: "Decrease Difficulty",
0x32: "Skip Interval",
0x33: "Pause",
0x34: "Resume",
0x35: "Lap",
0x3C: "Cruise Control",
# View Controls (0x40-0x4F)
0x40: "Camera View",
0x44: "HUD Toggle",
Expand Down Expand Up @@ -138,7 +142,25 @@ def format_button_state(button_id: int, state: int) -> str:
Formatted string
"""
button_name = BUTTON_NAMES.get(button_id, f"Button 0x{button_id:02X}")


# Special handling for gear selection buttons (0x03-0x05): direct 1-based index, not a percentage
if button_id in (0x03, 0x04, 0x05):
if state == 0:
return f"{button_name}: RELEASED"
if state == 1:
return f"{button_name}: NO-OP (reserved)"
return f"{button_name}: GEAR {state - 1}"

# Special handling for Cruise Control button (0x3C): value x 5 watts, not a percentage
if button_id == 0x3C:
if state == 0:
return f"{button_name}: RELEASED"
if state == 1:
return f"{button_name}: PRESSED (engage at current power)"
if state <= 0x09:
return f"{button_name}: NO-OP (reserved 0x{state:02X})"
return f"{button_name}: TARGET {state * 5} W"

# Special handling for Emote button (0x20)
if button_id == 0x20:
if state in EMOTE_VALUES:
Expand Down
24 changes: 22 additions & 2 deletions examples/python/test_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,27 @@ def test_format_button_state():
# Analog max (255)
result = format_button_state(0x10, 255)
assert "ANALOG" in result and "100%" in result, f"Unexpected format: {result}"


# Gear selection is a direct index, not a percentage
result = format_button_state(0x03, 0x02)
assert "Gear Set" in result and "GEAR 1" in result, f"Unexpected format: {result}"

result = format_button_state(0x05, 0x0D)
assert "Cassette Set" in result and "GEAR 12" in result, f"Unexpected format: {result}"

result = format_button_state(0x03, 0x01)
assert "NO-OP" in result, f"Unexpected format: {result}"

# Cruise control target power is value x 5 watts
result = format_button_state(0x3C, 0x64)
assert "Cruise Control" in result and "500 W" in result, f"Unexpected format: {result}"

result = format_button_state(0x3C, 0x01)
assert "Cruise Control" in result and "PRESSED" in result, f"Unexpected format: {result}"

result = format_button_state(0x3C, 0x05)
assert "NO-OP" in result, f"Unexpected format: {result}"

print(" ✓ All format_button_state tests passed")


Expand All @@ -99,7 +119,7 @@ def test_button_names():
assert 0x10 in BUTTON_NAMES, "Up (0x10) missing"
assert 0x14 in BUTTON_NAMES, "Select/Confirm (0x14) missing"
assert 0x20 in BUTTON_NAMES, "Emote (0x20) missing"
assert 0x30 in BUTTON_NAMES, "ERG Up (0x30) missing"
assert 0x30 in BUTTON_NAMES, "Increase Difficulty (0x30) missing"

print(" ✓ All button name mapping tests passed")

Expand Down