Repository navigation
Handlebar control of gears and workout intensity (TrainingPeaks Hub app, BikeControl fallback) #3
Description
Goal
Change TPV's virtual gears and workout intensity from the handlebars mid-ride, without reaching for the keyboard.
Preferred: TrainingPeaks' own Hub companion app on a phone mounted on the bars. It's free, first-party and needs no extra hardware.
Fallback: a Zwift Click through BikeControl, or a cheap Bluetooth remote.
Why it isn't straightforward
TPV runs under Wine, and Wine has no working BLE (see README, "Why a bridge at all"). TPV can't pair a Zwift Click directly, for the same reason it can't pair the trainer directly. Anything that reaches TPV has to arrive over the network or as keystrokes.
Keyboard controls (corrected)
The earlier version of this issue said + / - controlled intensity. That was wrong (shortcuts, workout modes):
| Key | Effect |
|---|---|
| Page Up / Page Down | Workout intensity ±1% |
+ / = and - |
Base trainer resistance, or slope modifier when ERG is off |
| Virtual gears | Not listed in the help pages; check F1 in game |
Option 1 (preferred): TrainingPeaks Hub app
TrainingPeaks Hub (iOS, Android) is TPV's equivalent of Zwift Companion: chat, live stats, events, and a remote.
Remote → In Game tab (layout depends on mode: spectating, normal ride, workout, racing; see the help article screenshots):
- Riding: Pos. Left / U Turn / Pos. Right, View, Join Rider, Break, Screenshot, Mark Lap, Routes, Change Turn, Cruise, Teleport, Gear Up / Gear Down
- Workout: Previous / Next / Pause, Resistance (mode), Difficulty − / +
- The Remote tab is a D-pad for menu navigation.
So Hub covers both virtual gears and workout intensity. The remaining gap is only that these are touchscreen taps, not physical buttons.
How it connects
- Requirements: phone and PC on the same Wi-Fi, both apps logged in to the same athlete account, and an active ride in TPV.
- TPV advertises itself over mDNS as
<hostname>._tpvirtual._tcpwith TXTtxtvers=1, and listens on TCP 7779. Hub finds the advert and connects in. This is the opposite direction to DIRCON and BikeControl, where TPV finds and connects to the other device.
Findings so far (2026-09-17, laptop, tpv-full:mesa26 container)
- ✅ Firewall is not a blocker. firewalld is active, but the
FedoraWorkstationzone allows 1025–65535/tcp+udp plus IPv4 multicast. nftables, iptables and ufw are inactive. - ✅ TPV under Wine listens on 7779, on all addresses (
[::ffff:0.0.0.0]:7779, wineserver). - ✅ TPV under Wine publishes its own mDNS advert. It binds UDP 5353 alongside avahi-daemon (per-interface sockets plus a wildcard), and the advert shows up in
avahi-browse. - ❌ TPV's own advert carries the wrong address.
FEDORA._tpvirtual._tcpresolves toFEDORA.tpvirtual.local→172.17.0.1, the Docker bridgedocker0(link down, no containers), not the Wi-Fi address192.168.0.146. That happens on every interface it's advertised on. With only this advert, Hub found TPV but couldn't reach it, and the Live tab said "start a ride with TrainingPeaks Virtual". This is the same kind of fault as the DIRCON advert problem: TPV under Wine picks the wrong adapter's address. ⚠️ TPV's per-interface 5353 sockets (%wlp0s20f3,%docker0,%tailscale0) have ~210 KB stuck in their receive queues, so TPV may not be reading them. It's harmless so far, but may matter if Hub relies on query/response rather than announcements.
✅ Workaround confirmed: host-side re-advert
With TPV in an active ride, Hub connects, the Live tab shows the ride, and the In Game remote works: Pos. Left moved the rider on screen.
On the laptop: ESTAB [::ffff:192.168.0.146]:7779 ← 192.168.0.105 (the phone), owned by TPVirtual.exe. TPV's own advert only ever pointed at 172.17.0.1, so the connection came through the re-advert. Hub picks the reachable entry even while the broken FEDORA one is still advertised.
TPV's log (Documents/TPVirtual/LogFiles/playerlog0.txt in the prefix; timestamps UTC) shows the Hub protocol:
Network service peering thread started / Network service broadcast thread started (at TPV start)
10:29:43 Network service peering - accepted new connection from: [::ffff:192.168.0.105]:41233
10:29:43 Network service message: Version / Hub App version: 1 / Authenticate / Authentication Success
10:30:14 Network service peer ...:41233 - Lost heartbeat
10:30:14 Network service peer ...:41233 - Closing connection
10:32:38 Network service peering - accepted new connection from: [::ffff:192.168.0.105]:46007
10:32:38 ... Authentication Success (this connection then worked for the remote)
*:54321 (probably the "broadcast thread"), whose role is unknown.
Running on the laptop as transient user units, alongside the #5 soak session and without touching the container:
systemd-run --user --unit=tpv-hub-addr --collect avahi-publish -a -R tpv-laptop.local 192.168.0.146
systemd-run --user --unit=tpv-hub-svc --collect avahi-publish-service -H tpv-laptop.local "FEDORA LAN" _tpvirtual._tcp 7779 txtvers=1
Stop it with systemctl --user stop tpv-hub-addr tpv-hub-svc. These are transient units, so they won't survive a reboot. They're superseded by the repo change below once it's deployed.
Repo change: PR #8 (branch feat/hub-readvert, open, not merged)
- Decision record:
docs/adr/0001-re-advertise-tpv-for-the-hub-app.md. scripts/entrypoint.sh:advertise_hubruns before TPV launches intpv/bothmodes, and the advert is withdrawn when TPV exits. It publishes<hostname> (LAN)._tpvirtual._tcp→<hostname>-tpv-<last octet>.local→ the host's default-route IPv4 address (ip route get), port 7779,txtvers=1, through the host avahi-daemon over the already-mounted system D-Bus. The last octet is there because two hosts namedfedorawould otherwise clash. If avahi is missing or a name clashes, it warns and TPV still starts.run-tpv.sh: passesTPV_HOST_NAME,TPV_HUB_ADVERT(set to0to disable), and optionalTPV_HUB_ADDR/TPV_HUB_NAMEoverrides.Containerfile.full: addsavahi-utils. This needs an image rebuild.verify.sh: check 6 reports each_tpvirtual._tcpIPv4 entry as OK (a host LAN address) or BAD.- README: new env vars, and a Known issues entry for "Hub app cannot find TPV".
Tested on the desktop: the real advertise_hub code ran in a Debian trixie + avahi-utils container with --userns=keep-id --network=host and the D-Bus/Avahi mounts, using a dummy service type so the live Hub session wasn't disturbed. The advert appeared on the host and was withdrawn on stop. Not yet tested end to end with TPV and Hub.
Next steps
- After the i915 GPU hang freezes TPV mid-ride (Mesa 25.0.7 in container) #5 soak test: stop the manual units on the laptop (
systemctl --user stop tpv-hub-addr tpv-hub-svc), rebuild the image fromfeat/hub-readvert(merged with the Mesa change if that lands first), and run it withrun-tpv.sh. Confirm the startup line==> TrainingPeaks Hub advert: ...,./verify.shcheck 6 OK, and Hub connecting with no manual steps. - Ride for 60+ minutes using Gear Up/Down and Difficulty −/+. Watch for "Lost heartbeat" disconnects, including after locking the phone or backgrounding Hub.
- Optional, fixing the cause: work out why TPV under Wine advertises
docker0's address. The re-advert is harmless either way.
Limits even if it works
- A touchscreen tap, not a physical button. It's fine for intensity changes but awkward for frequent shifting under effort.
- For physical buttons, use one of the options below alongside Hub.
Option 2 (fallback): BikeControl on the phone, network mode
Only needed if physical handlebar buttons are wanted, or if Hub proves unreliable.
TPV has a native BikeControl integration, network (recommended) or Bluetooth, with 28 actions including Shift Up/Down and Increase/Decrease Difficulty. We already have a BikeControl licence.
- BikeControl holds the Click over the phone's Bluetooth. It exposes it via OpenBikeControl: mDNS
_openbikecontrol._tcpplus a TCP socket. - The phone is the TCP server and TPV connects out (PROTOCOL.md), the same direction as DIRCON, so it's lower-risk under Wine than Hub. There's no handshake or authentication. Button IDs:
0x01/0x02shift up/down,0x30/0x31difficulty up/down. - No container or firewall changes are needed.
Known problems:
- IPv6 link-local bug (confirmed). TPV connects to the advertised
fe80::address without the scope ID (bikecontrol#366), the same fault as DIRCON in i915 GPU hang freezes TPV mid-ride (Mesa 25.0.7 in container) #5. BikeControl now advertises IPv4 only, but that shipped on Windows first. If Android still advertises IPv6, re-advertise from the host behind an IPv4-only A record (avahi-publish -a+avahi-publish-service -H). - Dropouts: "No Signal" and mid-ride controller drops (#371), slow shifting in one setup (#305).
- Click v2: either the daily Zwift unlock, or BikeControl's no-unlock "right side only" mode (TROUBLESHOOTING).
- Licence tiers: the 20 min/day Free/Base limit appears to apply to BikeControl's own virtual shifting, not to sending actions to TPV (pricing). Unconfirmed.
Test: update BikeControl → Trainer connection → TrainingPeaks Virtual → Network. On the host, avahi-browse -rpt _openbikecontrol._tcp (IPv4 only?). Pair BikeControl in TPV and map shift and difficulty. Ride for 60+ minutes.
Other alternatives
| Option | Verdict |
|---|---|
BT remote on the host + keyd |
Physical buttons with no phone. Pair a Bluetooth HID remote to Linux and map it to PgUp/PgDn and the gear keys. keyd works at the evdev level, below Wayland, so keys reach the Wine window. TPV must have focus. |
| DIY OpenBikeControl server on the host | Python + bleak holds the Click, plus an asyncio TCP server and an IPv4-only avahi-publish advert. About 150–250 lines. Click v1 is simple (makinolo, jat255/zwift_click_handling); v2 needs the daily unlock. |
| BikeControl running on the Linux host | Not viable. No Linux release, its mDNS dependency nsd has no Linux support, and newer releases use a non-commercial licence. |
| BikeControl Bluetooth mode | Won't work: TPV's BLE is stubbed under Wine. |
QZ zwift_click / zwift_play |
Not suitable. QZ uses presses for its own gearing and doesn't forward them to TPV (tpv-zclick needed a keystroke shim). |
Possible repo changes (depending on results)
- Done in PR Make the TrainingPeaks Hub app connect in seconds: pasta networking + LAN re-advert #8:
_tpvirtual._tcpre-advert in the entrypoint,verify.shcheck 6, README. - If BikeControl is ever used: a
verify.shcheck that_openbikecontrol._tcpadverts are IPv4-only.
Unknowns that need the real hardware
- Why the first Hub connection lost its heartbeat, and whether it recurs.
- The exact gear hotkeys (F1 in game).
Acceptance
- Hub connects to TPV in the container and the remote controls the game (manually confirmed 2026-09-17 with a host re-advert)
- Re-advert automated in the repo, so it works for every user (PR Make the TrainingPeaks Hub app connect in seconds: pasta networking + LAN re-advert #8; awaiting end-to-end test)
- Workout intensity can be changed mid-ride without the keyboard (Hub Difficulty −/+)
- Virtual gears can be changed mid-ride without the keyboard (Hub Gear Up/Down)
- Survives a 60+ minute ride without dropouts
- Reconnects after a container restart without re-pairing hassle
- Trainer bridge (QZ/DIRCON) unaffected
Activity
BenA-SA commentedon Sep 15, 2026
Research: use BikeControl over the network (TPV supports it natively)
Recommended route: TPV has a built-in BikeControl integration with 28 mappable actions, including virtual gear up/down, workout intensity +/-, steering, camera, and map/HUD toggles (TPV help). We already have a BikeControl licence, so the next step is just trying it.
_openbikecontrol._tcpplus a TCP socket that TPV connects to.Setup to try
Likely gotchas in this setup
192.168.1.xvs192.168.0.x), so the phone and laptop must be on the same one.fe80::address from a DIRCON advert and failed withSocketException: Invalid argument. If BikeControl's advert includes IPv6 link-local addresses, TPV may hit the same bug. Workaround if needed: a host-side re-advert of the phone's service behind an IPv4-only A record (avahi-publish -a+avahi-publish-service -H), the same fix used for the soak-test trainer.Alternatives considered
zwift_click/zwift_play-/=-/=with e.g.keyd. No app needed.Acceptance (unchanged)
[-]Remote + / - control from the bike (Zwift Click or alternative)[/-][+]Handlebar control of gears and workout intensity (Zwift Click via BikeControl, or alternative)[/+][-]Handlebar control of gears and workout intensity (Zwift Click via BikeControl, or alternative)[/-][+]Handlebar control of gears and workout intensity (TrainingPeaks Hub app, BikeControl fallback)[/+]Advertise TPV to the TrainingPeaks Hub app with a routable address
Advertise TPV to the TrainingPeaks Hub app with a routable address
Advertise TPV to the TrainingPeaks Hub app with a routable address
Advertise TPV to the TrainingPeaks Hub app with a routable address
Make the TrainingPeaks Hub app connect in seconds: pasta networking +…