diff --git a/PROTOCOL.md b/PROTOCOL.md index 0bad734..334e74d 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 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) @@ -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,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. +Apps SHOULD clamp target power values to the range they support. #### View Controls (0x40-0x4F) @@ -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:** @@ -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 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 8ae4bbf..23b14be 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,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", @@ -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: 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")