From e069c21f7af0189621674eed62df71193c891a08 Mon Sep 17 00:00:00 2001 From: Jonas Bark Date: Fri, 3 Jul 2026 08:10:53 +0200 Subject: [PATCH 1/2] Define gear selection encoding; add Brake and Cruise Control buttons Driven by manufacturer feedback on ambiguities in the button mapping: - 0x03 Gear Set: define the analog value as a direct 1-based gear index (0x02 = gear 1), not a percentage - 0x04 Chainring Set / 0x05 Cassette Set: new IDs for devices emulating NxM front/rear drivetrains, with guidance on coexisting with the flat 0x03 index via multi-action messages - 0x1A Brake: single analog braking axis (stronger lever wins on two-lever devices; 0x1B reserved for a future second axis) - 0x3C Cruise Control: digital toggle engaging at current power, with an optional absolute target encoded as value x 5 watts - Fix "Previous Internal" -> "Previous Interval" typo Co-Authored-By: Claude Fable 5 --- PROTOCOL.md | 58 ++++++++++++++++++++++++++---- examples/python/protocol_parser.py | 4 +++ 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/PROTOCOL.md b/PROTOCOL.md index 0bad734..9da9d74 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -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 so that the +values `0x00` and `0x01` keep their standard meaning: + +- `0x00` = Released / no change +- `0x01` = Not used for gear selection (reserved) +- `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) @@ -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) @@ -107,12 +142,23 @@ 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-0xFF` = Engage cruise control with an absolute target power of value × 5 watts (`0x0A` = 50 W, `0x64` = 500 W, up to 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. +Apps SHOULD clamp target power values to the range they support. #### View Controls (0x40-0x4F) diff --git a/examples/python/protocol_parser.py b/examples/python/protocol_parser.py index 8ae4bbf..ae6f28d 100644 --- a/examples/python/protocol_parser.py +++ b/examples/python/protocol_parser.py @@ -13,6 +13,8 @@ 0x01: "Shift Up", 0x02: "Shift Down", 0x03: "Gear Set", + 0x04: "Chainring Set", + 0x05: "Cassette Set", # Navigation (0x10-0x1F) 0x10: "Up", 0x11: "Down", @@ -24,6 +26,7 @@ 0x17: "Home", 0x18: "Steer Left", 0x19: "Steer Right", + 0x1A: "Brake", # Social/Emotes (0x20-0x2F) 0x20: "Emote", 0x21: "Push to Talk", @@ -34,6 +37,7 @@ 0x33: "Pause", 0x34: "Resume", 0x35: "Lap", + 0x3C: "Cruise Control", # View Controls (0x40-0x4F) 0x40: "Camera View", 0x44: "HUD Toggle", From a1dee304de4a9f72f520dd7bddcbc6ddab49dc31 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 09:15:12 +0000 Subject: [PATCH 2/2] Address review feedback on gear/brake/cruise control spec - Define cruise control absolute target range as 0x0A-0xFF with 0x02-0x09 reserved as no-op, removing the contradictory 50 W minimum wording - Clarify that gear-selection value 0x01 is a no-op for receivers instead of claiming it keeps its standard 'pressed' meaning - Use the Training Controls table names (Increase/Decrease Difficulty) for 0x30/0x31 consistently in the Multiple Actions example, parser, and README - Format gear selection (0x03-0x05) as a direct gear index and cruise control (0x3C) as target watts in format_button_state, with tests Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Xb1g4o9rUri1NRC1DZUVb4 --- PROTOCOL.md | 13 +++++++------ examples/python/README.md | 4 ++-- examples/python/protocol_parser.py | 24 +++++++++++++++++++++--- examples/python/test_examples.py | 24 ++++++++++++++++++++++-- 4 files changed, 52 insertions(+), 13 deletions(-) diff --git a/PROTOCOL.md b/PROTOCOL.md index 9da9d74..334e74d 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -74,11 +74,11 @@ OpenBikeControl defines standard button IDs for common actions. Device manufactu **Gear Selection Analog Values (0x03, 0x04, 0x05):** -The analog value encodes a direct, 1-based gear position, offset by +1 so that the -values `0x00` and `0x01` keep their standard meaning: +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 (reserved) +- `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 @@ -153,7 +153,8 @@ brakes. Devices with two brake levers SHOULD report the stronger of the two leve **Cruise Control Analog Values:** - `0x00` = Released - `0x01` = Pressed — toggle cruise control on/off, engaging at the rider's current power -- `0x02-0xFF` = Engage cruise control with an absolute target power of value × 5 watts (`0x0A` = 50 W, `0x64` = 500 W, up to 1275 W) +- `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 @@ -204,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:** @@ -214,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 diff --git a/examples/python/README.md b/examples/python/README.md index 05f9a72..15b58c4 100644 --- a/examples/python/README.md +++ b/examples/python/README.md @@ -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 diff --git a/examples/python/protocol_parser.py b/examples/python/protocol_parser.py index ae6f28d..23b14be 100644 --- a/examples/python/protocol_parser.py +++ b/examples/python/protocol_parser.py @@ -31,8 +31,8 @@ 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", @@ -142,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: diff --git a/examples/python/test_examples.py b/examples/python/test_examples.py index 65444aa..b3ace96 100755 --- a/examples/python/test_examples.py +++ b/examples/python/test_examples.py @@ -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") @@ -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")