Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Containerfile.full
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ ENV DEBIAN_FRONTEND=noninteractive
RUN dpkg --add-architecture i386 \
&& apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl gnupg tar zstd cabextract xvfb procps iproute2 \
avahi-utils \
libqt5bluetooth5 libqt5charts5 libqt5core5t64 libqt5dbus5 libqt5gui5 \
libqt5location5 libqt5multimedia5 libqt5network5 libqt5networkauth5 \
libqt5positioning5 libqt5qml5 libqt5quick5 libqt5sql5 libqt5sql5-sqlite \
Expand Down
38 changes: 35 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,13 @@ Configure via environment:
| `TPV_GPU` | `auto` | `intel`, `nvidia`, `none` |
| `STATE` | `~/.local/share/tpv-full` | Where the Wine prefix and game data live |
| `TZ` | host zone (`timedatectl`) | Time zone for TPV's HUD clock; set to override |
| `TPV_NETWORK` | `pasta` | `host` uses host networking (needed for trainers that advertise themselves on the LAN, #13) |
| `TPV_LAN_IF` | default-route interface | The only interface TPV sees under `pasta` |
| `TPV_HUB_ADVERT` | `1` | `0` skips the TrainingPeaks Hub re-advert (see below) |
| `TPV_HUB_ADDR` | default-route IPv4 | Address the Hub app should connect to |
| `TPV_HUB_NAME` | `<hostname> (LAN)` | Name of the re-advertised TPV instance |

`verify.sh` checks the five things that actually matter: the Bluetooth backend
`verify.sh` checks the five bridge things that actually matter: the Bluetooth backend
chosen, the trainer connection, the DIRCON listeners, whether the mDNS advert
carries a reachable host address, and whether the ports emit real DIRCON frames
rather than merely accepting sockets.
Expand Down Expand Up @@ -163,6 +168,31 @@ Not a container bug: TPV mis-renders custom routes where two legs run within
script that shifts a course a few metres to fix it
(`tools/gpx-separate-legs.py`), and an AI prompt that does the same.

### TrainingPeaks Hub app cannot find TPV

The [Hub companion app](https://help.trainingpeaks.com/hc/en-us/articles/34618989898765-TrainingPeaks-Virtual-Hub-App)
(chat, live stats, and a remote with gear and workout-difficulty buttons) finds
TPV over mDNS (`_tpvirtual._tcp`) and connects in on **TCP 7779**. TPV
advertises an address for **every** interface it can see, and Hub only tries the
first one. If that's a Docker bridge (`172.17.0.1`) or a VPN address the phone
can't reach, Hub shows "start a ride" for the ~127 s TCP connect timeout before
it moves on.

So `run-tpv.sh` runs the container on **pasta networking bound to the LAN
interface** (`TPV_LAN_IF`, by default the one carrying the default route). TPV
then sees only that interface, and `-p 7779:7779` forwards Hub's connection in.
Under pasta, TPV's own advert doesn't reach the phone, so the entrypoint
publishes one through the host's avahi-daemon, pointing at the LAN address.
Measured on cold starts of Hub: ~132 s on host networking with Docker running,
3–4 s under pasta.

Check with `./verify.sh` (check 6) during a ride. Override the address with
`TPV_HUB_ADDR` if the default route is not the network your phone is on.
`TPV_NETWORK=host` restores host networking (for trainers that advertise
themselves on the LAN, see #13). The phone must be on the same subnet, and the host
firewall must allow TCP 7779 and UDP 5353 (Fedora Workstation's default zone
already does).

### Heart rate appears to come from the trainer

If `heart_rate_belt_name` is unset, QZ binds no HR sensor and TPV falls back to
Expand Down Expand Up @@ -191,9 +221,11 @@ it to the trainer. Set `QZ_HR_BELT` and pair `Wahoo HRM` in TPV explicitly.
|---|---|
| `--userns=keep-id` | Container uid is 0 in its userns, so libdbus `EXTERNAL` auth claims uid 0 while the bus sees the real peer uid via `SO_PEERCRED`. Auth hangs: `Did not receive a reply` |
| `--security-opt label=disable` | SELinux blocks the host D-Bus socket: `Permission denied`. `:z` cannot work — relabelling the host socket is `operation not permitted` |
| `--network=host` | DIRCON binds the container netns, so the mDNS advert carries an address TPV cannot reach |
| `--network=pasta:-i,<LAN if>` | TPV advertises Docker/VPN addresses too, and Hub waits ~127 s on an unreachable one. pasta copies the host's LAN address into the container, so QZ's DIRCON advert still carries a reachable address (the old `--network=host` requirement was about private container addresses) |
| `-p 7779:7779` | Hub can't connect in to TPV under pasta |
| `--hostname` | TPV takes its name from the container ID (e.g. `E04007E15893`) instead of the host |
| writable `HOME` and workdir | QZ spins on failed debug-log writes and never gets past discovery. Looks like a hang |
| D-Bus socket mount | No BlueZ at all: `Cannot find a running Bluez` |
| D-Bus socket mount | No BlueZ at all: `Cannot find a running Bluez`. Also how the Hub re-advert reaches avahi-daemon |
| Avahi socket mount | No mDNS advert, so TPV never discovers the bridge |
| Session bus mount | Connect Garmin / Strava / TrainingPeaks buttons do nothing: Wine's `xdg-open` has no browser to reach. The image's `xdg-open` forwards links to the host browser through the OpenURI portal |
| `BLUETOOTH_FORCE_DBUS_LE_VERSION` | Qt probes bluetoothd's version by executing its path *inside* the container, where it is absent; the `"4.0"` fallback selects a legacy raw-L2CAP backend that cannot connect |
Expand Down
203 changes: 203 additions & 0 deletions docs/adr/0001-re-advertise-tpv-for-the-hub-app.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
# 0001. Run TPV on pasta networking and re-advertise `_tpvirtual._tcp` so the Hub app connects promptly

- **Status:** Accepted
- **Date:** 2026-09-17
- **Deciders:** Ben Atkinson
- **Feature / area:** hub-app-discovery
- **Builds on:** none — first ADR for hub-app-discovery
- **Supersedes / Superseded by:** none

## What problem were we trying to solve?

We wanted handlebar control of TPV's virtual gears and workout intensity (#3).
TPV runs under Wine, which has no BLE, so a Zwift Click can't pair directly.
TrainingPeaks' own companion app, **Hub**, has an In Game remote with Gear
Up/Down and Difficulty −/+, and it needs no extra hardware or licence.

Hub finds TPV over mDNS (`_tpvirtual._tcp`, TXT `txtvers=1`) and connects
**in** to TPV on TCP 7779. Every network path this container already relied on
(DIRCON from QZ, BikeControl) runs the other way, with TPV discovering and
connecting out.

On the laptop, Hub's Live tab kept saying "start a ride" during an active ride.
We found:

- The firewall wasn't the cause: Fedora Workstation's default zone allows
1025–65535 and IPv4 multicast.
- TPV under Wine *did* listen on `0.0.0.0:7779`, and *did* publish its own
advert through its own 5353 sockets.
- But the advert resolved to **`172.17.0.1`**, the address of `docker0` (link
down, Docker running on the host), not the Wi-Fi address. Hub found TPV and
couldn't reach it. This is the same kind of fault as the DIRCON advert
address problem: TPV under Wine picks the wrong adapter's address.

The fix had to work for every user of the repo, whatever bridges they have
(Docker, libvirt, Waydroid, Tailscale), without touching TPV, which is
proprietary.

## What did we try?

### Attempt 1: open firewall ports ❌ rejected up front

TrainingPeaks' troubleshooting says to open TCP 7779 and UDP 5353. Checking the
laptop showed both were already allowed, so the firewall was never the blocker.

### Attempt 2: manual host-side re-advert ✅ proved the fix

On the laptop, without touching the running container:

```
avahi-publish -a -R tpv-laptop.local 192.168.0.146
avahi-publish-service -H tpv-laptop.local "FEDORA LAN" _tpvirtual._tcp 7779 txtvers=1
```

Hub connected at once. TPV's log showed `accepted new connection from
192.168.0.105` / `Authentication Success`, and the In Game remote moved the
rider. At the time we concluded that **Hub picks the reachable entry even while
TPV's broken `FEDORA` advert is still published**. Attempt 5 showed that was
wrong: Hub was already open when the re-advert appeared, and a newly discovered
service is what makes it reconnect. The one
open question is a single "Lost heartbeat" drop 30 s into the first connection;
the phone reconnected by itself.

This is manual and doesn't survive a reboot, so it isn't a solution for other users.

### Attempt 3: remove the bridge so TPV advertises correctly ❌ not chosen

Stopping Docker or deleting `docker0` might make TPV pick the Wi-Fi address. We
rejected this as the fix because:

- It changes the user's host.
- It only covers Docker, not the other bridges that could win instead.
- It needs a TPV restart to test (that would have interrupted the #5 soak session).
- It relies on undocumented Wine adapter-enumeration order.

It's still worth investigating as a root cause, but the re-advert is needed either way.

### Attempt 4: publish from `run-tpv.sh` on the host ❌ not chosen

This would avoid an image change. But `run-tpv.sh` `exec`s podman, so it would
have to stop doing that and manage background publishers and traps itself. It
would also depend on the host having `avahi-tools` installed. Publishing inside
the container ties the advert's lifetime to TPV's automatically.

### Attempt 5: ship the re-advert on its own ❌ Hub still waited ~130 s

The end-to-end test of the first version of this PR (TPV on `--network=host`
plus the re-advert) failed. Hub kept saying "start a ride". While diagnosing it,
we changed the advert name (`fedora (LAN)` → `FEDORA LAN`), which looked like a
fix but was a red herring: every connection came right after *some* new advert
appeared while Hub was open, whatever its name.

With adb on the phone we could see Android's NSD log
(`dumpsys servicediscovery`) and the phone's `netstat`. On three cold starts of
Hub (force-stop, wait 20 s so the NSD cache expires, launch):

| Cold start | Hub resolved first | Phone's first attempt | Connected |
|---|---|---|---|
| 1 | `FEDORA` [172.17.0.1, 100.89.193.106, 192.168.0.146] | `172.17.0.1:7779` SYN_SENT | +132 s |
| 2 | same | same | +133 s |
| 3 | same | same | +133 s |

**Hub connects to the first address of the first service it resolves, and
waits out Android's ~127 s TCP connect timeout** before trying anything else.
TPV advertises an address for every interface Wine reports, and the re-advert
can't change which service Hub resolves first.

### Attempt 6: delete `docker0` ⚠️ diagnostic only

With `docker0` deleted and TPV restarted, TPV's advert became
[100.89.193.106, 192.168.0.146], and Hub connected in 3–4 s on three cold starts,
via Tailscale. That confirmed the cause. It isn't a fix: it changes the host, a
phone not on Tailscale would hit the same timeout on the next address, and
Docker Compose `br-*` bridges would bring it back.

### Attempt 7: hide interfaces from Wine with an `LD_PRELOAD` shim ❌ not chosen

Wine enumerates adapters with `if_nameindex()` and addresses with `getifaddrs()`,
so a preload library could filter them. We dropped it in favour of standard
container networking, which needs no custom native code.

### Attempt 8: pasta networking bound to the LAN interface ✅ chosen

`--network=pasta:-i,wlp0s20f3 -p 7779:7779` gives the container a copy of the
host's LAN address only. Wine then reports just `wlp0s20f3` and `lo`. Three cold
starts of Hub with `docker0` and Tailscale both present on the host:

| Cold start | Hub resolved | Connected |
|---|---|---|
| 1 | `FEDORA LAN` [192.168.0.146] | +3 s |
| 2 | same | +4 s |
| 3 | same | +3 s |

TPV's own advert doesn't reach the phone under pasta, so the re-advert went from
a nice-to-have to **required**.

## What did we land on, and why?

**Networking:** `run-tpv.sh` runs the container with
`--network=pasta:-i,<LAN if>`, `-p 7779:7779` and `--hostname <host>`, where the
LAN interface is `TPV_LAN_IF` or the default-route interface. `qz` mode and
`TPV_NETWORK=host` keep host networking. `--hostname` keeps TPV's name as the
host's; otherwise it becomes the container ID.

**Re-advert:** `scripts/entrypoint.sh` runs `advertise_hub` just before launching
TPV, and stops it after `wineserver -w`:

- **Address:** `TPV_HUB_ADDR`, or the source address of the default route
(`ip -4 route get 1.1.1.1`).
- **Records:** A record `<hostname>-tpv-<last octet>.local`. The octet prevents
clashes between hosts with the same name; the desktop and laptop are both
`fedora`. Service record `<hostname> (LAN)._tpvirtual._tcp` port 7779,
`txtvers=1`.
- **How it publishes:** `avahi-publish` / `avahi-publish-service` (new
`avahi-utils` package in the image) talk to the **host's** avahi-daemon over
the system D-Bus socket the container already mounts for BlueZ.
- **Failure handling:** if avahi is unavailable or a name clashes, it prints a
warning and TPV still starts. `TPV_HUB_ADVERT=0` disables it.
- **Checking:** `verify.sh` check 6 flags each `_tpvirtual._tcp` IPv4 entry as a
host LAN address or not.

This was chosen because it's measured on real hardware (Attempt 8), uses only
standard podman networking, needs no host changes, and works whatever bridges or
VPNs the host has.

```mermaid
sequenceDiagram
participant E as entrypoint.sh (container)
participant A as host avahi-daemon
participant T as TPVirtual.exe (Wine)
participant H as Hub app (phone)
E->>A: D-Bus: publish A record + _tpvirtual._tcp → LAN IP:7779
E->>T: launch TPV (pasta: sees only the LAN interface)
A-->>H: advert (LAN IP only)
H->>T: TCP connect host LAN IP:7779 → pasta -p forward → TPV
Note over H,T: Version, Authenticate, remote commands
T-->>E: wineserver -w returns
E->>A: stop publishers (records withdrawn)
```

We'd revisit this if Hub starts trying every resolved address (reported to
TrainingPeaks), or if TPV starts advertising only reachable addresses.

## What does this cost us?

- **Image rebuild:** `avahi-utils` is added to the runtime apt layer, which
invalidates the Wine and prefix layers after it.
- **Network-based trainers:** under pasta, inbound LAN multicast probably
doesn't reach TPV, so trainers that advertise themselves on the network (a
Wi-Fi KICKR, a remote DIRCON bridge) may not be discovered.
`TPV_NETWORK=host` is the workaround; tracked in #13.
- **`verify.sh`:** checks 3 and 5 now run inside the container under pasta,
because QZ's ports live in its network namespace.
- **Address fixed at start-up:** if the host changes networks mid-ride (say,
garage vs house Wi-Fi, which are different subnets), the advert is stale
until TPV restarts. Multi-homed hosts may need `TPV_HUB_ADDR`.
- **IPv4 only:** deliberate, given TPV's unscoped `fe80::` bug seen with DIRCON
(#5) and BikeControl (bikecontrol#366).
- **More host coupling:** the container now also publishes on the host's mDNS,
on top of reading it.
- **Follow-up:**
- A real ride in `both` mode (QZ + trainer) under pasta.
- A 60-minute ride watching for "Lost heartbeat" drops.
- #13: network-based trainers under pasta.
21 changes: 20 additions & 1 deletion run-tpv.sh
Original file line number Diff line number Diff line change
Expand Up @@ -84,18 +84,37 @@ if [ "$MODE" != qz ] && command -v gnome-session-inhibit >/dev/null; then
echo "==> screen blank/suspend inhibited while the container runs"
fi

# pasta shows TPV only the LAN interface, so it stops advertising Docker/VPN addresses Hub can't reach (#3).
NETWORK=(--network=host)
if [ "${TPV_NETWORK:-pasta}" = pasta ] && [ "$MODE" != qz ]; then
LAN_IF="${TPV_LAN_IF:-$(ip -4 route get 1.1.1.1 2>/dev/null \
| awk '{for (i = 1; i < NF; i++) if ($i == "dev") { print $(i + 1); exit }}' || true)}"
if [ -n "$LAN_IF" ]; then
NETWORK=(--network="pasta:-i,${LAN_IF}" --hostname "$(hostname -s)" -p 7779:7779)
echo "==> network: pasta on ${LAN_IF} (TPV sees only this interface)"
else
echo "==> WARNING: no default-route interface found; falling back to host networking" >&2
fi
fi
[ "${NETWORK[0]}" = --network=host ] && echo "==> network: host"

HUB=(-e "TPV_HOST_NAME=$(hostname -s)" -e "TPV_HUB_ADVERT=${TPV_HUB_ADVERT:-1}")
[ -n "${TPV_HUB_ADDR:-}" ] && HUB+=(-e "TPV_HUB_ADDR=${TPV_HUB_ADDR}")
[ -n "${TPV_HUB_NAME:-}" ] && HUB+=(-e "TPV_HUB_NAME=${TPV_HUB_NAME}")

podman rm -f tpv >/dev/null 2>&1 || true
exec "${INHIBIT[@]}" podman run --rm --name tpv \
--userns=keep-id \
--security-opt label=disable \
--network=host \
"${NETWORK[@]}" \
"${GPU[@]}" "${AUDIO[@]}" \
"${DISPLAY_ARGS[@]}" \
"${TIMEZONE[@]}" \
"${HOST_LINKS[@]}" \
-e "QZ_TRAINER=${TRAINER}" \
-e "QZ_HR_BELT=${HR_BELT}" \
-e "TPV_DIRECT=${TPV_DIRECT:-0}" \
"${HUB[@]}" \
-v "$STATE:/state" \
-v /run/dbus/system_bus_socket:/run/dbus/system_bus_socket \
-v /var/run/avahi-daemon:/var/run/avahi-daemon \
Expand Down
46 changes: 46 additions & 0 deletions scripts/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ set -euo pipefail

MODE="${1:-both}"
STATE="${TPV_HOME:-/state}"
HUB_ADVERT_PIDS=()
export WINEPREFIX="$STATE/wineprefix"
export WINEARCH=win64 WINEDEBUG="${WINEDEBUG:--all}"
export WINEDLLOVERRIDES="${WINEDLLOVERRIDES:-mscoree,mshtml=}"
Expand Down Expand Up @@ -80,10 +81,54 @@ sync_timezone() {
printf '%s' "$current" > "$marker"
}

lan_address() {
# No route yet (pasta still settling) must yield an empty string, not kill the script.
ip -4 route get 1.1.1.1 2>/dev/null \
| awk '{for (i = 1; i < NF; i++) if ($i == "src") { print $(i + 1); exit }}' \
|| true
}
Comment thread
sentry[bot] marked this conversation as resolved.

advertise_hub() {
# TPV's own _tpvirtual._tcp advert can carry a bridge address (docker0) the Hub app cannot reach (#3).
[ "${TPV_HUB_ADVERT:-1}" = "1" ] || return 0
local addr host_label record name
addr="${TPV_HUB_ADDR:-$(lan_address)}"
if [ -z "$addr" ]; then
echo "==> WARNING: no LAN address found; the TrainingPeaks Hub app may not find TPV" >&2
return 0
fi
host_label=$(printf '%s' "${TPV_HOST_NAME:-tpv}" | tr -c 'A-Za-z0-9-' '-')
record="${host_label}-tpv-${addr##*.}.local"
name="${TPV_HUB_NAME:-${TPV_HOST_NAME:-TPV} (LAN)}"
avahi-publish -a -R "$record" "$addr" &
HUB_ADVERT_PIDS=($!)
avahi-publish-service -H "$record" "$name" _tpvirtual._tcp 7779 txtvers=1 &
HUB_ADVERT_PIDS+=($!)
sleep 2
# bash's kill builtin returns 0 if ANY pid is live, so check them one at a time.
for pid in "${HUB_ADVERT_PIDS[@]}"; do
if kill -0 "$pid" 2>/dev/null; then
continue
fi
stop_hub_advert
HUB_ADVERT_PIDS=()
echo "==> WARNING: TrainingPeaks Hub advert failed (no avahi-daemon, or a name clash);" >&2
echo " set TPV_HUB_NAME / TPV_HUB_ADDR, or TPV_HUB_ADVERT=0 to skip" >&2
return 0
done
echo "==> TrainingPeaks Hub advert: '${name}' -> ${addr}:7779"
}

stop_hub_advert() {
[ ${#HUB_ADVERT_PIDS[@]} -gt 0 ] || return 0
kill "${HUB_ADVERT_PIDS[@]}" 2>/dev/null || true
}

run_tpv() {
seed_prefix
ensure_tpv
sync_timezone
advertise_hub
# TPV refuses a direct launch of the game exe ("must be launched using the
# TPVirtual-Launcher"), so the launcher is the only supported path. Do not
# exec it: it hands off to the game and exits, which would take the
Expand All @@ -95,6 +140,7 @@ run_tpv() {
wine "$WINEPREFIX/$TPV_LAUNCHER" || true
echo "==> launcher exited; waiting on any TPV process it started"
wineserver -w
stop_hub_advert
echo "==> all wine processes finished"
}

Expand Down
Loading
Loading