Skip to content

Handlebar control of gears and workout intensity (TrainingPeaks Hub app, BikeControl fallback) #3

Open

Description

@BenA-SA

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._tcp with TXT txtvers=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 FedoraWorkstation zone 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._tcp resolves to FEDORA.tpvirtual.local → 172.17.0.1, the Docker bridge docker0 (link down, no containers), not the Wi-Fi address 192.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)

⚠️ The first connection dropped after 30 s with "Lost heartbeat". The phone reconnected by itself about 2.5 minutes later. The cause is unknown: possibly Hub backgrounded or the screen locked, or a flaky first connection. Watch whether it recurs during a ride. TPV also holds a UDP socket on *: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_hub runs before TPV launches in tpv/both modes, 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 named fedora would otherwise clash. If avahi is missing or a name clashes, it warns and TPV still starts.
  • run-tpv.sh: passes TPV_HOST_NAME, TPV_HUB_ADVERT (set to 0 to disable), and optional TPV_HUB_ADDR / TPV_HUB_NAME overrides.
  • Containerfile.full: adds avahi-utils. This needs an image rebuild.
  • verify.sh: check 6 reports each _tpvirtual._tcp IPv4 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

  1. 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 from feat/hub-readvert (merged with the Mesa change if that lands first), and run it with run-tpv.sh. Confirm the startup line ==> TrainingPeaks Hub advert: ..., ./verify.sh check 6 OK, and Hub connecting with no manual steps.
  2. Ride for 60+ minutes using Gear Up/Down and Difficulty −/+. Watch for "Lost heartbeat" disconnects, including after locking the phone or backgrounding Hub.
  3. 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._tcp plus 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/0x02 shift up/down, 0x30/0x31 difficulty 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)

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

  1. BenA-SA commented on Sep 15, 2026

    @BenA-SA
    OwnerAuthor

    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.

    • BikeControl runs on the phone, which pairs the Zwift Click (v1 or v2) over its own Bluetooth.
    • It exposes the controller to TPV using the OpenBikeControl network transport: an mDNS advert _openbikecontrol._tcp plus a TCP socket that TPV connects to.
    • That is the same pattern as DIRCON, which already works through Wine + host networking + the Avahi socket mount. Nothing Bluetooth has to cross into the container, and no image changes should be needed.

    Setup to try

    1. BikeControl on the phone: connect the Click, then Trainer connection → TrainingPeaks Virtual → Network (not Bluetooth; Wine has no BLE).
    2. TPV: connection screen → pair BikeControl.
    3. Map Click buttons in BikeControl, e.g. intensity -/+ or gear down/up.

    Likely gotchas in this setup

    • Same subnet. mDNS doesn't cross subnets. The garage and house Wi-Fi are different subnets (192.168.1.x vs 192.168.0.x), so the phone and laptop must be on the same one.
    • IPv6 link-local adverts. In i915 GPU hang freezes TPV mid-ride (Mesa 25.0.7 in container) #5 soak testing, TPV picked an unscoped fe80:: address from a DIRCON advert and failed with SocketException: 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.
    • Click v2 needs a daily encryption unlock; BikeControl 6.0+ handles it automatically (details).
    • Licence tiers: the 20 min/day limit on Free/Base appears to apply to BikeControl's own virtual shifting (driving the trainer directly), not to sending TPV actions. Using TPV's gearing/intensity actions should be unaffected; confirm in practice (pricing).

    Alternatives considered

    Option Verdict
    QZ zwift_click / zwift_play Not suitable. QZ consumes Click presses for its own gearing and doesn't forward them to TPV. The macOS project tpv-zclick needed a script to turn QZ events into keystrokes.
    Keyboard - / = Works today (TPV's intensity keys). A cheap wireless numpad on the bars, with the TPV window focused.
    BLE/BT media remote on the host Pair to Linux, remap to -/= with e.g. keyd. No app needed.
    DIY Linux OpenBikeControl device Feasible for Click v1 (the spec has Python examples: examples/python). Click v2's daily unlock makes it harder. Only worth it if the phone route fails.
    BikeControl Bluetooth mode Won't work: TPV's BLE is stubbed under Wine.

    Acceptance (unchanged)

    • Click presses change TPV intensity / gears mid-ride via BikeControl Network mode
    • Works after a container restart without re-pairing hassle
    • Trainer bridge (QZ/DIRCON) unaffected
  2. changed the title [-]Remote + / - control from the bike (Zwift Click or alternative)[/-] [+]Handlebar control of gears and workout intensity (Zwift Click via BikeControl, or alternative)[/+] on Sep 17, 2026
  3. changed the title [-]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)[/+] on Sep 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions