Skip to content

OpenBikeControl listener — qz as a BLE central to an OBC button box (#4791) - #4851

Open
cauldnz wants to merge 2 commits into
cagnulein:masterfrom
cauldnz:pr/obc-listener
Open

OpenBikeControl listener — qz as a BLE central to an OBC button box (#4791)#4851
cauldnz wants to merge 2 commits into
cagnulein:masterfrom
cauldnz:pr/obc-listener

Conversation

@cauldnz

@cauldnz cauldnz commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Implements #4791: the symmetric half of the OBC work. #4504 made qz an OBC
producer; this makes it a consumer, so any OBC controller can feed qz's
existing button->action dispatch.

New obclistener class: connects to an OBC controller as a BLE central,
subscribes to Button-State (service d273f680-…, char d273f681-…), decodes
the [0x01, id, state, …] wire format per the MIT OBC spec, and dispatches each
rising edge through homeform's keyboard path — the same routes the keyboard
shortcuts and #4785's SB20 buttons use.

Behind obc_listener_enabled (default OFF, matching default_zwift_click /
default_thinkrider_controller / default_cycplus_bc2_controller). Bike only,
one controller.

OBC ids are already semantic, so it works out of the box — Shift Up -> gear_up,
ERG Up/Down -> power_up/down, Lap -> lap — and each id is overridable per user
via the obc_button_<id> setting.

Verified on air

Tested two ways.

1. Against qz'''s own OBC producer (#4504) — an independent implementation.
MyWhooshLink::sendButtonStateMessage() encodes the same ButtonState message
this listener decodes, written by a different author from the same MIT spec. I
cross-checked the two routines verbatim, producer-encode -> listener-decode:

01 01 01        -> button 1  (ShiftUp)
01 01 00        -> nothing   (release correctly ignored)
01 02 01        -> button 2  (ShiftDown)
01 16 01        -> button 22 (NavMenu)
01 01 01 02 01  -> button 1 | button 2   (multi-button in one message)

5/5. The constants agree too (MessageTypeButtonState = 0x01, ShiftUp 0x01,
ShiftDown 0x02, NavMenu 0x16). So the two halves of the OBC work in qz
interoperate at the byte level — which is the point of #4791.

2. On air against a real ESP32 OBC controller (my own build, offered in
#4791): advertise -> discover -> connect -> subscribe -> notify -> decode ->
dispatch, with real button presses on real hardware.

The honest gap: no third-party OBC hardware exists yet that I know of, so the
on-air test used my own producer. The #4504 cross-check above is what covers the
independence concern — that encoder is not mine.

obclistener: connecting to OBC controller OBC-SB20
obclistener: connected, discovering services
obclistener: subscribed to OBC Button-State
obclistener: button 48 -> power_up (0x30 ERG Up)
obclistener: button 1 -> gear_up (0x01 Shift Up)
obclistener: button 2 -> gear_down (0x02 Shift Down)
obclistener: button 53 -> lap (0x35 Lap)

Two things found while testing on hardware

1. Discovery matches the advertised name as well as the service UUID. My
first cut matched only on the OBC service UUID, on the reasoning that every OBC
controller advertises it. That turns out not to hold: a 128-bit UUID rarely fits
the 31-byte advert alongside the name, so real controllers carry the service in
their GATT table but advertise their identity by NAME. My own board advertises
0x1818 + a vendor UUID and no OBC UUID at all — service-UUID-only matching
never found it. It now matches the UUID or an OBC- name prefix.

The name match is deliberately narrow and safe: it is opt-in (default off), only
runs once a BIKE is connected, and obclistener only subscribes after it
actually discovers the OBC service on the connected device — so a stray name
match connects, finds no OBC service, and harmlessly does nothing. Flagging it
explicitly given AGENTS.md's rule about adding name patterns to bluetooth.cpp;
happy to drop it if you would rather not carry the prefix.

2. A stale press fired on every connect (fixed here). Button-State is
Read/Notify and RETAINS the last value, so service discovery handed us whatever
was last pressed — potentially minutes earlier — and the edge detector fired it
as a live press. In practice: connecting to a controller whose last press was
"ERG down" silently dropped the rider's target power. Fixed by seeding
m_lastState from the characteristic's current value before subscribing, so the
first REAL press is the first edge acted on.

Notes

  • Platform support. Uses QtBluetooth's central role, with no platform
    branch — so it should behave the same on Android, desktop/BlueZ and iOS.
    (An earlier version of this description wrongly claimed iOS was unsupported;
    IO_UNDER_QT is defined unconditionally, so iOS uses QtBluetooth central like
    everywhere else.) Tested on Linux/BlueZ and Android; not tested on an iOS
    device. Modelled on the zwiftclickremote pattern throughout.
  • The per-button mapping (obc_button_) is settings-only for now; qz as an OpenBikeControl listener — consume OBC controllers into the button→action dispatch #4791 lists
    a mapping UI in scope and I am happy to add one — I suspect you will have a
    view on where it belongs, same question as Stages SB20: surface the handlebar shifter buttons for in-app control (target power ± / Peloton offset ±) #4785.
  • BLE transport only. The mDNS/TCP transport in the spec (and used by Add OpenBikeControl binary protocol support with UDP and mDNS advertising #4504's
    producer) is not implemented here; happy to add it if useful.
  • One ordering caveat worth knowing: the OBC block runs in
    connectedAndDiscovered() over the accumulated scan list, and discovery stops
    once the bike connects — so the controller must be advertising before qz
    connects the bike. On a fast-connecting bike I have seen the listener not
    attach for this reason. Powering the button box on first is enough, but say the
    word if you would prefer a retry on later discovery instead.
  • settings-catalog.json updated (entry + settingCount); allSettingsCount
    bumped 1001 -> 1002.

Stacks on #4850 (the SB20-buttons PR). #4791 said I would build this "once the SB20-buttons PR lands", so #4850 is the one to look at first — they touch different drivers and do not conflict, but that is the order I had proposed to you.

…utton box (cagnulein#4791)

Implements cagnulein#4791: the symmetric half of the OBC work. cagnulein#4504 made qz an OBC
*producer*; this makes it a *consumer*, so any OBC controller can feed qz's
existing button->action dispatch.

New `obclistener` class: connects to an OBC controller as a BLE central,
subscribes to Button-State (service `d273f680-…`, char `d273f681-…`), decodes
the `[0x01, id, state, …]` wire format per the MIT OBC spec, and dispatches each
rising edge through `homeform`'s keyboard path — the same routes the keyboard
shortcuts and cagnulein#4785's SB20 buttons use.

Behind `obc_listener_enabled` (default OFF, matching `default_zwift_click` /
`default_thinkrider_controller` / `default_cycplus_bc2_controller`). Bike only,
one controller.

OBC ids are already semantic, so it works out of the box — Shift Up -> gear_up,
ERG Up/Down -> power_up/down, Lap -> lap — and each id is overridable per user
via the `obc_button_<id>` setting.

## Verified on air

Tested two ways.

**1. Against qz'''s own OBC producer (cagnulein#4504) — an independent implementation.**
`MyWhooshLink::sendButtonStateMessage()` encodes the same ButtonState message
this listener decodes, written by a different author from the same MIT spec. I
cross-checked the two routines verbatim, producer-encode -> listener-decode:

    01 01 01        -> button 1  (ShiftUp)
    01 01 00        -> nothing   (release correctly ignored)
    01 02 01        -> button 2  (ShiftDown)
    01 16 01        -> button 22 (NavMenu)
    01 01 01 02 01  -> button 1 | button 2   (multi-button in one message)

5/5. The constants agree too (`MessageTypeButtonState = 0x01`, ShiftUp 0x01,
ShiftDown 0x02, NavMenu 0x16). So the two halves of the OBC work in qz
interoperate at the byte level — which is the point of cagnulein#4791.

**2. On air against a real ESP32 OBC controller** (my own build, offered in
cagnulein#4791): advertise -> discover -> connect -> subscribe -> notify -> decode ->
dispatch, with real button presses on real hardware.

The honest gap: no third-party OBC hardware exists yet that I know of, so the
on-air test used my own producer. The cagnulein#4504 cross-check above is what covers the
independence concern — that encoder is not mine.

  obclistener: connecting to OBC controller OBC-SB20
  obclistener: connected, discovering services
  obclistener: subscribed to OBC Button-State
  obclistener: button 48 -> power_up      (0x30 ERG Up)
  obclistener: button 1  -> gear_up       (0x01 Shift Up)
  obclistener: button 2  -> gear_down     (0x02 Shift Down)
  obclistener: button 53 -> lap           (0x35 Lap)

## Two things found while testing on hardware

**1. Discovery matches the advertised name as well as the service UUID.** My
first cut matched only on the OBC service UUID, on the reasoning that every OBC
controller advertises it. That turns out not to hold: a 128-bit UUID rarely fits
the 31-byte advert alongside the name, so real controllers carry the service in
their GATT table but advertise their identity by NAME. My own board advertises
`0x1818` + a vendor UUID and no OBC UUID at all — service-UUID-only matching
never found it. It now matches the UUID **or** an `OBC-` name prefix.

The name match is deliberately narrow and safe: it is opt-in (default off), only
runs once a BIKE is connected, and `obclistener` only subscribes after it
actually discovers the OBC service on the connected device — so a stray name
match connects, finds no OBC service, and harmlessly does nothing. Flagging it
explicitly given AGENTS.md's rule about adding name patterns to `bluetooth.cpp`;
happy to drop it if you would rather not carry the prefix.

**2. A stale press fired on every connect (fixed here).** Button-State is
Read/Notify and RETAINS the last value, so service discovery handed us whatever
was last pressed — potentially minutes earlier — and the edge detector fired it
as a live press. In practice: connecting to a controller whose last press was
"ERG down" silently dropped the rider's target power. Fixed by seeding
`m_lastState` from the characteristic's current value before subscribing, so the
first REAL press is the first edge acted on.

## Notes

- **iOS is not supported.** This uses QtBluetooth's central role (fine on
  Android + desktop/BlueZ); qz drives BLE through the native Swift wrapper on
  iOS, so that would need a bridge like `iOS_zwiftClickRemote`. Modelled on the
  `zwiftclickremote` pattern throughout.
- The per-button mapping (obc_button_<id>) is settings-only for now; cagnulein#4791 lists
  a mapping UI in scope and I am happy to add one — I suspect you will have a
  view on where it belongs, same question as cagnulein#4785.
- BLE transport only. The mDNS/TCP transport in the spec (and used by cagnulein#4504's
  producer) is not implemented here; happy to add it if useful.
- One ordering caveat worth knowing: the OBC block runs in
  `connectedAndDiscovered()` over the accumulated scan list, and discovery stops
  once the bike connects — so the controller must be advertising *before* qz
  connects the bike. On a fast-connecting bike I have seen the listener not
  attach for this reason. Powering the button box on first is enough, but say the
  word if you would prefer a retry on later discovery instead.
- `settings-catalog.json` updated (entry + `settingCount`); `allSettingsCount`
  bumped 1001 -> 1002.
@cauldnz

cauldnz commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

Just as a quick note. I'm still working on the button box, but hopefully will be able to support listening to the aux buttons on SRAM and Shimano electronic shifters and then rebroadcasting those as OBC. Will also investigate adding a mechanism to have those buttons control QZ as per the Stages SB20 buttons.

@cagnulein

cagnulein commented Jul 26, 2026 via email

Copy link
Copy Markdown
Owner

@cagnulein

Copy link
Copy Markdown
Owner
  • iOS is not supported. This uses QtBluetooth's central role (fine on
    Android + desktop/BlueZ); qz drives BLE through the native Swift wrapper on
    iOS, so that would need a bridge like iOS_zwiftClickRemote. Modelled on the
    zwiftclickremote pattern throughout.

@cauldnz mmm why this? i mean qz on ios is supporting bluetooth central

@cauldnz

cauldnz commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

You're right, my mistake — thanks for catching it.

IO_UNDER_QT is defined unconditionally in src/qdomyos-zwift.pri:70, so #if defined(Q_OS_IOS) && !defined(IO_UNDER_QT) never compiles and iOS uses QtBluetooth central like every other platform. I saw iOS_zwiftClickRemote in zwiftclickremote and assumed a native bridge was required, without checking whether that branch is actually built.

No code change needed — obclistener uses QLowEnergyController::createCentral with no platform branch. I've corrected the PR description; I've only tested on Linux/BlueZ and Android, so it's "should work, untested on iOS" rather than a claim.

Also thanks for the settings.qml fix — declaring it last is the documented rule and I got it wrong here.

@cagnulein

Copy link
Copy Markdown
Owner

Hi @cauldnz on my side the code is mergable. Would you like to receive the ios version too to test this before merging it?

@stale

stale Bot commented Aug 16, 2026

Copy link
Copy Markdown

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

@stale stale Bot added the wontfix This will not be worked on label Aug 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

wontfix This will not be worked on

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants