diff --git a/deploy/phone/00-check.sh b/deploy/phone/00-check.sh new file mode 100755 index 0000000..97b681c --- /dev/null +++ b/deploy/phone/00-check.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Step 00 — prerequisite check. +# Verifies you have adb, the phone is connected, Termux is installed, +# and root (KernelSU / Magisk) responds. Run this first on a fresh setup +# and any time something seems off. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$HERE/_common.sh" + +hr +say "Step 00 — prerequisite check" +hr + +# 1. adb installed +if ! command -v adb >/dev/null 2>&1; then + die "adb is not on PATH. Install with 'brew install --cask android-platform-tools' (macOS)." +fi +ok "adb installed: $(adb version | head -1)" + +# 2. Phone connected +SERIAL=$(detect_serial) +ok "device: $SERIAL" + +# 3. Android version +android_ver=$(adb -s "$SERIAL" shell getprop ro.build.version.release 2>/dev/null | tr -d '\r') +model=$(adb -s "$SERIAL" shell getprop ro.product.model 2>/dev/null | tr -d '\r') +ok "model: $model (Android $android_ver)" + +# 4. Termux installed +if adb -s "$SERIAL" shell 'pm list packages com.termux' 2>/dev/null | grep -q com.termux; then + ok "Termux installed (com.termux)" +else + die "Termux is not installed. Install Termux + Termux:API from F-Droid first." +fi + +# 5. Root access (KernelSU/Magisk needed for the file-staging step). +# Probe with `|| true` so set -e doesn't halt when su is missing. +set +e +root_probe=$(adb -s "$SERIAL" shell 'su root id 2>&1' 2>/dev/null) +set -e +if echo "$root_probe" | grep -q 'uid=0'; then + ok "root (su) available" + HAS_ROOT=1 +else + warn "root not detected — staging files into Termux home will need an alternative path." + warn "Either install KernelSU/Magisk, OR run \`termux-setup-storage\` once inside Termux and we'll" + warn "fall back to using /sdcard/Download as a transfer staging area." + HAS_ROOT=0 +fi + +# 6. Termux UID — only meaningful if root works +if [ "$HAS_ROOT" = "1" ]; then + uid=$(adb -s "$SERIAL" shell "su root sh -c 'stat -c %u /data/data/com.termux'" 2>/dev/null | tr -d '\r' || true) + if [[ "$uid" =~ ^[0-9]+$ ]]; then + ok "Termux uid: $uid (export TERMUX_UID=$uid to pin if it changes)" + else + warn "Could not read Termux uid — falling back to default $TERMUX_UID_DEFAULT" + fi +else + warn "Skipping Termux uid probe (no root)" +fi + +# 7. USB-CAN adapter (optional, world-readable so no root needed) +set +e +acm=$(adb -s "$SERIAL" shell 'ls /dev/ttyACM* 2>/dev/null' 2>/dev/null | tr -d '\r') +set -e +if [ -n "$acm" ]; then + ok "USB-CAN device(s) present: $acm" +else + warn "No /dev/ttyACM* — fine if you only want to replay sessions; plug in the CANable for live CAN." +fi + +# 8. LocalLLM (optional) +set +e +llm_pkg=$(adb -s "$SERIAL" shell 'pm list packages com.localllm.app' 2>/dev/null) +set -e +if echo "$llm_pkg" | grep -q localllm; then + ok "LocalLLM app installed (start it manually + set 'Max Input Tokens' ≥ 1024)" +else + warn "LocalLLM not installed — coach brief will fall back to honest empty state with error message" +fi + +hr +ok "Prerequisites look good. Next: ./deploy/phone/10-termux-packages.sh" diff --git a/deploy/phone/10-termux-packages.sh b/deploy/phone/10-termux-packages.sh new file mode 100755 index 0000000..0592b60 --- /dev/null +++ b/deploy/phone/10-termux-packages.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Step 10 — install the Termux packages pitwall needs. +# Idempotent; safe to rerun. Takes ~2-5 min on first run. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$HERE/_common.sh" + +hr +say "Step 10 — install Termux packages" +hr +SERIAL=$(detect_serial) + +# Mirror selection: pick a US mirror automatically (Termux's default +# selector sometimes hangs). Skip if user already configured one. +termuxrun "$SERIAL" ' + if [ ! -f $PREFIX/etc/apt/sources.list.d/_pitwall_mirror ]; then + echo "deb https://mirror.fcix.net/termux/termux-main stable main" > $PREFIX/etc/apt/sources.list.d/_pitwall_mirror + fi + echo "--- pkg update ---" + pkg update -y 2>&1 | tail -5 +' >/dev/null 2>&1 || true + +say "Installing core packages (python git clang make rust cmake ninja openssh termux-tools libduckdb)…" +termuxrun "$SERIAL" ' + pkg install -y \ + python git clang make pkg-config rust cmake ninja openssh termux-tools \ + libduckdb 2>&1 | tail -10 +' + +say "Verifying:" +termuxrun "$SERIAL" ' + python --version + pip --version + git --version +' + +hr +ok "Termux packages installed. Next: ./deploy/phone/20-stage-repo.sh" diff --git a/deploy/phone/20-stage-repo.sh b/deploy/phone/20-stage-repo.sh new file mode 100755 index 0000000..e79a796 --- /dev/null +++ b/deploy/phone/20-stage-repo.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Step 20 — stage the pitwall repo onto the phone. +# Tars the parts of the repo the bridge needs (src/, data/), pushes via +# adb, and extracts into ~/pitwall on the phone. Idempotent. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$HERE/_common.sh" + +hr +say "Step 20 — stage repo into Termux home" +hr +SERIAL=$(detect_serial) + +# Build a tarball of just what the bridge needs. Skip caches + the big +# duckdb file (we'll stage that separately in step 40 if you want it). +TAR=/tmp/pitwall-src-$$.tgz +say "Building tarball at ${TAR}…" +tar -czf "$TAR" \ + --exclude='__pycache__' --exclude='*.pyc' --exclude='.venv' \ + --exclude='node_modules' --exclude='*.duckdb' --exclude='*.duckdb.*' \ + -C "$REPO_ROOT" \ + src/pitwall src/simulator data pyproject.toml +ok "tarball: $(ls -lh "$TAR" | awk '{print $5}')" + +say "Pushing to /data/local/tmp/…" +adb -s "$SERIAL" push "$TAR" /data/local/tmp/pitwall-src.tgz | tail -1 + +say "Extracting into ~/pitwall…" +termuxrun "$SERIAL" ' + mkdir -p ~/pitwall ~/pitwall/logs + tar -xzf /data/local/tmp/pitwall-src.tgz -C ~/pitwall 2>/dev/null + ls -la ~/pitwall | head -10 +' +rm -f "$TAR" +adb -s "$SERIAL" shell 'rm -f /data/local/tmp/pitwall-src.tgz' >/dev/null 2>&1 || true + +hr +ok "Repo staged at ~/pitwall on the phone. Next: ./deploy/phone/30-python-deps.sh" diff --git a/deploy/phone/30-python-deps.sh b/deploy/phone/30-python-deps.sh new file mode 100755 index 0000000..b7247a1 --- /dev/null +++ b/deploy/phone/30-python-deps.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Step 30 — create the Python venv and install pitwall's runtime deps. +# - flask, flask-cors, waitress, numpy, pyyaml, python-can, cantools, pyserial +# - duckdb stub (no aarch64 wheel; backend falls back to SQLite from stdlib) +# Idempotent — re-running just reconciles the venv. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$HERE/_common.sh" + +hr +say "Step 30 — Python venv + pip deps" +hr +SERIAL=$(detect_serial) + +termuxrun "$SERIAL" ' + cd ~/pitwall + if [ ! -d .venv ]; then + python -m venv .venv + fi + . .venv/bin/activate + pip install --upgrade pip 2>&1 | tail -2 + echo "--- installing runtime deps ---" + pip install --prefer-binary \ + flask flask-cors waitress numpy pyyaml python-can cantools pyserial 2>&1 | tail -5 + echo + echo "--- verifying imports ---" + python -c "import flask, can, cantools, yaml, serial, numpy; print(\"ok — flask\",flask.__version__,\"can\",can.__version__,\"cantools\",cantools.__version__)" +' + +say "Installing duckdb stub (Termux has no aarch64 wheel → bridge falls back to SQLite)…" +termuxrun "$SERIAL" ' + cd ~/pitwall + . .venv/bin/activate + SP=$(python -c "import sys; print([p for p in sys.path if \"site-packages\" in p][0])") + mkdir -p "$SP/duckdb" + cat > "$SP/duckdb/__init__.py" < to push something else. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$HERE/_common.sh" + +REC="${1:-$REPO_ROOT/.context/recordings/track-sonoma-2026-05-23-1.sqlite}" +if [ "$1" = "--file" ] && [ -n "${2:-}" ]; then + REC="$2" +fi + +hr +say "Step 40 — stage recording → phone bridge DB" +hr +SERIAL=$(detect_serial) + +if [ ! -f "$REC" ]; then + warn "Recording not found at: $REC" + warn "Either record one with the bridge first, or pass a different path:" + warn " ./deploy/phone/40-stage-recording.sh /path/to/session.sqlite" + exit 1 +fi + +SIZE=$(ls -lh "$REC" | awk '{print $5}') +say "Source: $REC ($SIZE)" +say "Archiving any existing on-phone DB first…" +termuxrun "$SERIAL" ' + mkdir -p ~/pitwall/data ~/pitwall/data/archive + if [ -f ~/pitwall/data/pitwall_sessions.duckdb ]; then + mv ~/pitwall/data/pitwall_sessions.duckdb \ + ~/pitwall/data/archive/pre-stage-$(date +%Y%m%dT%H%M%S).sqlite + rm -f ~/pitwall/data/pitwall_sessions.duckdb.wal \ + ~/pitwall/data/pitwall_sessions.duckdb.shm + fi +' + +say "Pushing $SIZE …" +adb -s "$SERIAL" push "$REC" /data/local/tmp/pitwall-upload.sqlite | tail -1 + +termuxrun "$SERIAL" ' + cp /data/local/tmp/pitwall-upload.sqlite ~/pitwall/data/pitwall_sessions.duckdb + python - <\", \"speed\": 1, \"loop\": false}'" diff --git a/deploy/phone/50-build-pwa.sh b/deploy/phone/50-build-pwa.sh new file mode 100755 index 0000000..288ff54 --- /dev/null +++ b/deploy/phone/50-build-pwa.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Step 50 — build the PWA on the Mac and start a local static server. +# The server keeps running in the background; PWA is reached from the +# phone via 'adb reverse tcp:5173 tcp:5173' (set up in step 60). +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$HERE/_common.sh" + +hr +say "Step 50 — build PWA + serve from Mac" +hr +PWA_DIR="$REPO_ROOT/src/pwa" + +if ! command -v node >/dev/null 2>&1; then + die "node not on PATH. Install via 'brew install node' (macOS) or https://nodejs.org" +fi +ok "node: $(node -v)" + +cd "$PWA_DIR" +if [ ! -d node_modules ]; then + say "First-time install (npm install)…" + npm install 2>&1 | tail -3 +fi + +say "Building dist…" +npm run build 2>&1 | tail -3 + +# Tear down any previous serve on the port +EXISTING=$(lsof -ti :"$PWA_PORT" 2>/dev/null || true) +if [ -n "$EXISTING" ]; then + warn "Port $PWA_PORT in use by pid $EXISTING — killing" + kill -9 $EXISTING 2>/dev/null || true + sleep 1 +fi + +say "Starting npx serve on :${PWA_PORT}…" +nohup npx serve -s dist -l "tcp://127.0.0.1:${PWA_PORT}" \ + > /tmp/pwa-serve.log 2>&1 & +SERVE_PID=$! +ok "serve pid=$SERVE_PID log=/tmp/pwa-serve.log" + +# npx-serve takes 1-5 s to bind; poll up to 10 s for the HTTP check. +HTTP="000" +for _ in $(seq 1 20); do + if ! kill -0 "$SERVE_PID" 2>/dev/null; then + die "serve died — check /tmp/pwa-serve.log" + fi + HTTP=$(curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:${PWA_PORT}/" 2>/dev/null || echo "000") + if [ "$HTTP" = "200" ]; then break; fi + sleep 0.5 +done + +if [ "$HTTP" = "200" ]; then + ok "http://127.0.0.1:${PWA_PORT}/ → 200" +else + warn "http://127.0.0.1:${PWA_PORT}/ → $HTTP after 10 s — check /tmp/pwa-serve.log" +fi + +hr +ok "PWA built + served. Next: ./deploy/phone/60-forward-ports.sh" diff --git a/deploy/phone/60-forward-ports.sh b/deploy/phone/60-forward-ports.sh new file mode 100755 index 0000000..f153a48 --- /dev/null +++ b/deploy/phone/60-forward-ports.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Step 60 — wire the adb port bridges. +# adb reverse :5173 → phone Chrome can reach Mac's PWA static server +# adb forward :8765 → Mac can reach phone's pitwall bridge +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$HERE/_common.sh" + +hr +say "Step 60 — adb port wiring" +hr +SERIAL=$(detect_serial) + +adb -s "$SERIAL" reverse "tcp:${PWA_PORT}" "tcp:${PWA_PORT}" +ok "phone:${PWA_PORT} ← Mac:${PWA_PORT} (PWA reachable in phone Chrome)" + +adb -s "$SERIAL" forward "tcp:${BRIDGE_PORT}" "tcp:${BRIDGE_PORT}" +ok "Mac:${BRIDGE_PORT} → phone:${BRIDGE_PORT} (bridge curl/PWA testing from Mac)" + +hr +ok "Port bridges live. Next: ./deploy/phone/70-start-bridge.sh" diff --git a/deploy/phone/70-start-bridge.sh b/deploy/phone/70-start-bridge.sh new file mode 100755 index 0000000..5881940 --- /dev/null +++ b/deploy/phone/70-start-bridge.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Step 70 — start the pitwall bridge on the phone (as a daemon under +# `timeout 3600` so it auto-stops after an hour). Auto-detects an +# attached CANable on /dev/ttyACM*; without one, runs in "no live CAN" +# mode and you drive telemetry via /session/replay/*. +# +# Env knobs (export before running): +# DURATION_S bridge auto-stop (default 3600 = 1 h) +# NO_CAN=1 skip CAN reader even if /dev/ttyACM* exists +# SIM=1 enable the built-in AiM MXP synthetic simulator +# (mutually exclusive with live CAN; forces NO_CAN) +# SIM_SPEED=1.0 simulator wall-clock speed multiplier +# SIM_LAP_SECONDS=60 duration of one synthetic lap (default 60 s) +# LOCALLLM_URL default http://localhost:8080/v1 +# LOCALLLM_MODEL default gemma-4-e2b +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$HERE/_common.sh" + +hr +say "Step 70 — start pitwall bridge" +hr +SERIAL=$(detect_serial) +DURATION_S="${DURATION_S:-3600}" + +# Pick mode: SIM (synthetic) > NO_CAN (idle) > live CAN auto-detect. +CAN_ARGS="" +if [ "${SIM:-0}" = "1" ]; then + SIM_SPEED="${SIM_SPEED:-1.0}" + SIM_LAP_SECONDS="${SIM_LAP_SECONDS:-60}" + ok "SIM=1 — built-in AiM MXP synthetic simulator (speed=${SIM_SPEED}, lap=${SIM_LAP_SECONDS}s)" + CAN_ARGS="--simulate --simulate-speed ${SIM_SPEED} --simulate-lap-seconds ${SIM_LAP_SECONDS}" +elif [ "${NO_CAN:-0}" != "1" ]; then + DEV=$(adb -s "$SERIAL" shell 'ls /dev/ttyACM* 2>/dev/null | head -1' | tr -d '\r') + if [ -n "$DEV" ]; then + # chmod 666 so Termux can open it without root each frame + adb -s "$SERIAL" shell "su root chmod 666 $DEV" >/dev/null 2>&1 || true + ok "CAN device: $DEV" + CAN_ARGS="--can-interface slcan --can-channel $DEV --can-bitrate 1000000" + else + warn "no /dev/ttyACM* — bridge will start without live CAN; use SIM=1 or /session/replay/start" + fi +else + warn "NO_CAN=1 — skipping CAN reader; bridge will start in replay-only mode" +fi + +say "Killing any old bridge process…" +termuxrun "$SERIAL" 'pkill -f "python -m pitwall" 2>/dev/null; pkill -f "timeout " 2>/dev/null; sleep 1; true' + +CMD="cd ~/pitwall && . .venv/bin/activate +nohup env \ + PITWALL_ADK_OPENAI_URL=${LOCALLLM_URL} \ + PITWALL_ADK_OPENAI_MODEL=${LOCALLLM_MODEL} \ + PITWALL_ADK_OPENAI_API_KEY=local \ + PITWALL_LLM_MAX_TOKENS=512 \ + PITWALL_COMPACT_PROMPTS=1 \ + PYTHONPATH=src \ + timeout ${DURATION_S} python -m pitwall ${CAN_ARGS} \ + --can-car-config data/cars/bmw_e46_m3.yaml \ + --can-dbc data/dbc/pitwall.dbc \ + --track data/tracks/sonoma.json \ + --port ${BRIDGE_PORT} --log-level INFO \ + > logs/bridge.log 2>&1 & +echo \$! > data/bridge.pid +echo bridge_pid=\$!" + +termuxrun "$SERIAL" "$CMD" +say "Waiting for /health …" +if wait_for_bridge "$SERIAL" 15; then + ok "bridge up" + curl -sS "http://127.0.0.1:${BRIDGE_PORT}/health" \ + | python3 -c "import sys,json;d=json.load(sys.stdin);c=d.get('can') or {};print(f\" active_session={d.get('active_session_id')} track={d.get('track')} can.connected={c.get('connected')} fps={c.get('fps')}\")" +else + die "bridge didn't respond on /health within 15 s — see logs/bridge.log on phone" +fi + +hr +ok "Bridge running for ${DURATION_S}s. Next: ./deploy/phone/80-open-pwa.sh" +ok "Tail logs: adb -s $SERIAL shell 'su $TERMUX_UID tail -f $PHONE_HOME/pitwall/logs/bridge.log'" diff --git a/deploy/phone/80-open-pwa.sh b/deploy/phone/80-open-pwa.sh new file mode 100755 index 0000000..48b987d --- /dev/null +++ b/deploy/phone/80-open-pwa.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Step 80 — launch Chrome on the phone pointed at the PWA. +# Optionally pass a sub-route: ./deploy/phone/80-open-pwa.sh /briefing +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$HERE/_common.sh" + +ROUTE="${1:-/}" +[[ "$ROUTE" = /* ]] || ROUTE="/$ROUTE" + +hr +say "Step 80 — open PWA in phone Chrome" +hr +SERIAL=$(detect_serial) +URL="http://localhost:${PWA_PORT}${ROUTE}" + +say "URL: $URL" +adb -s "$SERIAL" shell "am start -a android.intent.action.VIEW -d '$URL' \ + -n com.android.chrome/com.google.android.apps.chrome.Main" 2>&1 | tail -1 + +sleep 2 +# Force-reload to bypass the PWA service worker cache when a new build landed +adb -s "$SERIAL" shell 'input keyevent KEYCODE_F5' >/dev/null 2>&1 || true + +ok "Opened in Chrome. Use the fullscreen-toggle button in the corner to hide the URL bar." +hr diff --git a/deploy/phone/99-stop.sh b/deploy/phone/99-stop.sh new file mode 100755 index 0000000..c3e7b5b --- /dev/null +++ b/deploy/phone/99-stop.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Step 99 — stop everything cleanly. Mac PWA server + phone bridge. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$HERE/_common.sh" + +hr +say "Stopping pitwall (PWA server + phone bridge)" +hr +SERIAL=$(detect_serial) + +# 1. Phone bridge — only if root is available. Without it, this step is +# a no-op (the user has to stop the bridge from the Termux app). +say "Stopping phone bridge…" +if adb -s "$SERIAL" shell 'su root id 2>&1' 2>/dev/null | grep -q 'uid=0'; then + termuxrun "$SERIAL" ' + if [ -f ~/pitwall/data/bridge.pid ]; then + kill -TERM $(cat ~/pitwall/data/bridge.pid) 2>/dev/null || true + fi + pkill -TERM -f "python -m pitwall" 2>/dev/null || true + pkill -TERM -f "timeout " 2>/dev/null || true + sleep 2 + if pgrep -f "python -m pitwall" >/dev/null 2>&1; then + echo " forcing SIGKILL" + pkill -KILL -f "python -m pitwall" 2>/dev/null || true + fi + echo "remaining pitwall processes:" + ps -ef | grep -E "python -m pitwall|timeout " | grep -v grep || echo " (none)" + ' + ok "Phone bridge stopped" +else + warn "no root — skipping phone bridge stop (open Termux + 'pkill -f pitwall' manually)" +fi + +# 2. Mac PWA server +say "Stopping Mac PWA serve…" +PIDS=$(lsof -ti :"$PWA_PORT" 2>/dev/null || true) +if [ -n "$PIDS" ]; then + kill -9 $PIDS 2>/dev/null || true + ok "killed pid(s): $PIDS" +else + ok "(no serve process on :$PWA_PORT)" +fi + +# 3. Tear down adb port bridges +adb -s "$SERIAL" reverse --remove "tcp:${PWA_PORT}" 2>/dev/null || true +adb -s "$SERIAL" forward --remove "tcp:${BRIDGE_PORT}" 2>/dev/null || true +ok "adb forwards removed" + +hr +ok "Everything stopped." diff --git a/deploy/phone/README.md b/deploy/phone/README.md new file mode 100644 index 0000000..3c91209 --- /dev/null +++ b/deploy/phone/README.md @@ -0,0 +1,135 @@ +# `deploy/phone/` — run pitwall on the phone, one script at a time + +Each script is a single step. Run them in order on a fresh phone. After +that, day-to-day use only needs the last few. + +``` +00-check.sh Verify adb, Termux, root, USB-CAN, LocalLLM +10-termux-packages.sh pkg install python git clang ... libduckdb +20-stage-repo.sh tar src/ + data/, push, extract into ~/pitwall +30-python-deps.sh venv + pip + duckdb-stub shim +40-stage-recording.sh (optional) push a .sqlite recording for replay +50-build-pwa.sh npm install + npm run build + serve on Mac :5173 +60-forward-ports.sh adb reverse :5173, adb forward :8765 +70-start-bridge.sh start the pitwall daemon on the phone +80-open-pwa.sh launch Chrome on the phone → localhost:5173 +99-stop.sh stop everything cleanly (Mac + phone) + +status.sh print the live state of every layer +``` + +## Fresh-phone install (run once) + +```bash +./deploy/phone/00-check.sh +./deploy/phone/10-termux-packages.sh +./deploy/phone/20-stage-repo.sh +./deploy/phone/30-python-deps.sh +./deploy/phone/40-stage-recording.sh # optional +``` + +## Daily run + +```bash +./deploy/phone/50-build-pwa.sh +./deploy/phone/60-forward-ports.sh +./deploy/phone/70-start-bridge.sh +./deploy/phone/80-open-pwa.sh /briefing +``` + +When you're done: + +```bash +./deploy/phone/99-stop.sh +``` + +## Update after a code change + +```bash +./deploy/phone/20-stage-repo.sh # repush src/ + data/ +./deploy/phone/70-start-bridge.sh # restart bridge +``` + +Or for PWA-only changes: + +```bash +./deploy/phone/50-build-pwa.sh # rebuild + reserve +./deploy/phone/80-open-pwa.sh # F5 to bypass SW cache +``` + +## Env knobs + +``` +SERIAL=53061FDCR000XR # pin a specific adb device +BRIDGE_PORT=8765 # default bridge port +PWA_PORT=5173 # default PWA port +DURATION_S=3600 # bridge auto-stop timer (1 h) +NO_CAN=1 # start bridge without live CAN +LOCALLLM_URL=http://localhost:8080/v1 +LOCALLLM_MODEL=gemma-4-e2b +TERMUX_UID=10312 # uncommon: pin a non-default Termux uid +``` + +## What runs where + +| Component | Host | Port | How | +| ----------------- | ----- | ---- | --------------------------------------------------------- | +| PWA static serve | Mac | 5173 | `npx serve -s src/pwa/dist -l :5173` | +| pitwall bridge | Phone | 8765 | `python -m pitwall …` under `timeout` in Termux | +| LocalLLM (Gemma) | Phone | 8080 | Pre-installed Android app, started manually | +| Chrome → PWA | Phone | →5173 | via `adb reverse tcp:5173 tcp:5173` | +| Mac curl → bridge | Mac | →8765 | via `adb forward tcp:8765 tcp:8765` | +| CANable USB-CAN | Phone | `/dev/ttyACM*` | USB-C OTG from car → SLCAN frames into bridge | + +## What does NOT live in this repo + +- Termux + Termux:API APKs — install from F-Droid before running step 00. +- LocalLLM Android APK + the `gemma-4-e2b` model — install + load in-app. +- The 4258 m Sonoma centerline GeoJSON used by the live map is already + bundled at `data/tracks/sonoma_real_gps.json`. + +## Root requirement + +Steps **10, 20, 30, 40, 70** drop into the Termux user (uid 10312+) via +`su` to install packages, copy files into Termux's private dir +(`/data/data/com.termux/files/home`, not world-accessible), or launch +the bridge daemon. Android doesn't grant adb the right to do that +without root. Three ways to satisfy this: + +1. **KernelSU or Magisk** (recommended) — root the phone once. Scripts + then Just Work. Tested on a Pixel 10 with KernelSU. +2. **SSH bootstrap** — open the Termux app on the phone, run: + ``` + pkg install openssh + passwd # set a password for the Termux user + sshd # starts on port 8022 + whoami # note the u0_aXXX username + ``` + Then on the Mac: `adb forward tcp:8022 tcp:8022` and re-export the + helpers in `_common.sh` to use `ssh u0_aXXX@localhost -p 8022 …` + instead of `su u0_aXXX`. (Pluggable, but not yet wired into the + default scripts.) +3. **Manual install** — open Termux on the phone and run the commands + that each script would run (copy them out of the script body). Works + for one-shot installs; not great for daily restarts. + +Steps **00, 50, 60, 80, 99, status** don't need root — they only touch +adb, the Mac, and the PWA. So you can always at least: + +```bash +./deploy/phone/00-check.sh # tells you if root is missing +./deploy/phone/50-build-pwa.sh # Mac-only +./deploy/phone/60-forward-ports.sh # adb-only +./deploy/phone/80-open-pwa.sh # adb-only +./deploy/phone/status.sh # adb + curl +./deploy/phone/99-stop.sh # gracefully skips Termux step on no-root +``` + +## Logs + +| What | Path | +| ---------------- | ------------------------------------------------ | +| Mac PWA serve | `/tmp/pwa-serve.log` | +| Phone bridge | `~/pitwall/logs/bridge.log` (on the phone) | +| PID file | `~/pitwall/data/bridge.pid` | +| Stash recordings | `~/pitwall/data/archive/` | diff --git a/deploy/phone/_common.sh b/deploy/phone/_common.sh new file mode 100755 index 0000000..9fb6975 --- /dev/null +++ b/deploy/phone/_common.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Shared helpers for the deploy/phone/* ladder. +# `source` this from each step; never run it directly. + +set -euo pipefail + +# Colours for status messages (no-op when not a TTY). +if [ -t 1 ]; then + C_OK=$'\033[32m'; C_WARN=$'\033[33m'; C_ERR=$'\033[31m' + C_INFO=$'\033[36m'; C_DIM=$'\033[2m'; C_OFF=$'\033[0m' +else + C_OK= C_WARN= C_ERR= C_INFO= C_DIM= C_OFF= +fi + +say() { printf "%s%s%s\n" "$C_INFO" "$*" "$C_OFF"; } +ok() { printf "%s✓ %s%s\n" "$C_OK" "$*" "$C_OFF"; } +warn() { printf "%s! %s%s\n" "$C_WARN" "$*" "$C_OFF" >&2; } +die() { printf "%s✗ %s%s\n" "$C_ERR" "$*" "$C_OFF" >&2; exit 1; } +hr() { printf "%s%s%s\n" "$C_DIM" "────────────────────────────────────────────────────────────" "$C_OFF"; } + +REPO_ROOT="$(git -C "$(dirname "${BASH_SOURCE[0]}")" rev-parse --show-toplevel 2>/dev/null || pwd)" +PHONE_HOME="/data/data/com.termux/files/home" +TERMUX_UID_DEFAULT=10312 +TERMUX_UID="${TERMUX_UID:-$TERMUX_UID_DEFAULT}" +BRIDGE_PORT="${BRIDGE_PORT:-8765}" +PWA_PORT="${PWA_PORT:-5173}" +LOCALLLM_URL="${LOCALLLM_URL:-http://localhost:8080/v1}" +LOCALLLM_MODEL="${LOCALLLM_MODEL:-gemma-4-e2b}" + +# Auto-detect adb serial. Honours $SERIAL if set. +detect_serial() { + if [ -n "${SERIAL:-}" ]; then echo "$SERIAL"; return; fi + local count + count=$(adb devices | awk 'NR>1 && /device$/{c++} END{print c+0}') + case "$count" in + 0) die "No adb device connected. Plug in over USB or 'adb connect :5555'.";; + 1) adb devices | awk 'NR>1 && /device$/{print $1; exit}';; + *) die "Multiple adb devices found. Set SERIAL= and re-run. Devices:\n$(adb devices | tail -n +2)";; + esac +} + +# Detect whether the phone has root (KernelSU/Magisk). Memoised in +# $_HAS_ROOT_CACHE so we only probe once per script invocation. +require_root() { + local serial="${1:?serial required}" + if [ -n "${_HAS_ROOT_CACHE:-}" ]; then + [ "$_HAS_ROOT_CACHE" = "1" ] || die "$_NO_ROOT_MSG" + return + fi + if adb -s "$serial" shell 'su root id 2>&1' 2>/dev/null | grep -q 'uid=0'; then + export _HAS_ROOT_CACHE=1 + return + fi + export _HAS_ROOT_CACHE=0 + export _NO_ROOT_MSG="Step needs root (KernelSU or Magisk) to run as the Termux user. +This phone doesn't expose 'su'. Three options: + 1. Install KernelSU or Magisk (root the device) — the cleanest fix. + 2. Bootstrap SSH manually: open Termux app, run \`pkg install openssh && passwd && sshd\`, + then re-run this script with SSH_PORT=8022 SSH_USER=u0_aXXX (see deploy/phone/README.md). + 3. Run Termux commands by hand: open the Termux app on the phone and execute the same + command shown in the bridge log (deploy/phone/README.md → 'No-root manual install')." + die "$_NO_ROOT_MSG" +} + +# Run a bash command as the Termux user via adb shell + su (rooted phone). +# Uses base64 to bypass nested quoting hell. +termuxrun() { + local serial="${1:?serial required}"; shift + require_root "$serial" + local cmd="$*" + local b64 + b64=$(printf '%s' "$cmd" | base64) + adb -s "$serial" shell "su $TERMUX_UID sh -c 'export PATH=/data/data/com.termux/files/usr/bin:/data/data/com.termux/files/usr/bin/applets HOME=$PHONE_HOME PREFIX=/data/data/com.termux/files/usr LD_LIBRARY_PATH=/data/data/com.termux/files/usr/lib TMPDIR=/data/data/com.termux/files/usr/tmp LANG=en_US.UTF-8; cd $PHONE_HOME; echo $b64 | base64 -d | bash'" +} + +# Run a command as root via adb shell (KernelSU-style su root). +rootrun() { + local serial="${1:?serial required}"; shift + require_root "$serial" + adb -s "$serial" shell "su root sh -c '$*'" +} + +# Wait for /health to respond on the phone (over adb forward). +wait_for_bridge() { + local serial="${1:?serial required}" + local tries="${2:-20}" + adb -s "$serial" forward "tcp:${BRIDGE_PORT}" "tcp:${BRIDGE_PORT}" >/dev/null 2>&1 || true + for _ in $(seq 1 "$tries"); do + if curl -sS -m 2 "http://127.0.0.1:${BRIDGE_PORT}/health" >/dev/null 2>&1; then + return 0 + fi + sleep 1 + done + return 1 +} diff --git a/deploy/phone/status.sh b/deploy/phone/status.sh new file mode 100755 index 0000000..7acca33 --- /dev/null +++ b/deploy/phone/status.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Status — print the live state of every layer. Safe to run anytime. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$HERE/_common.sh" + +hr +say "pitwall status" +hr + +# 1. adb device +SERIAL=$(detect_serial) +ok "adb: $SERIAL" + +# 2. Mac PWA serve +PWA_PIDS=$(lsof -ti :"$PWA_PORT" 2>/dev/null || true) +if [ -n "$PWA_PIDS" ]; then + ok "Mac PWA serve on :$PWA_PORT (pid $PWA_PIDS)" +else + warn "Mac PWA serve on :$PWA_PORT — not running" +fi + +# 3. adb port wiring +REVERSE=$(adb -s "$SERIAL" reverse --list 2>/dev/null | grep ":$PWA_PORT" || true) +FORWARD=$(adb -s "$SERIAL" forward --list 2>/dev/null | grep ":$BRIDGE_PORT" || true) +[ -n "$REVERSE" ] && ok "adb reverse :$PWA_PORT active" || warn "adb reverse :$PWA_PORT not set up" +[ -n "$FORWARD" ] && ok "adb forward :$BRIDGE_PORT active" || warn "adb forward :$BRIDGE_PORT not set up" + +# 4. Phone bridge — must respond AND return JSON (some random :8765 +# squatter could return HTML or a 404, which would crash json.load). +# Redirect curl's diagnostic output to /dev/null so it doesn't leak +# into our parsed http_code field. +health_resp=$(curl -s -m 3 -o /tmp/_status_body -w '%{http_code}|%{content_type}' "http://127.0.0.1:${BRIDGE_PORT}/health" 2>/dev/null || echo "000|") +http_code=$(echo "$health_resp" | cut -d'|' -f1) +content_type=$(echo "$health_resp" | cut -d'|' -f2) +if [ "$http_code" = "200" ] && [[ "$content_type" == *json* ]]; then + python3 -c " +import sys, json +d = json.load(open('/tmp/_status_body')) +c = d.get('can') or {} +l = d.get('litert') or {} +print(f\" status: {d.get('status')}\") +print(f\" active_session_id: {d.get('active_session_id')}\") +print(f\" track: {d.get('track')}\") +print(f\" can.connected={c.get('connected')} fps={c.get('fps')} frames={c.get('frames_total')}\") +print(f\" litert: up={l.get('up')} model={l.get('http_model')} url={l.get('http_url')}\") +" 2>/dev/null || warn "bridge returned 200 + json but failed to parse" +elif [ "$http_code" = "200" ]; then + warn "phone bridge :$BRIDGE_PORT — something responded but not JSON (got $content_type). Stale squatter?" +elif [ "$http_code" = "000" ]; then + warn "phone bridge :$BRIDGE_PORT — no response (forward not set up? bridge dead?)" +else + warn "phone bridge :$BRIDGE_PORT — HTTP $http_code (expected 200)" +fi +rm -f /tmp/_status_body + +# 5. Replay state — only if bridge looked healthy above +if [ "$http_code" = "200" ] && [[ "$content_type" == *json* ]]; then + replay_code=$(curl -sS -m 3 -o /tmp/_replay_body -w '%{http_code}' "http://127.0.0.1:${BRIDGE_PORT}/session/replay/status" 2>&1 || echo "000") + if [ "$replay_code" = "200" ]; then + python3 -c " +import sys, json +d = json.load(open('/tmp/_replay_body')) +if d.get('running'): + print(f\" replay running: {d.get('source_session_id')} @ {d.get('speed')}x {d.get('frame_idx')}/{d.get('total_frames')} ({d.get('elapsed_s'):.0f}s elapsed)\") +else: + print(' replay: not running') +" 2>/dev/null || true + fi + rm -f /tmp/_replay_body +fi + +hr diff --git a/src/pitwall/__init__.py b/src/pitwall/__init__.py index df89d82..20e9dfa 100644 --- a/src/pitwall/__init__.py +++ b/src/pitwall/__init__.py @@ -33,14 +33,16 @@ def register_blueprints(app): from pitwall.features.bp_core import bp as bp_core from pitwall.features.session.bp_session import bp as bp_session from pitwall.features.session.bp_analysis import bp as bp_analysis + from pitwall.features.session.bp_replay import bp as bp_replay from pitwall.features.coaching.bp_coaching import bp as bp_coaching from pitwall.features.telemetry.bp_signals import bp as bp_signals from pitwall.features.track.bp_track import bp as bp_track from pitwall.features.realtime.bp_realtime import bp as bp_realtime from pitwall.features.bp_diagnostics import bp as bp_diagnostics - for blueprint in (bp_core, bp_session, bp_analysis, bp_coaching, - bp_signals, bp_track, bp_realtime, bp_diagnostics): + for blueprint in (bp_core, bp_session, bp_analysis, bp_replay, + bp_coaching, bp_signals, bp_track, bp_realtime, + bp_diagnostics): app.register_blueprint(blueprint) def create_app() -> Flask: diff --git a/src/pitwall/features/coaching/bp_coaching.py b/src/pitwall/features/coaching/bp_coaching.py index bb71416..f24e755 100644 --- a/src/pitwall/features/coaching/bp_coaching.py +++ b/src/pitwall/features/coaching/bp_coaching.py @@ -318,7 +318,27 @@ def coach_brief(): conn.execute("INSERT INTO conversations (session_id, driver_id, role, text, focus_items, emotion) VALUES (?,'coach_brief',?,?,?)",[sid_param or "", driver_id, narrative, json.dumps(focus), emotion]) except (DuckDbUnavailable, _DUCKDB_ERROR) as e: log.warning("conversations insert (brief) failed: %s", e) - return jsonify({"driver_id":driver_id,"date":today,"weather_phase":weather_phase.id,"surface_state":weather_phase.surface_state,"weather_note":weather_phase.coaching_note,"weakest_recent_corner":profile.get("weakest_recent_corner"),"biggest_recent_improvement":profile.get("biggest_improvement"),"danger_zones_today":danger_today,"narrative_md":narrative,"focus":focus,"emotion":emotion}) + # Empty narrative means the LLM didn't speak (offline, token cap, or + # transport error). Surface the reason via a clear `error` field so + # the PWA can render an honest "brief unavailable" panel instead of + # synthesizing fake coach voice. Pull the most recent friction row + # for this driver/role so the user sees the actual cause. + error: str | None = None + if not narrative or not narrative.strip(): + error = "LLM offline or returned empty narrative" + if state.has_duckdb: + try: + with db_conn() as conn: + row = conn.execute( + "SELECT error, backend, fell_back FROM llm_friction " + "WHERE role = 'brief' ORDER BY ts DESC LIMIT 1" + ).fetchone() + if row and row[0]: + # truncate to keep the JSON small + error = str(row[0])[:240] + except (DuckDbUnavailable, _DUCKDB_ERROR): + pass + return jsonify({"driver_id":driver_id,"date":today,"weather_phase":weather_phase.id,"surface_state":weather_phase.surface_state,"weather_note":weather_phase.coaching_note,"weakest_recent_corner":profile.get("weakest_recent_corner"),"biggest_recent_improvement":profile.get("biggest_improvement"),"danger_zones_today":danger_today,"narrative_md":narrative,"focus":focus,"emotion":emotion,"error":error}) @bp.route("/coach/ask", methods=["POST"]) def coach_ask(): diff --git a/src/pitwall/features/coaching/litert_coach.py b/src/pitwall/features/coaching/litert_coach.py index 2404cb4..d924a88 100644 --- a/src/pitwall/features/coaching/litert_coach.py +++ b/src/pitwall/features/coaching/litert_coach.py @@ -423,23 +423,21 @@ def brief(self, *, driver_id: str, today_iso: str, weather_phase: str, danger_zones_today=danger_zones_today or [], goal=goal, ) + # No-fake-data policy: never synthesize a brief from a template. + # If the LLM can't speak, return empty narrative + empty focus and + # let the PWA render an honest error state. The friction sink + # records WHY so the user can debug from /diagnostics/llm_friction. if self._llm is None: _emit_friction({ "session_id": session_id, "role": "brief", "mode": CoachMode.PRE_BRIEF.value, "backend": self.backend, "prompt_chars": len(sys_p) + len(usr_p), "completion_chars": 0, "latency_ms": 0.0, - "truncated": False, "fell_back": True, + "truncated": False, "fell_back": False, "error": self._init_error or "engine_not_loaded", "emotion": "neutral", }) - narr, focus = _templated_pre_brief( - driver_id=driver_id, weather_phase=weather_phase, - surface_state=surface_state, markers_selected=markers_selected, - weakest_recent_corner=weakest_recent_corner, - danger_zones_today=danger_zones_today or [], - ) - return narr, focus, "neutral" + return "", [], "neutral" try: raw = self._generate( sys_p, usr_p, session_id=session_id, @@ -447,32 +445,20 @@ def brief(self, *, driver_id: str, today_iso: str, weather_phase: str, ) cleaned, emotion = _extract_emotion(raw) narr, focus = _split_brief_narrative_and_focus(cleaned) - # `_generate` swallows transport/HTTP exceptions and returns "" — - # in that case the LLM didn't actually contribute anything, so - # fall back to the templated brief just like a thrown exception. + # `_generate` swallows transport/HTTP exceptions and returns "". + # In that case the LLM didn't actually contribute anything; + # return an empty narrative (no template synthesis) so the PWA + # can show "brief unavailable" rather than fabricated text. if not narr or not narr.strip(): - _log.warning("brief LLM returned empty — using templated fallback") - narr, focus = _templated_pre_brief( - driver_id=driver_id, weather_phase=weather_phase, - surface_state=surface_state, markers_selected=markers_selected, - weakest_recent_corner=weakest_recent_corner, - danger_zones_today=danger_zones_today or [], - ) - emotion = "neutral" + _log.warning("brief LLM returned empty — surfacing empty narrative") + return "", [], "neutral" return narr, focus, emotion except Exception as exc: - # LLM / parse failure — fall back to the templated narrative. - # Caller already records a friction record inside _generate, so - # we only need a log breadcrumb here, not a re-raise. - _log.warning("brief LLM/parse failed (%s) — using templated fallback", - exc) - narr, focus = _templated_pre_brief( - driver_id=driver_id, weather_phase=weather_phase, - surface_state=surface_state, markers_selected=markers_selected, - weakest_recent_corner=weakest_recent_corner, - danger_zones_today=danger_zones_today or [], - ) - return narr, focus, "neutral" + # LLM / parse failure. Surface the empty + error state instead of + # synthesizing content. `_generate` already wrote a friction + # record with the error reason. + _log.warning("brief LLM/parse failed (%s) — surfacing empty narrative", exc) + return "", [], "neutral" def debrief(self, bundle: dict, *, driver_level: Optional[str] = None @@ -490,7 +476,7 @@ def debrief(self, bundle: dict, "mode": CoachMode.POST_SESSION.value, "backend": self.backend, "prompt_chars": len(sys_p) + len(usr_p), "completion_chars": 0, "latency_ms": 0.0, - "truncated": False, "fell_back": True, + "truncated": False, "fell_back": False, "error": self._init_error or "engine_not_loaded", "emotion": "neutral", }) diff --git a/src/pitwall/features/session/bp_replay.py b/src/pitwall/features/session/bp_replay.py new file mode 100644 index 0000000..72416d8 --- /dev/null +++ b/src/pitwall/features/session/bp_replay.py @@ -0,0 +1,355 @@ +"""bridge.bp_replay — Blueprint: session replay (simulated car from recording). + +Drives the PWA's live HUD from a previously-recorded session in the bridge +DB, instead of from a real CAN bus. Publishes frames to `telemetry_bus` +with the exact same canonical shape `can_reader._flush_wide` uses, so the +PWA can't tell the difference between live and replay. + +Endpoints: + POST /session/replay/start — body { source_session_id, speed, loop } + POST /session/replay/stop — { stopped, frames_emitted } + GET /session/replay/status — running/idle, frame_idx, elapsed_s, ... + +One replay thread per bridge process. Starting a replay while a CAN reader +is running stops the CAN reader first (avoids two publishers on the same +session_id). +""" + +import logging +import threading +import time + +from flask import Blueprint, request, jsonify + +from pitwall.state import state +from pitwall.db import db_conn, DuckDbUnavailable, WIDE_SIGNAL_NAMES # noqa: F401 + + +log = logging.getLogger(__name__) + +bp = Blueprint("replay", __name__) + + +# ── Module-level replay state ──────────────────────────────────────────────── + +_replay_lock = threading.Lock() +_replay_thread: threading.Thread | None = None +_replay_stop_event = threading.Event() +_replay_status: dict = {"running": False} + + +# Mapping from the wide-table column names to the SSE canonical names that +# `can_reader._flush_wide` publishes. The PWA reads these — don't rename. +_WIDE_COL_TO_SSE = { + "timestamp": "timestamp", + "distance_m": "distance", + "speed_ms": "speed", + "g_lat": "g_lat", + "g_long": "g_long", + "combo_g": "combo_g", + "brake_bar": "brake_pressure", + "throttle_pct": "throttle", + "steering_deg": "steering", + "rpm": "rpm", + "lat": "lat", + "lon": "lon", +} + + +def _load_replay_data(sid: str): + """Return (wide_rows, extras_by_frame_idx) for the given session. + + wide_rows: list of dicts in canonical SSE shape, one per telemetry row. + extras_by_frame_idx: list[dict] parallel to wide_rows; each dict holds + the latest-known tall-signal value (by signal name) at that frame's + timestamp, for signal names not already in the wide snapshot. + """ + with db_conn() as conn: + wide = conn.execute( + """SELECT timestamp, distance_m, speed_ms, g_lat, g_long, + combo_g, brake_bar, throttle_pct, steering_deg, + rpm, lat, lon + FROM telemetry + WHERE session_id = ? + ORDER BY timestamp""", + [sid], + ).fetchall() + + # Tall signals join their human-readable name from signal_registry. + tall = conn.execute( + """SELECT ts.t, sr.name, ts.value + FROM telemetry_signals ts + JOIN signal_registry sr USING(signal_id) + WHERE ts.session_id = ? + ORDER BY ts.t""", + [sid], + ).fetchall() + + wide_cols = ("timestamp", "distance_m", "speed_ms", "g_lat", "g_long", + "combo_g", "brake_bar", "throttle_pct", "steering_deg", + "rpm", "lat", "lon") + + wide_rows: list[dict] = [] + for row in wide: + snap = {} + for col, val in zip(wide_cols, row): + sse_key = _WIDE_COL_TO_SSE[col] + snap[sse_key] = float(val) if val is not None else None + wide_rows.append(snap) + + # Group tall samples by nearest wide-row timestamp. Walk both lists in + # one O(N+M) pass — both are timestamp-sorted. + extras_by_frame: list[dict] = [dict() for _ in wide_rows] + if not wide_rows or not tall: + return wide_rows, extras_by_frame + + # latest[name] = most recent value seen so far across all earlier tall samples + latest: dict[str, float] = {} + j = 0 # cursor into tall + n_tall = len(tall) + wide_ts = [r["timestamp"] for r in wide_rows] + n_wide = len(wide_rows) + + # Skip any used SSE canonical names so extras don't shadow them. + canonical_keys = set(_WIDE_COL_TO_SSE.values()) + + for i in range(n_wide): + t_i = wide_ts[i] + # advance j while tall_t <= t_i + while j < n_tall and tall[j][0] <= t_i: + _, name, value = tall[j] + if name not in canonical_keys and value is not None: + try: + latest[name] = float(value) + except (TypeError, ValueError): + pass + j += 1 + # Snapshot of latest tall values at this frame + if latest: + extras_by_frame[i] = dict(latest) + + return wide_rows, extras_by_frame + + +def _replay_loop(source_sid: str, speed: float, loop: bool, + wide_rows: list[dict], extras_by_frame: list[dict]): + """Background thread: emit frames to telemetry_bus at recorded cadence.""" + try: + from pitwall.features.realtime.bp_realtime import telemetry_bus + except Exception as e: # noqa: BLE001 + log.exception("replay: failed to import telemetry_bus: %s", e) + with _replay_lock: + _replay_status.clear() + _replay_status.update({"running": False, "error": str(e)}) + return + + n_frames = len(wide_rows) + started_at = time.monotonic() + frames_emitted = 0 + last_log_idx = -1 + + try: + while not _replay_stop_event.is_set(): + for i in range(n_frames): + if _replay_stop_event.is_set(): + break + snap = wide_rows[i] + extras = extras_by_frame[i] or {} + frame = {**snap, **{k: v for k, v in extras.items() + if k not in snap}} + telemetry_bus.publish(source_sid, frame) + frames_emitted += 1 + + # Update status (cheap, under lock) + with _replay_lock: + _replay_status["frame_idx"] = i + _replay_status["total_frames"] = n_frames + _replay_status["elapsed_s"] = time.monotonic() - started_at + _replay_status["frames_emitted"] = frames_emitted + # est_remaining_s = (last_ts - cur_ts) / speed + last_ts = wide_rows[-1].get("timestamp") or 0.0 + cur_ts = snap.get("timestamp") or 0.0 + rem = max(0.0, (last_ts - cur_ts)) / max(speed, 1e-6) + _replay_status["est_remaining_s"] = rem + + # Keep state.active_session_id accurate (for /health) + state.active_session_id = source_sid + + if i // 500 != last_log_idx: + last_log_idx = i // 500 + log.info("replay: frame %d/%d (sid=%s)", + i, n_frames, source_sid) + + # Sleep to the next frame's timestamp + if i + 1 < n_frames: + t_now = snap.get("timestamp") + t_next = wide_rows[i + 1].get("timestamp") + if t_now is not None and t_next is not None: + dt = (t_next - t_now) / max(speed, 1e-6) + if dt < 0: + dt = 0.0 + # Cap individual sleep so a stop signal isn't blocked + # for too long. Use Event.wait so we exit early on stop. + remaining = dt + while remaining > 0 and not _replay_stop_event.is_set(): + chunk = min(remaining, 1.0) + if _replay_stop_event.wait(timeout=chunk): + break + remaining -= chunk + + if not loop or _replay_stop_event.is_set(): + break + # On loop, restart timing baseline so elapsed_s reflects current pass + started_at = time.monotonic() + log.info("replay: looping back to frame 0 (sid=%s)", source_sid) + except Exception as e: # noqa: BLE001 + log.exception("replay: thread crashed: %s", e) + with _replay_lock: + _replay_status.clear() + _replay_status.update({ + "running": False, + "error": str(e), + "frames_emitted": frames_emitted, + }) + return + + with _replay_lock: + _replay_status["running"] = False + _replay_status["frames_emitted"] = frames_emitted + log.info("replay: thread exiting (frames_emitted=%d, loop=%s)", + frames_emitted, loop) + + +# ── HTTP routes ────────────────────────────────────────────────────────────── + +@bp.route("/session/replay/start", methods=["POST"]) +def replay_start(): + """Start a session replay. Body JSON: source_session_id, speed, loop.""" + global _replay_thread + + body = request.get_json(silent=True) or {} + sid = (body.get("source_session_id") or "").strip() + if not sid: + return jsonify({"error": "source_session_id required"}), 400 + try: + speed = float(body.get("speed", 1.0)) + except (TypeError, ValueError): + speed = 1.0 + if speed <= 0: + speed = 1.0 + loop = bool(body.get("loop", False)) + + with _replay_lock: + if _replay_thread is not None and _replay_thread.is_alive(): + return jsonify({ + "error": "a replay is already running; stop it first", + "source_session_id": _replay_status.get("source_session_id"), + }), 409 + + # Load recorded frames (outside the lock — DB read can take a moment) + try: + wide_rows, extras_by_frame = _load_replay_data(sid) + except DuckDbUnavailable: + return jsonify({"error": "no DB backend available"}), 503 + except Exception as e: # noqa: BLE001 + log.exception("replay: load failed: %s", e) + return jsonify({"error": f"failed to load replay data: {e}"}), 500 + + if not wide_rows: + return jsonify({ + "error": "source session has no telemetry rows", + "source_session_id": sid, + }), 404 + + # est_duration_s = (last_ts - first_ts) / speed + first_ts = wide_rows[0].get("timestamp") or 0.0 + last_ts = wide_rows[-1].get("timestamp") or 0.0 + est_duration_s = max(0.0, (last_ts - first_ts)) / max(speed, 1e-6) + + # Stop any running CAN reader to avoid two publishers on the same sid + if state.can_reader is not None: + try: + log.info("replay: stopping existing CAN reader before replay") + state.can_reader.stop(timeout=2.0) + except Exception as e: # noqa: BLE001 + log.warning("replay: CAN reader stop raised: %s", e) + state.can_reader = None + + state.active_session_id = sid + + with _replay_lock: + _replay_stop_event.clear() + _replay_status.clear() + _replay_status.update({ + "running": True, + "source_session_id": sid, + "speed": speed, + "loop": loop, + "frame_idx": 0, + "total_frames": len(wide_rows), + "elapsed_s": 0.0, + "est_remaining_s": est_duration_s, + "frames_emitted": 0, + }) + _replay_thread = threading.Thread( + target=_replay_loop, + args=(sid, speed, loop, wide_rows, extras_by_frame), + name="pitwall-replay", + daemon=True, + ) + _replay_thread.start() + + return jsonify({ + "replay_id": sid, + "source_session_id": sid, + "total_frames": len(wide_rows), + "est_duration_s": est_duration_s, + "speed": speed, + "loop": loop, + }), 200 + + +@bp.route("/session/replay/stop", methods=["POST"]) +def replay_stop(): + """Stop the running replay (no-op if not running).""" + global _replay_thread + + with _replay_lock: + running = (_replay_thread is not None + and _replay_thread.is_alive()) + if not running: + return jsonify({ + "stopped": False, + "frames_emitted": int(_replay_status.get("frames_emitted", 0)), + }), 200 + thread = _replay_thread + + _replay_stop_event.set() + thread.join(timeout=2.0) + + with _replay_lock: + _replay_thread = None + _replay_status["running"] = False + frames_emitted = int(_replay_status.get("frames_emitted", 0)) + + return jsonify({"stopped": True, "frames_emitted": frames_emitted}), 200 + + +@bp.route("/session/replay/status", methods=["GET"]) +def replay_status(): + """Snapshot of the replay state. {"running": false} when idle.""" + with _replay_lock: + running = (_replay_thread is not None + and _replay_thread.is_alive()) + if not running: + # If a thread crashed it may have left running=False + error set + if _replay_status.get("error"): + return jsonify({ + "running": False, + "error": _replay_status.get("error"), + "frames_emitted": int(_replay_status.get("frames_emitted", 0)), + }), 200 + return jsonify({"running": False}), 200 + # Return a shallow copy so callers don't see further mutation mid-serialise + snap = dict(_replay_status) + return jsonify(snap), 200 diff --git a/src/pwa/index.html b/src/pwa/index.html index d72afdb..ab50b65 100644 --- a/src/pwa/index.html +++ b/src/pwa/index.html @@ -3,7 +3,7 @@ - + diff --git a/src/pwa/public/icons/apple-touch-icon-180.png b/src/pwa/public/icons/apple-touch-icon-180.png new file mode 100644 index 0000000..a34ac5b Binary files /dev/null and b/src/pwa/public/icons/apple-touch-icon-180.png differ diff --git a/src/pwa/public/icons/icon-192.png b/src/pwa/public/icons/icon-192.png new file mode 100644 index 0000000..69f7740 Binary files /dev/null and b/src/pwa/public/icons/icon-192.png differ diff --git a/src/pwa/public/icons/icon-512.png b/src/pwa/public/icons/icon-512.png new file mode 100644 index 0000000..fd83594 Binary files /dev/null and b/src/pwa/public/icons/icon-512.png differ diff --git a/src/pwa/public/icons/icon-maskable-192.png b/src/pwa/public/icons/icon-maskable-192.png new file mode 100644 index 0000000..6f9840f Binary files /dev/null and b/src/pwa/public/icons/icon-maskable-192.png differ diff --git a/src/pwa/public/icons/icon-maskable-512.png b/src/pwa/public/icons/icon-maskable-512.png new file mode 100644 index 0000000..db1b5b1 Binary files /dev/null and b/src/pwa/public/icons/icon-maskable-512.png differ diff --git a/src/pwa/src/app/App.vue b/src/pwa/src/app/App.vue index 3a1930f..4d05b7c 100644 --- a/src/pwa/src/app/App.vue +++ b/src/pwa/src/app/App.vue @@ -1,6 +1,6 @@