From b90ad7950ab0ac7d260af89ffc4593a91d20612b Mon Sep 17 00:00:00 2001 From: Ben Atkinson Date: Thu, 17 Sep 2026 11:41:34 +0100 Subject: [PATCH 1/6] Advertise TPV to the TrainingPeaks Hub app with a routable address Under Wine, TPV's own _tpvirtual._tcp mDNS advert can carry a bridge address (docker0's 172.17.0.1 on a host running Docker), so the Hub companion app finds TPV but cannot connect to TCP 7779 and shows no ride. The entrypoint now publishes a second advert through the host avahi-daemon while TPV runs, pointing at the default-route IPv4 address. Hub picks the reachable entry, confirmed on the laptop with a manual re-advert: Live data and the In Game remote both worked. - entrypoint: advertise_hub / stop_hub_advert around the TPV launch - run-tpv.sh: TPV_HOST_NAME, TPV_HUB_ADVERT, TPV_HUB_ADDR, TPV_HUB_NAME - Containerfile.full: avahi-utils - verify.sh: check 6 for the _tpvirtual._tcp advert address - README and ADR-0001 Refs #3 Co-Authored-By: Claude --- Containerfile.full | 1 + README.md | 23 ++- .../0001-re-advertise-tpv-for-the-hub-app.md | 139 ++++++++++++++++++ run-tpv.sh | 5 + scripts/entrypoint.sh | 38 +++++ verify.sh | 15 ++ 6 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 docs/adr/0001-re-advertise-tpv-for-the-hub-app.md 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..1a8d21d 100644 --- a/README.md +++ b/README.md @@ -72,8 +72,11 @@ 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_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 +166,22 @@ 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**. Under Wine, +TPV can advertise the address of the wrong adapter — on a host running Docker it +advertised `docker0`'s `172.17.0.1` — so Hub sees no ride. + +The entrypoint therefore publishes a second advert through the host's +avahi-daemon while TPV runs, pointing at the host's default-route IPv4 address. +Hub ignores the unreachable one. 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. 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 @@ -193,7 +212,7 @@ it to the trainer. Set `QZ_HR_BELT` and pair `Wahoo HRM` in TPV explicitly. | `--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 | | 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..44989c3 --- /dev/null +++ b/docs/adr/0001-re-advertise-tpv-for-the-hub-app.md @@ -0,0 +1,139 @@ +# 0001. Re-advertise TPV's `_tpvirtual._tcp` service from the container so the Hub app can connect + +- **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. **Hub picked the reachable entry even though TPV's broken `FEDORA` advert +was still published**, so we don't need to suppress TPV's own advert. 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. + +## What did we land on, and why? + +`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 the approach proven on real hardware (Attempt 2), +needs no host changes, works whatever bridge TPV happens to pick, and is +harmless if TPV's own advert is correct: Hub just sees a duplicate. + +```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 + T-->>H: own mDNS advert (may carry a bridge IP, e.g. 172.17.0.1) + A-->>H: re-advert (routable LAN IP) + H->>T: TCP connect LAN IP:7779 → Version, Authenticate, remote commands + T-->>E: wineserver -w returns + E->>A: stop publishers (records withdrawn) +``` + +We'd revisit this if TPV starts advertising the right address under Wine, or if +Hub starts preferring TPV's own entry over ours. + +## 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. +- **Duplicate entries:** two `_tpvirtual._tcp` instances appear on the LAN + while TPV runs. Hub copes today; a future Hub version might not. +- **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:** + - End-to-end test after the rebuild. + - A 60-minute ride watching for "Lost heartbeat" drops. + - Optionally, find out why Wine exposes `docker0` first. diff --git a/run-tpv.sh b/run-tpv.sh index 6a9d874..9c7a74c 100755 --- a/run-tpv.sh +++ b/run-tpv.sh @@ -84,6 +84,10 @@ if [ "$MODE" != qz ] && command -v gnome-session-inhibit >/dev/null; then echo "==> screen blank/suspend inhibited while the container runs" fi +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 \ @@ -96,6 +100,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..7d367f2 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,46 @@ sync_timezone() { printf '%s' "$current" > "$marker" } +lan_address() { + 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 }}' +} + +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 + if ! kill -0 "${HUB_ADVERT_PIDS[@]}" 2>/dev/null; then + 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 + fi + 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 +132,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..3503493 100755 --- a/verify.sh +++ b/verify.sh @@ -40,3 +40,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 From f616c11284c27f4bccda21f759883bec1cd94642 Mon Sep 17 00:00:00 2001 From: Ben Atkinson Date: Thu, 17 Sep 2026 14:44:46 +0100 Subject: [PATCH 2/6] Name the Hub advert ' LAN', the format Hub actually connects to End-to-end testing showed Hub ignores 'fedora (LAN)' but connects to 'FEDORA LAN' on the same host record. ADR-0001 records the A/B steps. --- README.md | 2 +- .../0001-re-advertise-tpv-for-the-hub-app.md | 25 +++++++++++++++++-- scripts/entrypoint.sh | 3 ++- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1a8d21d..4a93ab9 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ Configure via environment: | `TZ` | host zone (`timedatectl`) | Time zone for TPV's HUD clock; set to override | | `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 | +| `TPV_HUB_NAME` | ` LAN` | Name of the re-advertised TPV instance | `verify.sh` checks the five bridge things that actually matter: the Bluetooth backend chosen, the trainer connection, the DIRCON listeners, whether the mDNS advert 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 index 44989c3..c0910fa 100644 --- a/docs/adr/0001-re-advertise-tpv-for-the-hub-app.md +++ b/docs/adr/0001-re-advertise-tpv-for-the-hub-app.md @@ -79,6 +79,26 @@ 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: service name ` (LAN)` ❌ Hub ignored it + +The first version of this PR named the service `fedora (LAN)` on +`fedora-tpv-146.local`. In the end-to-end test on 2026-09-17, Hub never +connected (no connection on 7779). We changed one thing at a time with +TPV mid-ride, closing and reopening Hub after each change: + +| Adverts published | Hub connected? | +|---|---| +| `fedora (LAN)` on `fedora-tpv-146.local` | no | +| + `FEDORA LAN` on `tpv-laptop.local` (the Attempt 2 adverts) | yes | +| `fedora (LAN)` only (manual adverts stopped) | no | +| + `FEDORA LAN` on `fedora-tpv-146.local` | **yes** | + +The host record was fine; **the service name was the problem**. Hub appears +to require the name to follow TPV's own upper-case instance name (`FEDORA`). +We don't know whether the case, the parentheses, or both break the match. We +use the proven format rather than test that further. `TPV_HUB_NAME` still +overrides it. + ## What did we land on, and why? `scripts/entrypoint.sh` runs `advertise_hub` just before launching TPV, and @@ -88,8 +108,9 @@ stops it after `wineserver -w`: (`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`. + `fedora`. Service record ` LAN._tpvirtual._tcp` port 7779, + `txtvers=1`. The name is the upper-cased host name, the same as TPV's own + Wine computer name, plus ` LAN` (see Attempt 5). - **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. diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 7d367f2..619ab80 100755 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -97,7 +97,8 @@ advertise_hub() { 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)}" + # Hub ignores "fedora (LAN)" but connects to "FEDORA LAN": match TPV's upper-case Wine computer name. + name="${TPV_HUB_NAME:-$(printf '%s' "${TPV_HOST_NAME:-TPV}" | tr '[:lower:]' '[:upper:]') LAN}" avahi-publish -a -R "$record" "$addr" & HUB_ADVERT_PIDS=($!) avahi-publish-service -H "$record" "$name" _tpvirtual._tcp 7779 txtvers=1 & From 12634b9a4914f3240505566e35de3560698fac7f Mon Sep 17 00:00:00 2001 From: Ben Atkinson Date: Thu, 17 Sep 2026 15:31:22 +0100 Subject: [PATCH 3/6] Revert "Name the Hub advert ' LAN', the format Hub actually connects to" This reverts commit 0b23275b6a7412240ffb6b84cfed7809495b22d9. --- README.md | 2 +- .../0001-re-advertise-tpv-for-the-hub-app.md | 25 ++----------------- scripts/entrypoint.sh | 3 +-- 3 files changed, 4 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 4a93ab9..1a8d21d 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ Configure via environment: | `TZ` | host zone (`timedatectl`) | Time zone for TPV's HUD clock; set to override | | `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 | +| `TPV_HUB_NAME` | ` (LAN)` | Name of the re-advertised TPV instance | `verify.sh` checks the five bridge things that actually matter: the Bluetooth backend chosen, the trainer connection, the DIRCON listeners, whether the mDNS advert 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 index c0910fa..44989c3 100644 --- a/docs/adr/0001-re-advertise-tpv-for-the-hub-app.md +++ b/docs/adr/0001-re-advertise-tpv-for-the-hub-app.md @@ -79,26 +79,6 @@ 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: service name ` (LAN)` ❌ Hub ignored it - -The first version of this PR named the service `fedora (LAN)` on -`fedora-tpv-146.local`. In the end-to-end test on 2026-09-17, Hub never -connected (no connection on 7779). We changed one thing at a time with -TPV mid-ride, closing and reopening Hub after each change: - -| Adverts published | Hub connected? | -|---|---| -| `fedora (LAN)` on `fedora-tpv-146.local` | no | -| + `FEDORA LAN` on `tpv-laptop.local` (the Attempt 2 adverts) | yes | -| `fedora (LAN)` only (manual adverts stopped) | no | -| + `FEDORA LAN` on `fedora-tpv-146.local` | **yes** | - -The host record was fine; **the service name was the problem**. Hub appears -to require the name to follow TPV's own upper-case instance name (`FEDORA`). -We don't know whether the case, the parentheses, or both break the match. We -use the proven format rather than test that further. `TPV_HUB_NAME` still -overrides it. - ## What did we land on, and why? `scripts/entrypoint.sh` runs `advertise_hub` just before launching TPV, and @@ -108,9 +88,8 @@ stops it after `wineserver -w`: (`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`. The name is the upper-cased host name, the same as TPV's own - Wine computer name, plus ` LAN` (see Attempt 5). + `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. diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 619ab80..7d367f2 100755 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -97,8 +97,7 @@ advertise_hub() { fi host_label=$(printf '%s' "${TPV_HOST_NAME:-tpv}" | tr -c 'A-Za-z0-9-' '-') record="${host_label}-tpv-${addr##*.}.local" - # Hub ignores "fedora (LAN)" but connects to "FEDORA LAN": match TPV's upper-case Wine computer name. - name="${TPV_HUB_NAME:-$(printf '%s' "${TPV_HOST_NAME:-TPV}" | tr '[:lower:]' '[:upper:]') LAN}" + 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 & From 486cece92f3622623f7974864be7c36066ac8633 Mon Sep 17 00:00:00 2001 From: Ben Atkinson Date: Thu, 17 Sep 2026 15:34:41 +0100 Subject: [PATCH 4/6] Run TPV on pasta networking so Hub connects in seconds, not ~130 s TPV advertises an address for every interface Wine reports, and Hub tries only the first, waiting out Android's ~127 s TCP timeout when it is a Docker bridge or VPN address. pasta bound to the LAN interface shows TPV only that interface: cold-start connects went from ~132 s to 3-4 s. TPV_NETWORK=host restores host networking (network trainers, #13). ADR-0001 records the diagnosis, including the advert-name red herring. --- README.md | 33 ++++-- .../0001-re-advertise-tpv-for-the-hub-app.md | 100 ++++++++++++++---- run-tpv.sh | 16 ++- verify.sh | 16 ++- 4 files changed, 135 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 1a8d21d..fe2660e 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,8 @@ 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 | @@ -170,15 +172,24 @@ script that shifts a course a few metres to fix it 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**. Under Wine, -TPV can advertise the address of the wrong adapter — on a host running Docker it -advertised `docker0`'s `172.17.0.1` — so Hub sees no ride. - -The entrypoint therefore publishes a second advert through the host's -avahi-daemon while TPV runs, pointing at the host's default-route IPv4 address. -Hub ignores the unreachable one. 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. The phone must be on the same subnet, and the host +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). @@ -210,7 +221,9 @@ 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`. Also how the Hub re-advert reaches avahi-daemon | | Avahi socket mount | No mDNS advert, so TPV never discovers the bridge | 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 index 44989c3..239b6cc 100644 --- a/docs/adr/0001-re-advertise-tpv-for-the-hub-app.md +++ b/docs/adr/0001-re-advertise-tpv-for-the-hub-app.md @@ -1,4 +1,4 @@ -# 0001. Re-advertise TPV's `_tpvirtual._tcp` service from the container so the Hub app can connect +# 0001. Run TPV on pasta networking and re-advertise `_tpvirtual._tcp` so the Hub app connects promptly - **Status:** Accepted - **Date:** 2026-09-17 @@ -53,8 +53,10 @@ avahi-publish-service -H tpv-laptop.local "FEDORA LAN" _tpvirtual._tcp 7779 txtv 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. **Hub picked the reachable entry even though TPV's broken `FEDORA` advert -was still published**, so we don't need to suppress TPV's own advert. The one +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. @@ -79,10 +81,68 @@ 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? -`scripts/entrypoint.sh` runs `advertise_hub` just before launching TPV, and -stops it after `wineserver -w`: +**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`). @@ -98,9 +158,9 @@ stops it after `wineserver -w`: - **Checking:** `verify.sh` check 6 flags each `_tpvirtual._tcp` IPv4 entry as a host LAN address or not. -This was chosen because it's the approach proven on real hardware (Attempt 2), -needs no host changes, works whatever bridge TPV happens to pick, and is -harmless if TPV's own advert is correct: Hub just sees a duplicate. +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 @@ -109,23 +169,27 @@ sequenceDiagram 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 - T-->>H: own mDNS advert (may carry a bridge IP, e.g. 172.17.0.1) - A-->>H: re-advert (routable LAN IP) - H->>T: TCP connect LAN IP:7779 → Version, Authenticate, remote commands + 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 TPV starts advertising the right address under Wine, or if -Hub starts preferring TPV's own entry over ours. +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. -- **Duplicate entries:** two `_tpvirtual._tcp` instances appear on the LAN - while TPV runs. Hub copes today; a future Hub version might not. +- **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`. @@ -134,6 +198,6 @@ Hub starts preferring TPV's own entry over ours. - **More host coupling:** the container now also publishes on the host's mDNS, on top of reading it. - **Follow-up:** - - End-to-end test after the rebuild. + - A real ride in `both` mode (QZ + trainer) under pasta. - A 60-minute ride watching for "Lost heartbeat" drops. - - Optionally, find out why Wine exposes `docker0` first. + - #13: network-based trainers under pasta. diff --git a/run-tpv.sh b/run-tpv.sh index 9c7a74c..9c97a10 100755 --- a/run-tpv.sh +++ b/run-tpv.sh @@ -84,6 +84,20 @@ 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}") @@ -92,7 +106,7 @@ 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[@]}" \ diff --git a/verify.sh b/verify.sh index 3503493..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 From 719a2090444f047c2d653e63d8a23cf261bfe199 Mon Sep 17 00:00:00 2001 From: Ben Atkinson Date: Sun, 20 Sep 2026 09:22:44 +0100 Subject: [PATCH 5/6] Detect a half-failed Hub advert, and reap the survivor Seer flagged that the advert check leaves an orphaned avahi-publish when only one of the two publishers dies. The orphan is real, but the stated mechanism is inverted for this script: bash's kill BUILTIN returns 0 if ANY listed pid is live, so 'kill -0 "${HUB_ADVERT_PIDS[@]}"' succeeds with one publisher dead and the failure branch never runs. (/bin/kill does return non-zero there, which is where the assumption comes from.) The effect was worse than an orphan: no warning, and TPV reported the advert as working while publishing half a service to the Hub app. Check each pid on its own, and reap the rest before returning. --- scripts/entrypoint.sh | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 7d367f2..8339377 100755 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -103,11 +103,17 @@ advertise_hub() { avahi-publish-service -H "$record" "$name" _tpvirtual._tcp 7779 txtvers=1 & HUB_ADVERT_PIDS+=($!) sleep 2 - if ! kill -0 "${HUB_ADVERT_PIDS[@]}" 2>/dev/null; then + # 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 - fi + done echo "==> TrainingPeaks Hub advert: '${name}' -> ${addr}:7779" } From 3ea2d7b68b8249f137575db966cdbdfa47e41bfa Mon Sep 17 00:00:00 2001 From: Ben Atkinson Date: Sun, 20 Sep 2026 09:29:47 +0100 Subject: [PATCH 6/6] Keep a missing LAN route from killing the entrypoint Seer, correctly: lan_address() runs a pipeline under 'set -euo pipefail', and its value is taken by a plain assignment, so a failing 'ip route get' propagates and the script exits before the empty-address warning can run. Reproduced: with the route unavailable the entrypoint dies silently at exit 2, printing neither the warning nor anything after it. That is exactly the case this PR introduces -- no route yet while pasta networking settles -- so it would have bitten on a cold start. Let the pipeline fail into an empty string instead. --- scripts/entrypoint.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 8339377..b4ae469 100755 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -82,8 +82,10 @@ sync_timezone() { } 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 }}' + | awk '{for (i = 1; i < NF; i++) if ($i == "src") { print $(i + 1); exit }}' \ + || true } advertise_hub() {