diff --git a/Containerfile.full b/Containerfile.full index 4db7ba0..c8ccd05 100644 --- a/Containerfile.full +++ b/Containerfile.full @@ -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 \ diff --git a/README.md b/README.md index 247422f..fe2660e 100644 --- a/README.md +++ b/README.md @@ -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` | ` (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. @@ -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 @@ -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,` | 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 | diff --git a/docs/adr/0001-re-advertise-tpv-for-the-hub-app.md b/docs/adr/0001-re-advertise-tpv-for-the-hub-app.md new file mode 100644 index 0000000..239b6cc --- /dev/null +++ b/docs/adr/0001-re-advertise-tpv-for-the-hub-app.md @@ -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,`, `-p 7779:7779` and `--hostname `, 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 `-tpv-.local`. The octet prevents + clashes between hosts with the same name; the desktop and laptop are both + `fedora`. Service record ` (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. diff --git a/run-tpv.sh b/run-tpv.sh index 6a9d874..9c97a10 100755 --- a/run-tpv.sh +++ b/run-tpv.sh @@ -84,11 +84,29 @@ 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[@]}" \ @@ -96,6 +114,7 @@ exec "${INHIBIT[@]}" podman run --rm --name tpv \ -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 \ diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 7583d3a..b4ae469 100755 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -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=}" @@ -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 +} + +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 @@ -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" } diff --git a/verify.sh b/verify.sh index 7e46faf..db11028 100755 --- a/verify.sh +++ b/verify.sh @@ -9,8 +9,17 @@ grep -Ei 'Bluetoothd:|kernel ATT|raw socket|BluezDBus' "$LOG" 2>/dev/null | head echo "2. trainer connection (want ConnectedState):" grep -Ei 'ConnectedState|Connection refused' "$LOG" 2>/dev/null | tail -2 +# Under pasta (run-tpv.sh default) QZ's ports live in the container's network namespace, not the host's. +NETMODE=$(podman inspect tpv --format '{{.HostConfig.NetworkMode}}' 2>/dev/null || true) +IN_CTR="" +[ -n "$NETMODE" ] && [ "$NETMODE" != host ] && IN_CTR=1 + echo "3. DIRCON listeners (want 36866 + 36867):" -ss -tlnp 2>/dev/null | grep 3686 || echo " NOT LISTENING" +if [ -n "$IN_CTR" ]; then + podman exec tpv ss -tln 2>/dev/null | grep 3686 || echo " NOT LISTENING (inside container)" +else + ss -tlnp 2>/dev/null | grep 3686 || echo " NOT LISTENING" +fi echo "4. mDNS advert (must carry a real host LAN address):" HOSTIPS=$(ip -4 -o addr show scope global \ @@ -32,6 +41,11 @@ fi echo "5. ports speak DIRCON (want a frame, not just an open socket):" IP=$(ip -4 -o addr show scope global | awk '$2!~/^(br-|docker|veth)/{print $4}' | cut -d/ -f1 | head -1) for p in 36866 36867; do + if [ -n "$IN_CTR" ]; then + printf " container 127.0.0.1:%s -> " "$p" + podman exec tpv bash -c "exec 3<>/dev/tcp/127.0.0.1/$p && timeout 4 head -c 16 <&3 | od -An -tx1" 2>&1 | tail -1 + continue + fi printf " %s:%s -> " "$IP" "$p" timeout 6 python3 -c " import socket,binascii,sys @@ -40,3 +54,18 @@ d=s.recv(256); s.close() print(len(d),'bytes:',binascii.hexlify(d[:16]).decode()) " "$IP" "$p" 2>&1 | tail -1 done + +echo "6. TrainingPeaks Hub advert (TPV running; want at least one OK):" +HUBADV=$(timeout 12 avahi-browse -rpt _tpvirtual._tcp 2>/dev/null \ + | awk -F';' '/^=/ && $3=="IPv4" {print $8, $9, $4}' | sort -u) +if [ -z "$HUBADV" ]; then + echo " no advert (is TPV running?)" +else + echo "$HUBADV" | while read -r ip port name; do + if echo "$HOSTIPS" | grep -qx "$ip"; then + echo " OK $ip:$port $name" + else + echo " BAD $ip:$port $name (unreachable; harmless if an OK entry exists)" + fi + done +fi