OpenBikeControl listener — qz as a BLE central to an OBC button box (#4791) - #4851
OpenBikeControl listener — qz as a BLE central to an OBC button box (#4791)#4851cauldnz wants to merge 2 commits into
Conversation
…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.
|
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. |
|
Thanks I will review it ASAP!
Roberto Viola
I sometimes trade time with my daughter to help. If this was valuable,
please support my time:
https://buymeacoffee.com/cagnulein
https://www.patreon.com/cagnulein/join
https://paypal.me/cagnulein
Software engineer and open source enthusiast, QZ Creator
http://robertoviola.cloud
http://qzfitness.com/
Il giorno dom 26 lug 2026 alle 08:10 Chris Auld ***@***.***>
ha scritto:
… *cauldnz* left a comment (cagnulein/qdomyos-zwift#4851)
<#4851 (comment)>
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.
—
Reply to this email directly, view it on GitHub
<#4851?email_source=notifications&email_token=AAALYWAK5K35KDMIKZPAFNL5GWOERA5CNFSNUABFM5UWIORPF5TWS5BNNB2WEL2JONZXKZKDN5WW2ZLOOQXTKMBYGIZTCMRRGE32M4TFMFZW63VKON2WE43DOJUWEZLEUVSXMZLOOSWGM33PORSXEX3DNRUWG2Y#issuecomment-5082312117>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/AAALYWC6EITRM3A45EIQFET5GWOERAVCNFSNUABFKJSXA33TNF2G64TZHMZDSNBRGU3TINZXHNEXG43VMU5TIOJXHEZTOMJQGMZ2C5QC>
.
You are receiving this because you are subscribed to this thread.Message
ID: ***@***.***>
|
@cauldnz mmm why this? i mean qz on ios is supporting bluetooth central |
|
You're right, my mistake — thanks for catching it.
No code change needed — Also thanks for the |
|
Hi @cauldnz on my side the code is mergable. Would you like to receive the ios version too to test this before merging it? |
|
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. |
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
obclistenerclass: connects to an OBC controller as a BLE central,subscribes to Button-State (service
d273f680-…, chard273f681-…), decodesthe
[0x01, id, state, …]wire format per the MIT OBC spec, and dispatches eachrising edge through
homeform's keyboard path — the same routes the keyboardshortcuts and #4785's SB20 buttons use.
Behind
obc_listener_enabled(default OFF, matchingdefault_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 messagethis listener decodes, written by a different author from the same MIT spec. I
cross-checked the two routines verbatim, producer-encode -> listener-decode:
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 matchingnever 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
obclisteneronly subscribes after itactually 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_lastStatefrom the characteristic's current value before subscribing, so thefirst REAL press is the first edge acted on.
Notes
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_QTis defined unconditionally, so iOS uses QtBluetooth central likeeverywhere else.) Tested on Linux/BlueZ and Android; not tested on an iOS
device. Modelled on the
zwiftclickremotepattern throughout.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.
producer) is not implemented here; happy to add it if useful.
connectedAndDiscovered()over the accumulated scan list, and discovery stopsonce 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.jsonupdated (entry +settingCount);allSettingsCountbumped 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.