From cdb032b8880350b65619e171bbdfef50ac275c0e Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Sun, 9 Aug 2026 22:48:11 -0400 Subject: [PATCH] Add phone hotspot uplink Prefer a configured phone hotspot for outbound synchronization, preserve secure write-only credentials, and recover through home WiFi or the local Pi hotspot. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PLAN.md | 41 ++- app.py | 34 ++- deploy/90-yaris-uplink | 9 + deploy/install_netwatch.sh | 27 +- deploy/yaris-hotspot-dns.conf | 4 + deploy/yaris-netwatch.service | 8 +- deploy/yaris-netwatch.sh | 70 ----- deploy/yaris-netwatch.timer | 2 +- deploy/yaris-uplink-wait.service | 12 + deploy/yaris-uplink.sudoers | 3 + deploy/yaris_uplink.py | 451 +++++++++++++++++++++++++++++++ modules/app_network.py | 144 ++++++++++ readme.md | 9 +- static/js/settings.js | 128 +++++++++ templates/settings.html | 38 +++ tests/test_app_network.py | 74 +++++ tests/test_yaris_uplink.py | 245 +++++++++++++++++ 17 files changed, 1211 insertions(+), 88 deletions(-) create mode 100644 deploy/90-yaris-uplink create mode 100644 deploy/yaris-hotspot-dns.conf delete mode 100755 deploy/yaris-netwatch.sh create mode 100644 deploy/yaris-uplink-wait.service create mode 100644 deploy/yaris-uplink.sudoers create mode 100644 deploy/yaris_uplink.py create mode 100644 modules/app_network.py create mode 100644 tests/test_app_network.py create mode 100644 tests/test_yaris_uplink.py diff --git a/PLAN.md b/PLAN.md index f1770c1..5362709 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1092,7 +1092,7 @@ defaults and shared alert/navigation shell. Browser testing at 390x844 found and top-bar overlap by adding a dedicated bottom mobile save action; the real save then completed with no horizontal overflow or console errors. -### Phase 22: Phone-hotspot internet uplink — 🚧 PLANNED +### Phase 22: Phone-hotspot internet uplink — ✅ DONE Add a no-SIM internet path where the Pi joins a user's phone hotspot as a WiFi client and uses the phone's cellular data for outbound synchronization. This must work with an ordinary iPhone or Android hotspot and support tracking rows, alert events/state, software/server checks, and large @@ -1124,10 +1124,47 @@ client simultaneously: while still associated, automatic Pi-hotspot recovery, local `yaris.local` access, tracking catch-up, and a disposable chunked video upload. Record phone/OS limitations before committing. +**Implemented and hardware-tested 2026-08-09 with a real Pixel hotspot.** +- Added a root-owned `yaris-uplink` NetworkManager helper, systemd timer/wait service, dispatcher + transition recorder, restricted sudoers entries, and Settings APIs/UI. Priority is + `phone-hotspot` (20), `home-wifi` (10), then the Pi `Hotspot` (-10). A non-disruptive scan checks + that the phone SSID is actually visible before replacing a working home connection. +- The intended single-phone workflow is **save credentials first → select Wait for hotspot → enable + the phone hotspot**. The wait service gives the user ten seconds before dropping the Pi AP, scans + for up to two minutes, and always restarts the periodic watcher. If the phone never appears or + later disappears, NetworkManager/helper recovery selects home WiFi when available and otherwise + restores the Pi hotspot. Multiple dead-man tests independently forced home WiFi recovery. +- Phone credentials go from browser to the privileged helper over stdin and into an atomic + root-only (`0600`) NetworkManager keyfile. Passwords never enter SQLite, command arguments, + status files, HTML/JavaScript responses, or read APIs. The UI exposes only configured state and + SSID, supports replace/remove, and preserves in-progress edits during status polling. +- Status reports actual connection/mode/IP, NetworkManager-proven internet connectivity, whether + large uploads are currently possible, last transition/error, and the last phone-assigned direct + address. The non-secret transition record persists across reboot under `/var/lib/yaris-obd`. +- Pi-hosted hotspot testing initially found that the real phone could open `10.42.0.1` but not + `yaris.local` even though Avahi correctly joined/published on `wlan0`. Added a NetworkManager + shared-dnsmasq unicast fallback for `yaris.local`; the repeated dead-man test then passed. +- Pixel tethering has a different limitation: the hotspot-host phone can reach the Pi by its DHCP + address, but relays neither mDNS (`yaris.local`) nor the DHCP hostname (`yaris`). Settings + therefore retains and links the last direct phone-network address with a DHCP-change warning. + The tested Pixel repeatedly assigned the same address, but the implementation does not assume it + is permanent. +- Proved cold boot both with Pixel absent (selected home WiFi) and present (selected Pixel directly, + Web UI/service started, and the server received a fresh heartbeat). Turning Pixel off repeatedly + restored home WiFi in about 16 seconds. The same fallback code is covered for restoring the Pi + hotspot when home WiFi is unavailable. +- Proved real tracking uploads through Pixel data against remote Postgres. The first disposable + 1 MiB video exposed repeated TLS EOF failures over tethering despite small requests working; + setting only the phone profile MTU to 1400 fixed the path, after which a second 1 MiB disposable + video reached the real server over Pixel data. Both named local/server files and exact database + upload/hash rows were removed afterward, and dashcam uploads were restored enabled. +- The future Phase 24 alert-event synchronization queue will automatically use this normal IP + uplink; Phase 22 does not prematurely implement that server schema/API. + ### Phase 23: Finish web UI redesign — 🚧 PLANNED **Roadmap decision 2026-08-09: keep the redesign after functional uplink work.** Phase 15 already pulled the architecture-level work forward: every page now shares one Jinja shell and -the later list-based features have a reusable component contract. Phases 16-21 still add most of +the later list-based features have a reusable component contract. Phases 16-22 still add most of the final screens and dashboard data. Redesigning their visual details before those features exist would mean guessing at layouts and polishing the same surfaces twice; keeping the final pass here lets it cover the complete product once, while new phases still build on consistent diff --git a/app.py b/app.py index bb6c982..7034a5c 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,5 @@ from flask import Flask, render_template, make_response, json, jsonify, request, send_file -from modules import app_gps, app_events, app_car, app_account, app_gps,app_obd, app_sql, app_dash, app_upload, app_video, app_health, app_alerts, app_settings +from modules import app_gps, app_events, app_car, app_account, app_gps,app_obd, app_sql, app_dash, app_upload, app_video, app_health, app_alerts, app_settings, app_network import os, time from gevent.pywsgi import WSGIServer @@ -46,6 +46,38 @@ def api_settings(): return jsonify(error="Invalid settings.", fields=errors), 400 return jsonify(app_settings.get_settings()) +@app.route('/api/uplink/status') +def uplink_status(): + try: + return jsonify(app_network.get_status()) + except app_network.NetworkConfigurationError as error: + return jsonify(error=str(error)), 503 + +@app.route('/api/uplink/config', methods=['POST', 'DELETE']) +def uplink_config(): + try: + if request.method == 'DELETE': + app_network.remove_phone_hotspot() + else: + body = request.get_json(silent=True) + if not isinstance(body, dict): + return jsonify(error="Expected a JSON object."), 400 + app_network.configure_phone_hotspot( + body.get("ssid"), + body.get("password"), + ) + return jsonify(app_network.get_status()) + except app_network.NetworkConfigurationError as error: + return jsonify(error=str(error)), 400 + +@app.route('/api/uplink/connect', methods=['POST']) +def uplink_connect(): + try: + app_network.request_phone_connection() + except app_network.NetworkConfigurationError as error: + return jsonify(error=str(error)), 503 + return jsonify(status="connection_requested"), 202 + @app.route('/api/trips') def trips(): return jsonify({"trips": app_sql.list_trips()}) diff --git a/deploy/90-yaris-uplink b/deploy/90-yaris-uplink new file mode 100644 index 0000000..0e35a5b --- /dev/null +++ b/deploy/90-yaris-uplink @@ -0,0 +1,9 @@ +#!/bin/sh + +[ "$1" = "wlan0" ] || exit 0 + +case "$2" in + up|down|connectivity-change|dhcp4-change) + /usr/local/bin/yaris-uplink record >/dev/null 2>&1 || true + ;; +esac diff --git a/deploy/install_netwatch.sh b/deploy/install_netwatch.sh index e0f9775..f2fa916 100755 --- a/deploy/install_netwatch.sh +++ b/deploy/install_netwatch.sh @@ -1,19 +1,34 @@ #!/usr/bin/env bash -# One-time install of the yaris-obd WiFi hotspot <-> home-WiFi auto-switch +# One-time install of the yaris-obd phone/home/local-hotspot auto-switch # watcher on the Pi. Run this ON THE PI as the `yaris` user (needs sudo): # cd /home/yaris/app/yaris-obd && bash deploy/install_netwatch.sh # -# Prerequisite: the "home-wifi" (home WiFi) and "Hotspot" (AP mode) NetworkManager -# connection profiles must already exist (see PLAN.md Phase 6 for the nmcli -# commands used to create them) with home-wifi having a higher -# connection.autoconnect-priority than Hotspot. +# Prerequisite: the "home-wifi" and "Hotspot" NetworkManager profiles must +# already exist (see PLAN.md Phase 6). The optional "phone-hotspot" profile is +# created through the Settings page. set -euo pipefail REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -sudo install -m 755 "$REPO_DIR/deploy/yaris-netwatch.sh" /usr/local/bin/yaris-netwatch.sh +sudo systemctl stop yaris-uplink-wait.service 2>/dev/null || true +sudo systemctl stop yaris-netwatch.timer yaris-netwatch.service 2>/dev/null || true +sudo rm -f /usr/local/bin/yaris-netwatch.sh +sudo install -m 755 "$REPO_DIR/deploy/yaris_uplink.py" /usr/local/bin/yaris-uplink +sudo install -m 440 "$REPO_DIR/deploy/yaris-uplink.sudoers" /etc/sudoers.d/yaris-uplink +sudo visudo -cf /etc/sudoers.d/yaris-uplink +sudo install -m 644 "$REPO_DIR/deploy/yaris-hotspot-dns.conf" \ + /etc/NetworkManager/dnsmasq-shared.d/yaris-hotspot-dns.conf +sudo install -m 755 "$REPO_DIR/deploy/90-yaris-uplink" \ + /etc/NetworkManager/dispatcher.d/90-yaris-uplink +sudo nmcli connection modify Hotspot ipv4.method shared ipv4.addresses 10.42.0.1/24 +if nmcli connection show phone-hotspot >/dev/null 2>&1; then + sudo nmcli connection modify phone-hotspot \ + ipv4.dhcp-send-hostname yes ipv4.dhcp-hostname yaris \ + 802-11-wireless.mtu 1400 +fi sudo cp "$REPO_DIR/deploy/yaris-netwatch.service" /etc/systemd/system/yaris-netwatch.service sudo cp "$REPO_DIR/deploy/yaris-netwatch.timer" /etc/systemd/system/yaris-netwatch.timer +sudo cp "$REPO_DIR/deploy/yaris-uplink-wait.service" /etc/systemd/system/yaris-uplink-wait.service sudo systemctl daemon-reload sudo systemctl enable --now yaris-netwatch.timer diff --git a/deploy/yaris-hotspot-dns.conf b/deploy/yaris-hotspot-dns.conf new file mode 100644 index 0000000..815d7e9 --- /dev/null +++ b/deploy/yaris-hotspot-dns.conf @@ -0,0 +1,4 @@ +# Unicast fallback for phones/browsers that do not resolve mDNS on a WiFi +# network without internet. Avahi still publishes the same name over mDNS. +address=/yaris.local/10.42.0.1 +address=/yaris/10.42.0.1 diff --git a/deploy/yaris-netwatch.service b/deploy/yaris-netwatch.service index eb96acf..7cd62f5 100644 --- a/deploy/yaris-netwatch.service +++ b/deploy/yaris-netwatch.service @@ -1,11 +1,9 @@ [Unit] -Description=yaris-obd network watcher (hotspot -> home WiFi reconnect check) +Description=yaris-obd phone/home/local hotspot uplink watcher After=NetworkManager.service Wants=NetworkManager.service [Service] Type=oneshot -# Retry loop (down AP, rescan, retry connect up to 2x) can take ~80s worst -# case - default 90s systemd start timeout was too tight a margin. -TimeoutStartSec=180 -ExecStart=/usr/local/bin/yaris-netwatch.sh +TimeoutStartSec=120 +ExecStart=/usr/local/bin/yaris-uplink check diff --git a/deploy/yaris-netwatch.sh b/deploy/yaris-netwatch.sh deleted file mode 100755 index a068d99..0000000 --- a/deploy/yaris-netwatch.sh +++ /dev/null @@ -1,70 +0,0 @@ -#!/bin/bash -# yaris-netwatch.sh -# -# Runs periodically via yaris-netwatch.timer. If wlan0 is currently in -# AP/hotspot mode, briefly attempts to reconnect to the known home WiFi. -# If that succeeds, we're back on home WiFi (used for syncing itinerary/ -# dashcam data to the remote server). If it fails (still out of range), -# reactivates the hotspot so the web UI stays reachable locally in the car. -# -# The Pi's onboard WiFi radio (BCM43438) cannot act as an AP and scan for -# client networks at the same time, so this brief switch-and-check is the -# only way to detect "home WiFi is back in range" while sitting in AP mode. -# The reverse direction (home WiFi -> hotspot, i.e. leaving range) is handled -# automatically by NetworkManager's own autoconnect-priority mechanism and -# needs no watcher: the "home-wifi" profile has a higher -# connection.autoconnect-priority than Hotspot, so NM tries it first whenever -# the device needs to reconnect, and falls through to Hotspot on its own once -# home WiFi drops out of range. - -set -u - -LOG_TAG="yaris-netwatch" -HOME_CON="home-wifi" -AP_CON="Hotspot" -IFACE="wlan0" -LAST_LOG="/tmp/yaris-netwatch-last.log" - -active_con() { - nmcli -t -f DEVICE,CONNECTION device status | awk -F: -v d="$IFACE" '$1==d {print $2}' -} - -current="$(active_con)" - -if [ "$current" != "$AP_CON" ]; then - # Already on home WiFi (or something else) - nothing to do. - logger -t "$LOG_TAG" "wlan0 is on '$current', no action needed" - exit 0 -fi - -logger -t "$LOG_TAG" "wlan0 is on hotspot, checking for home WiFi ($HOME_CON)..." - -# Real-hardware finding: a plain "nmcli con up home-wifi" straight out of AP -# mode reliably reports "not found" even when actually in range, repeatedly -# (confirmed: 4 failures in a row over 13min while sitting right next to the -# router). NM's scan cache for wlan0 is stale/empty coming out of AP mode -# (the radio literally can't scan while acting as an AP - see comment above), -# and a single con-up attempt doesn't wait for a fresh scan to land before -# giving up. Fix: explicitly drop the AP connection first to free the radio, -# force a fresh scan and give it a few seconds to populate, then retry the -# connect attempt with a longer timeout up to 2 times before conceding and -# restoring the hotspot. -nmcli con down "$AP_CON" >"$LAST_LOG" 2>&1 - -connected=false -for attempt in 1 2; do - nmcli device wifi rescan ifname "$IFACE" >>"$LAST_LOG" 2>&1 - sleep 5 - if nmcli --wait 30 con up "$HOME_CON" >>"$LAST_LOG" 2>&1; then - connected=true - break - fi - logger -t "$LOG_TAG" "home WiFi attempt $attempt failed, retrying..." -done - -if [ "$connected" = true ]; then - logger -t "$LOG_TAG" "home WiFi found, switched to '$HOME_CON'" -else - logger -t "$LOG_TAG" "home WiFi not found after retries, restoring hotspot" - nmcli con up "$AP_CON" >>"$LAST_LOG" 2>&1 -fi diff --git a/deploy/yaris-netwatch.timer b/deploy/yaris-netwatch.timer index 3b155c9..7264579 100644 --- a/deploy/yaris-netwatch.timer +++ b/deploy/yaris-netwatch.timer @@ -1,5 +1,5 @@ [Unit] -Description=Periodically check if home WiFi is back in range while in hotspot mode +Description=Periodically select the preferred Yaris internet uplink [Timer] OnBootSec=1min diff --git a/deploy/yaris-uplink-wait.service b/deploy/yaris-uplink-wait.service new file mode 100644 index 0000000..5e3593f --- /dev/null +++ b/deploy/yaris-uplink-wait.service @@ -0,0 +1,12 @@ +[Unit] +Description=Wait for the configured phone hotspot +After=NetworkManager.service +Wants=NetworkManager.service +Conflicts=yaris-netwatch.service + +[Service] +Type=oneshot +TimeoutStartSec=180 +ExecStartPre=-/usr/bin/systemctl stop yaris-netwatch.timer +ExecStart=/usr/local/bin/yaris-uplink wait +ExecStopPost=/usr/bin/systemctl start yaris-netwatch.timer diff --git a/deploy/yaris-uplink.sudoers b/deploy/yaris-uplink.sudoers new file mode 100644 index 0000000..14954d8 --- /dev/null +++ b/deploy/yaris-uplink.sudoers @@ -0,0 +1,3 @@ +yaris ALL=(root) NOPASSWD: /usr/local/bin/yaris-uplink configure +yaris ALL=(root) NOPASSWD: /usr/local/bin/yaris-uplink remove +yaris ALL=(root) NOPASSWD: /usr/bin/systemctl start --no-block yaris-uplink-wait.service diff --git a/deploy/yaris_uplink.py b/deploy/yaris_uplink.py new file mode 100644 index 0000000..345b13d --- /dev/null +++ b/deploy/yaris_uplink.py @@ -0,0 +1,451 @@ +#!/usr/bin/env python3 +"""Manage the Pi's phone-hotspot, home-WiFi, and local-AP priority.""" +import json +import os +import subprocess +import sys +import tempfile +import time +import uuid + + +PHONE_CONNECTION = "phone-hotspot" +HOME_CONNECTION = "home-wifi" +AP_CONNECTION = "Hotspot" +INTERFACE = "wlan0" +STATUS_PATH = "/var/lib/yaris-obd/uplink-status.json" +PHONE_KEYFILE = ( + "/etc/NetworkManager/system-connections/" + "phone-hotspot.nmconnection" +) + + +class UplinkError(RuntimeError): + pass + + +def run_nmcli(*args, timeout=45, check=True): + process = subprocess.run( + ["nmcli", *args], + capture_output=True, + text=True, + timeout=timeout, + ) + if check and process.returncode != 0: + detail = (process.stderr or process.stdout).strip() + raise UplinkError(detail or f"nmcli exited {process.returncode}") + return process + + +def connection_exists(name): + return run_nmcli( + "-g", + "connection.id", + "connection", + "show", + name, + check=False, + ).returncode == 0 + + +def active_connection(): + process = run_nmcli( + "-g", + "GENERAL.CONNECTION", + "device", + "show", + INTERFACE, + check=False, + ) + return process.stdout.strip() if process.returncode == 0 else "" + + +def ipv4_address(): + process = run_nmcli( + "-g", + "IP4.ADDRESS", + "device", + "show", + INTERFACE, + check=False, + ) + return ( + process.stdout.strip().split("/", 1)[0] + if process.returncode == 0 + else "" + ) + + +def internet_reachable(): + process = run_nmcli( + "-g", + "GENERAL.IP4-CONNECTIVITY", + "device", + "show", + INTERFACE, + check=False, + ) + return process.returncode == 0 and process.stdout.strip() == "4 (full)" + + +def phone_ssid(): + process = run_nmcli( + "-g", + "802-11-wireless.ssid", + "connection", + "show", + PHONE_CONNECTION, + check=False, + ) + return process.stdout.strip() if process.returncode == 0 else "" + + +def phone_is_visible(): + ssid = phone_ssid() + if not ssid: + return False + process = run_nmcli( + "-t", + "--escape", + "no", + "-f", + "SSID", + "device", + "wifi", + "list", + "ifname", + INTERFACE, + check=False, + ) + return process.returncode == 0 and ssid in process.stdout.splitlines() + + +def activate(name, wait=30): + if not connection_exists(name): + return False + process = run_nmcli( + "--wait", + str(wait), + "connection", + "up", + name, + timeout=wait + 5, + check=False, + ) + return process.returncode == 0 + + +def write_status(action, error=None): + previous = {} + try: + with open(STATUS_PATH, encoding="utf-8") as handle: + previous = json.load(handle) + except (FileNotFoundError, OSError, json.JSONDecodeError): + pass + current = active_connection() + last_phone_ip = previous.get("last_phone_ip") + if current == PHONE_CONNECTION: + last_phone_ip = ipv4_address() or last_phone_ip + status = { + "action": action, + "active_connection": current, + "error": error, + "last_phone_ip": last_phone_ip, + "updated_at": time.time(), + } + os.makedirs(os.path.dirname(STATUS_PATH), mode=0o755, exist_ok=True) + temporary_path = None + try: + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + dir=os.path.dirname(STATUS_PATH), + prefix=".uplink-status-", + delete=False, + ) as handle: + temporary_path = handle.name + os.fchmod(handle.fileno(), 0o644) + json.dump(status, handle) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary_path, STATUS_PATH) + except OSError: + if temporary_path is not None: + try: + os.remove(temporary_path) + except FileNotFoundError: + pass + raise + + +def restore_fallback(): + if activate(HOME_CONNECTION): + write_status("connected_home") + return True + if activate(AP_CONNECTION): + write_status("restored_local_hotspot") + return True + write_status("recovery_failed", "Could not activate home WiFi or local hotspot.") + return False + + +def check_uplink(): + current = active_connection() + phone_configured = connection_exists(PHONE_CONNECTION) + if current == PHONE_CONNECTION: + if internet_reachable(): + write_status("phone_connected") + return True + if activate(HOME_CONNECTION): + write_status("phone_without_internet_connected_home") + return True + if active_connection() == PHONE_CONNECTION or activate(PHONE_CONNECTION): + write_status( + "phone_connected_without_internet", + "Phone hotspot is connected but internet is not reachable.", + ) + return True + return restore_fallback() + + if phone_configured: + if current == AP_CONNECTION: + run_nmcli("connection", "down", AP_CONNECTION, check=False) + run_nmcli("device", "wifi", "rescan", "ifname", INTERFACE, check=False) + time.sleep(5) + if not phone_is_visible(): + if current != AP_CONNECTION and active_connection(): + write_status("phone_not_in_range_current_connection_preserved") + return True + return restore_fallback() + if activate(PHONE_CONNECTION): + time.sleep(3) + if internet_reachable(): + write_status("connected_phone") + return True + if activate(HOME_CONNECTION): + write_status("phone_without_internet_connected_home") + return True + if active_connection() == PHONE_CONNECTION: + write_status( + "phone_connected_without_internet", + "Phone hotspot is connected but internet is not reachable.", + ) + return True + return restore_fallback() + if active_connection() == HOME_CONNECTION: + write_status("phone_unavailable_home_preserved") + return True + return restore_fallback() + + if current != AP_CONNECTION: + write_status("phone_not_configured") + return True + + run_nmcli("connection", "down", AP_CONNECTION, check=False) + run_nmcli("device", "wifi", "rescan", "ifname", INTERFACE, check=False) + time.sleep(5) + return restore_fallback() + + +def wait_for_phone(timeout=120): + if not connection_exists(PHONE_CONNECTION): + raise UplinkError("No phone hotspot is configured.") + if active_connection() == PHONE_CONNECTION: + write_status("phone_connected") + return True + + original_connection = active_connection() + write_status("waiting_for_phone") + time.sleep(10) + if original_connection == AP_CONNECTION: + run_nmcli("connection", "down", AP_CONNECTION, check=False) + + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + run_nmcli("device", "wifi", "rescan", "ifname", INTERFACE, check=False) + time.sleep(5) + if phone_is_visible(): + return check_uplink() + + if original_connection != AP_CONNECTION and active_connection(): + write_status( + "phone_wait_timed_out_current_connection_preserved", + "Phone hotspot did not appear within two minutes.", + ) + return True + restored = restore_fallback() + if restored: + write_status( + "phone_wait_timed_out_fallback_restored", + "Phone hotspot did not appear within two minutes.", + ) + return restored + + +def validate_credentials(payload): + if not isinstance(payload, dict): + raise UplinkError("Expected a JSON object.") + ssid = payload.get("ssid") + password = payload.get("password") + if not isinstance(ssid, str) or not ssid or len(ssid.encode("utf-8")) > 32: + raise UplinkError("SSID must be between 1 and 32 bytes.") + if "\x00" in ssid or "\n" in ssid or "\r" in ssid: + raise UplinkError("SSID contains unsupported characters.") + if ";" in ssid: + raise UplinkError("SSID cannot contain a semicolon.") + if not isinstance(password, str) or not 8 <= len(password.encode("utf-8")) <= 63: + raise UplinkError("Password must be between 8 and 63 bytes.") + if "\x00" in password or "\n" in password or "\r" in password: + raise UplinkError("Password contains unsupported characters.") + return ssid, password + + +def keyfile_escape(value): + leading_spaces = len(value) - len(value.lstrip(" ")) + trailing_spaces = len(value) - len(value.rstrip(" ")) + end = len(value) - trailing_spaces if trailing_spaces else len(value) + middle = value[leading_spaces:end].replace("\\", "\\\\") + return "\\s" * leading_spaces + middle + "\\s" * trailing_spaces + + +def configure(): + raw = sys.stdin.buffer.read(2049) + if len(raw) > 2048: + raise UplinkError("Configuration request is too large.") + try: + payload = json.loads(raw) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise UplinkError("Invalid configuration JSON.") from error + ssid, password = validate_credentials(payload) + + was_active = active_connection() == PHONE_CONNECTION + run_nmcli("connection", "delete", PHONE_CONNECTION, check=False) + keyfile = f"""[connection] +id={PHONE_CONNECTION} +uuid={uuid.uuid4()} +type=wifi +interface-name={INTERFACE} +autoconnect=true +autoconnect-priority=20 + +[wifi] +mode=infrastructure +ssid={keyfile_escape(ssid)} +mtu=1400 + +[wifi-security] +key-mgmt=wpa-psk +psk={keyfile_escape(password)} + +[ipv4] +method=auto +dhcp-send-hostname=true +dhcp-hostname=yaris + +[ipv6] +method=auto +""" + temporary_path = None + try: + os.makedirs(os.path.dirname(PHONE_KEYFILE), mode=0o700, exist_ok=True) + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + dir=os.path.dirname(PHONE_KEYFILE), + prefix=".phone-hotspot-", + delete=False, + ) as handle: + temporary_path = handle.name + os.fchmod(handle.fileno(), 0o600) + handle.write(keyfile) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary_path, PHONE_KEYFILE) + temporary_path = None + run_nmcli("connection", "reload") + if not connection_exists(PHONE_CONNECTION): + raise UplinkError("NetworkManager did not load the phone hotspot.") + except (OSError, UplinkError, subprocess.TimeoutExpired): + if temporary_path is not None: + try: + os.remove(temporary_path) + except FileNotFoundError: + pass + try: + os.remove(PHONE_KEYFILE) + except FileNotFoundError: + pass + run_nmcli("connection", "reload", check=False) + run_nmcli("connection", "delete", PHONE_CONNECTION, check=False) + if was_active: + try: + restore_fallback() + except (OSError, subprocess.TimeoutExpired, UplinkError): + pass + raise + write_status("phone_configured") + if was_active: + check_uplink() + + +def remove(): + was_active = active_connection() == PHONE_CONNECTION + run_nmcli("connection", "delete", PHONE_CONNECTION, check=False) + try: + os.remove(PHONE_KEYFILE) + except FileNotFoundError: + pass + if was_active: + restore_fallback() + else: + write_status("phone_removed") + + +def record_current_connection(): + current = active_connection() + actions = { + PHONE_CONNECTION: "network_phone_connected", + HOME_CONNECTION: "network_home_connected", + AP_CONNECTION: "network_local_hotspot_active", + } + write_status(actions.get(current, "network_disconnected")) + + +def main(): + if len(sys.argv) != 2 or sys.argv[1] not in { + "check", + "configure", + "connect", + "record", + "wait", + "remove", + }: + print( + "Usage: yaris-uplink {check|configure|connect|record|wait|remove}", + file=sys.stderr, + ) + return 2 + action = sys.argv[1] + try: + if action == "configure": + configure() + elif action == "remove": + remove() + elif action == "wait": + wait_for_phone() + elif action == "record": + record_current_connection() + else: + check_uplink() + except (OSError, subprocess.TimeoutExpired, UplinkError) as error: + try: + write_status(f"{action}_failed", str(error)) + except OSError: + pass + print(str(error), file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/modules/app_network.py b/modules/app_network.py new file mode 100644 index 0000000..e33f615 --- /dev/null +++ b/modules/app_network.py @@ -0,0 +1,144 @@ +"""Phone-hotspot configuration and read-only uplink status.""" +import json +import os +import subprocess +import sys + + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +INSTALLED_HELPER = "/usr/local/bin/yaris-uplink" +REPO_HELPER = os.path.join(BASE_DIR, "deploy", "yaris_uplink.py") +PHONE_CONNECTION = "phone-hotspot" +INTERFACE = "wlan0" +STATUS_PATH = "/var/lib/yaris-obd/uplink-status.json" + + +class NetworkConfigurationError(RuntimeError): + pass + + +def _helper_command(action): + if os.path.isfile(INSTALLED_HELPER): + return ["sudo", "-n", INSTALLED_HELPER, action] + return ["sudo", "-n", sys.executable, REPO_HELPER, action] + + +def _run_helper(action, payload=None): + process = subprocess.run( + _helper_command(action), + input=json.dumps(payload) if payload is not None else None, + capture_output=True, + text=True, + timeout=120, + ) + if process.returncode != 0: + detail = (process.stderr or process.stdout).strip() + raise NetworkConfigurationError( + detail or f"Network helper exited {process.returncode}." + ) + + +def configure_phone_hotspot(ssid, password): + _run_helper("configure", {"ssid": ssid, "password": password}) + + +def request_phone_connection(): + process = subprocess.run( + [ + "sudo", + "-n", + "systemctl", + "start", + "--no-block", + "yaris-uplink-wait.service", + ], + capture_output=True, + text=True, + timeout=5, + ) + if process.returncode != 0: + detail = (process.stderr or process.stdout).strip() + raise NetworkConfigurationError( + detail or "Could not request a phone-hotspot connection." + ) + + +def remove_phone_hotspot(): + _run_helper("remove") + + +def _nmcli(*args): + process = subprocess.run( + ["nmcli", *args], + capture_output=True, + text=True, + timeout=5, + ) + if process.returncode != 0: + raise NetworkConfigurationError( + (process.stderr or process.stdout).strip() or "nmcli failed." + ) + return process.stdout.strip() + + +def _phone_ssid(): + process = subprocess.run( + [ + "nmcli", + "-g", + "802-11-wireless.ssid", + "connection", + "show", + PHONE_CONNECTION, + ], + capture_output=True, + text=True, + timeout=5, + ) + return process.stdout.strip() if process.returncode == 0 else "" + + +def get_status(): + values = _nmcli( + "-g", + "GENERAL.CONNECTION,GENERAL.STATE,GENERAL.IP4-CONNECTIVITY,IP4.ADDRESS", + "device", + "show", + INTERFACE, + ).splitlines() + values += [""] * (4 - len(values)) + last_transition = {} + try: + with open(STATUS_PATH, encoding="utf-8") as handle: + last_transition = json.load(handle) + except FileNotFoundError: + pass + except (OSError, json.JSONDecodeError) as error: + last_transition = {"error": f"Could not read uplink status: {error}"} + + active = values[0] + connectivity = values[2] + phone_ssid = _phone_ssid() + internet_reachable = connectivity == "4 (full)" + return { + "active_connection": active, + "mode": ( + "phone" + if active == PHONE_CONNECTION + else "local_hotspot" + if active == "Hotspot" + else "wifi" + if active + else "disconnected" + ), + "state": values[1], + "internet_reachable": internet_reachable, + "connectivity": connectivity, + "ip_address": values[3].split("/", 1)[0], + "phone_configured": bool(phone_ssid), + "phone_ssid": phone_ssid, + "large_upload_allowed": ( + internet_reachable and active != "" and active != "Hotspot" + ), + "last_transition": last_transition, + } diff --git a/readme.md b/readme.md index 65e64a8..dd3d8b5 100644 --- a/readme.md +++ b/readme.md @@ -132,6 +132,9 @@ the Pi out remain SSH/file-managed. When cellular is configured, small ntfy noti SIM868 first and WiFi/Ethernet only as fallback; that modem path is implemented and mock-tested but still needs real-SIM/carrier verification. -A planned pre-redesign phase will also let the Pi join a configured phone hotspot for outbound -internet when no SIM is available, including queued trip/alert synchronization and WiFi dashcam -uploads, with automatic recovery to the Pi-hosted local hotspot when the phone is unavailable. +The Pi can also join a configured phone hotspot for outbound internet when no SIM is available, +including trip synchronization and WiFi dashcam uploads, with automatic recovery to home WiFi or +the Pi-hosted local hotspot. Phone credentials are write-only in the Web UI and stored in a +root-only NetworkManager profile. Real Pixel testing proved cold boot, reconnect/fallback, server +tracking, and chunked video upload; a 1400-byte phone-profile MTU was required for reliable +tethered TLS uploads. diff --git a/static/js/settings.js b/static/js/settings.js index 1551b8d..7c59b08 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -1,6 +1,16 @@ const form = document.getElementById('settings-form'); const saveButtons = Array.from(document.querySelectorAll('.settings-save')); const message = document.getElementById('settings-message'); +const uplinkStatus = document.getElementById('uplink-status'); +const uplinkMessage = document.getElementById('uplink-message'); +const phoneSsid = document.getElementById('phone-hotspot-ssid'); +const phonePassword = document.getElementById('phone-hotspot-password'); +const phoneSave = document.getElementById('phone-hotspot-save'); +const phoneConnect = document.getElementById('phone-hotspot-connect'); +const phoneRemove = document.getElementById('phone-hotspot-remove'); +const lastPhoneAddress = document.getElementById('last-phone-address'); +const lastPhoneAddressLink = document.getElementById('last-phone-address-link'); +let initialUplinkStatusLoaded = false; const fields = { dashcamUpload: document.getElementById('dashcam-upload-enabled'), @@ -26,6 +36,12 @@ function showMessage(text, kind) { message.hidden = false; } +function showUplinkMessage(text, kind) { + uplinkMessage.textContent = text; + uplinkMessage.className = `alert alert-${kind} mt-3 mb-0`; + uplinkMessage.hidden = false; +} + function setSaving(disabled) { saveButtons.forEach(button => { button.disabled = disabled; @@ -131,6 +147,116 @@ async function loadUploadHealth() { } } +function describeUplink(status) { + const modeNames = { + phone: 'Phone hotspot', + local_hotspot: 'Pi local hotspot', + wifi: status.active_connection || 'WiFi', + disconnected: 'Disconnected', + }; + const parts = [ + `Mode: ${modeNames[status.mode] || status.mode}.`, + status.ip_address ? `IP: ${status.ip_address}.` : '', + status.internet_reachable ? 'Internet reachable.' : 'Internet not verified.', + ]; + if (status.last_transition && status.last_transition.error) { + parts.push(`Last error: ${status.last_transition.error}`); + } + return parts.filter(Boolean).join(' '); +} + +async function loadUplinkStatus() { + try { + const response = await fetch('/api/uplink/status'); + const status = await response.json(); + if (!response.ok) throw new Error(status.error || `HTTP ${response.status}`); + uplinkStatus.textContent = describeUplink(status); + const phoneAddress = status.mode === 'phone' + ? status.ip_address + : status.last_transition && status.last_transition.last_phone_ip; + lastPhoneAddress.hidden = !phoneAddress; + if (phoneAddress) { + const url = `http://${phoneAddress}/`; + lastPhoneAddressLink.href = url; + lastPhoneAddressLink.textContent = url; + } + if ( + !initialUplinkStatusLoaded + && status.phone_configured + && !phoneSsid.value + ) { + phoneSsid.value = status.phone_ssid; + } + initialUplinkStatusLoaded = true; + phoneConnect.disabled = !status.phone_configured; + phoneRemove.disabled = !status.phone_configured; + } catch (error) { + uplinkStatus.textContent = `Could not load network status: ${error.message}`; + uplinkStatus.className = 'text-danger mb-3'; + } +} + +phoneSave.addEventListener('click', async () => { + uplinkMessage.hidden = true; + phoneSave.disabled = true; + try { + const response = await fetch('/api/uplink/config', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + ssid: phoneSsid.value, + password: phonePassword.value, + }), + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || `HTTP ${response.status}`); + phonePassword.value = ''; + showUplinkMessage( + 'Phone hotspot saved. Select Wait for hotspot, then enable the hotspot on your phone within two minutes.', + 'success', + ); + await loadUplinkStatus(); + } catch (error) { + showUplinkMessage(`Could not save phone hotspot: ${error.message}`, 'danger'); + } finally { + phoneSave.disabled = false; + } +}); + +phoneConnect.addEventListener('click', async () => { + phoneConnect.disabled = true; + try { + const response = await fetch('/api/uplink/connect', {method: 'POST'}); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || `HTTP ${response.status}`); + showUplinkMessage( + 'Waiting for the saved hotspot. Enable it on your phone within two minutes. This page may disconnect while networks switch.', + 'info', + ); + } catch (error) { + showUplinkMessage(`Could not request connection: ${error.message}`, 'danger'); + } finally { + phoneConnect.disabled = false; + } +}); + +phoneRemove.addEventListener('click', async () => { + if (!window.confirm('Remove the saved phone hotspot from the Pi?')) return; + phoneRemove.disabled = true; + try { + const response = await fetch('/api/uplink/config', {method: 'DELETE'}); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || `HTTP ${response.status}`); + phoneSsid.value = ''; + phonePassword.value = ''; + showUplinkMessage('Saved phone hotspot removed.', 'success'); + await loadUplinkStatus(); + } catch (error) { + showUplinkMessage(`Could not remove phone hotspot: ${error.message}`, 'danger'); + phoneRemove.disabled = false; + } +}); + async function loadSettings() { setSaving(true); try { @@ -170,3 +296,5 @@ form.addEventListener('submit', async event => { loadSettings(); loadUploadHealth(); +loadUplinkStatus(); +setInterval(loadUplinkStatus, 15000); diff --git a/templates/settings.html b/templates/settings.html index 5d2a521..8c4ef5a 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -37,6 +37,44 @@ +
+
+

Phone hotspot internet

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + +
+

+ Save while the phone is still connected to the Pi/home WiFi. Then select + Wait for hotspot and enable the phone hotspot within two minutes. + The password is write-only and is never returned here. This page may briefly close + while networks switch. Some phones do not resolve yaris.local for + tethered clients; use the last direct address shown above when needed. If the phone + hotspot never appears, the Pi restores home WiFi or its local hotspot. +

+
+
+

Cellular trip-log fallback

diff --git a/tests/test_app_network.py b/tests/test_app_network.py new file mode 100644 index 0000000..6b02be7 --- /dev/null +++ b/tests/test_app_network.py @@ -0,0 +1,74 @@ +import json +import unittest +from unittest.mock import MagicMock, mock_open, patch + +from modules import app_network + + +class AppNetworkTest(unittest.TestCase): + @patch("modules.app_network.subprocess.run") + @patch( + "modules.app_network._helper_command", + return_value=["sudo", "helper", "configure"], + ) + def test_configuration_secret_is_sent_only_over_stdin( + self, + _helper_command, + run, + ): + run.return_value = MagicMock(returncode=0, stdout="", stderr="") + + app_network.configure_phone_hotspot("My Phone", "private-password") + + command = run.call_args.args[0] + self.assertNotIn("private-password", command) + self.assertEqual( + json.loads(run.call_args.kwargs["input"]), + {"ssid": "My Phone", "password": "private-password"}, + ) + + @patch("modules.app_network.subprocess.run") + def test_connection_request_is_nonblocking(self, run): + run.return_value = MagicMock(returncode=0, stdout="", stderr="") + + app_network.request_phone_connection() + + self.assertIn("--no-block", run.call_args.args[0]) + self.assertIn("yaris-uplink-wait.service", run.call_args.args[0]) + self.assertEqual(run.call_args.kwargs["timeout"], 5) + + @patch("modules.app_network._phone_ssid", return_value="My Phone") + @patch( + "modules.app_network._nmcli", + return_value="phone-hotspot\n100 (connected)\n4 (full)\n172.20.10.2/28", + ) + def test_status_reports_phone_uplink_without_secret(self, _nmcli, _ssid): + with patch("builtins.open", mock_open(read_data='{"action":"connected_phone"}')): + status = app_network.get_status() + + self.assertEqual(status["mode"], "phone") + self.assertEqual(status["phone_ssid"], "My Phone") + self.assertTrue(status["internet_reachable"]) + self.assertTrue(status["large_upload_allowed"]) + self.assertNotIn("password", status) + + @patch("modules.app_network._phone_ssid", return_value="") + @patch( + "modules.app_network._nmcli", + return_value="Hotspot\n100 (connected)\n2 (portal)\n10.42.0.1/24", + ) + def test_local_hotspot_does_not_claim_internet_or_large_uploads( + self, + _nmcli, + _ssid, + ): + with patch("builtins.open", side_effect=FileNotFoundError): + status = app_network.get_status() + + self.assertEqual(status["mode"], "local_hotspot") + self.assertFalse(status["internet_reachable"]) + self.assertFalse(status["large_upload_allowed"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_yaris_uplink.py b/tests/test_yaris_uplink.py new file mode 100644 index 0000000..d68d5bf --- /dev/null +++ b/tests/test_yaris_uplink.py @@ -0,0 +1,245 @@ +import os +import stat +import tempfile +import unittest +from unittest.mock import MagicMock, call, patch + +from deploy import yaris_uplink + + +class UplinkCredentialTest(unittest.TestCase): + def test_rejects_invalid_credentials(self): + with self.assertRaises(yaris_uplink.UplinkError): + yaris_uplink.validate_credentials( + {"ssid": "", "password": "long-enough"} + ) + with self.assertRaises(yaris_uplink.UplinkError): + yaris_uplink.validate_credentials( + {"ssid": "Phone", "password": "short"} + ) + + def test_keyfile_preserves_edge_spaces_and_backslashes(self): + self.assertEqual( + yaris_uplink.keyfile_escape(r" two\spaces "), + r"\stwo\\spaces\s", + ) + + @patch("deploy.yaris_uplink.write_status") + @patch( + "deploy.yaris_uplink.active_connection", + return_value="home-wifi", + ) + @patch("deploy.yaris_uplink.connection_exists", return_value=True) + @patch("deploy.yaris_uplink.run_nmcli") + def test_configure_writes_root_only_keyfile_without_secret_in_nmcli( + self, + run_nmcli, + _connection_exists, + _active_connection, + _write_status, + ): + with tempfile.TemporaryDirectory() as directory: + keyfile_path = os.path.join(directory, "phone.nmconnection") + payload = b'{"ssid":"My Phone","password":"private-password"}' + with ( + patch.object(yaris_uplink, "PHONE_KEYFILE", keyfile_path), + patch.object( + yaris_uplink.sys, + "stdin", + MagicMock(buffer=MagicMock(read=MagicMock(return_value=payload))), + ), + ): + yaris_uplink.configure() + + with open(keyfile_path, encoding="utf-8") as handle: + keyfile = handle.read() + mode = stat.S_IMODE(os.stat(keyfile_path).st_mode) + + self.assertEqual(mode, 0o600) + self.assertIn("ssid=My Phone", keyfile) + self.assertIn("psk=private-password", keyfile) + for call in run_nmcli.call_args_list: + self.assertNotIn("private-password", call.args) + + +class UplinkSelectionTest(unittest.TestCase): + @patch("deploy.yaris_uplink.time.sleep") + @patch("deploy.yaris_uplink.write_status") + @patch("deploy.yaris_uplink.run_nmcli") + @patch("deploy.yaris_uplink.internet_reachable", return_value=True) + @patch("deploy.yaris_uplink.phone_is_visible", return_value=True) + @patch("deploy.yaris_uplink.activate", return_value=True) + @patch("deploy.yaris_uplink.connection_exists", return_value=True) + @patch("deploy.yaris_uplink.active_connection", return_value="Hotspot") + def test_phone_is_preferred_over_home_from_local_hotspot( + self, + _active, + _exists, + activate, + _visible, + _internet, + _run_nmcli, + write_status, + _sleep, + ): + self.assertTrue(yaris_uplink.check_uplink()) + + activate.assert_called_once_with("phone-hotspot") + write_status.assert_called_once_with("connected_phone") + + @patch("deploy.yaris_uplink.time.sleep") + @patch("deploy.yaris_uplink.write_status") + @patch("deploy.yaris_uplink.run_nmcli") + @patch("deploy.yaris_uplink.phone_is_visible", return_value=False) + @patch("deploy.yaris_uplink.activate") + @patch("deploy.yaris_uplink.connection_exists", return_value=True) + @patch("deploy.yaris_uplink.active_connection", return_value="home-wifi") + def test_missing_phone_does_not_disrupt_home_wifi( + self, + _active, + _exists, + activate, + _visible, + _run_nmcli, + write_status, + _sleep, + ): + self.assertTrue(yaris_uplink.check_uplink()) + + activate.assert_not_called() + write_status.assert_called_once_with( + "phone_not_in_range_current_connection_preserved" + ) + + @patch("deploy.yaris_uplink.write_status") + @patch("deploy.yaris_uplink.activate", return_value=True) + @patch("deploy.yaris_uplink.internet_reachable", return_value=False) + @patch("deploy.yaris_uplink.connection_exists", return_value=True) + @patch( + "deploy.yaris_uplink.active_connection", + return_value="phone-hotspot", + ) + def test_home_is_fallback_when_phone_has_no_internet( + self, + _active, + _exists, + _internet, + activate, + write_status, + ): + self.assertTrue(yaris_uplink.check_uplink()) + + activate.assert_called_once_with("home-wifi") + write_status.assert_called_once_with( + "phone_without_internet_connected_home" + ) + + @patch("deploy.yaris_uplink.write_status") + @patch( + "deploy.yaris_uplink.activate", + side_effect=[False, True], + ) + def test_local_hotspot_is_restored_when_home_is_unavailable( + self, + activate, + write_status, + ): + self.assertTrue(yaris_uplink.restore_fallback()) + + self.assertEqual( + activate.call_args_list, + [ + call("home-wifi"), + call("Hotspot"), + ], + ) + write_status.assert_called_once_with("restored_local_hotspot") + + +class UplinkWaitTest(unittest.TestCase): + @patch("deploy.yaris_uplink.check_uplink", return_value=True) + @patch("deploy.yaris_uplink.phone_is_visible", return_value=True) + @patch("deploy.yaris_uplink.run_nmcli") + @patch("deploy.yaris_uplink.time.sleep") + @patch("deploy.yaris_uplink.write_status") + @patch("deploy.yaris_uplink.active_connection", return_value="home-wifi") + @patch("deploy.yaris_uplink.connection_exists", return_value=True) + def test_wait_connects_after_phone_appears( + self, + _exists, + _active, + write_status, + _sleep, + _run_nmcli, + _visible, + check_uplink, + ): + self.assertTrue(yaris_uplink.wait_for_phone()) + + write_status.assert_called_once_with("waiting_for_phone") + check_uplink.assert_called_once_with() + + @patch( + "deploy.yaris_uplink.time.monotonic", + side_effect=[100, 221], + ) + @patch("deploy.yaris_uplink.run_nmcli") + @patch("deploy.yaris_uplink.time.sleep") + @patch("deploy.yaris_uplink.write_status") + @patch("deploy.yaris_uplink.active_connection", return_value="home-wifi") + @patch("deploy.yaris_uplink.connection_exists", return_value=True) + def test_wait_timeout_preserves_current_client_connection( + self, + _exists, + _active, + write_status, + _sleep, + _run_nmcli, + _monotonic, + ): + self.assertTrue(yaris_uplink.wait_for_phone()) + + write_status.assert_any_call("waiting_for_phone") + write_status.assert_called_with( + "phone_wait_timed_out_current_connection_preserved", + "Phone hotspot did not appear within two minutes.", + ) + + +class UplinkTransitionTest(unittest.TestCase): + @patch("deploy.yaris_uplink.write_status") + @patch("deploy.yaris_uplink.active_connection", return_value="Hotspot") + def test_records_networkmanager_fallback(self, _active, write_status): + yaris_uplink.record_current_connection() + + write_status.assert_called_once_with("network_local_hotspot_active") + + @patch("deploy.yaris_uplink.time.time", return_value=123) + @patch("deploy.yaris_uplink.ipv4_address", return_value="10.21.0.112") + @patch( + "deploy.yaris_uplink.active_connection", + return_value="phone-hotspot", + ) + def test_status_preserves_last_phone_address( + self, + _active, + _address, + _time, + ): + with tempfile.TemporaryDirectory() as directory: + status_path = os.path.join(directory, "status.json") + with patch.object(yaris_uplink, "STATUS_PATH", status_path): + yaris_uplink.write_status("network_phone_connected") + with patch( + "deploy.yaris_uplink.active_connection", + return_value="home-wifi", + ): + yaris_uplink.write_status("network_home_connected") + with open(status_path, encoding="utf-8") as handle: + status = yaris_uplink.json.load(handle) + + self.assertEqual(status["last_phone_ip"], "10.21.0.112") + + +if __name__ == "__main__": + unittest.main()