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 <[email protected]>
This commit is contained in:
Mathieu Moran
2026-08-09 22:48:11 -04:00
co-authored by Copilot
parent 5c707fe295
commit cdb032b888
17 changed files with 1211 additions and 88 deletions
+39 -2
View File
@@ -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
+33 -1
View File
@@ -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()})
+9
View File
@@ -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
+21 -6
View File
@@ -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
+4
View File
@@ -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
+3 -5
View File
@@ -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
-70
View File
@@ -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
+1 -1
View File
@@ -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
+12
View File
@@ -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
+3
View File
@@ -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
+451
View File
@@ -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())
+144
View File
@@ -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,
}
+6 -3
View File
@@ -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.
+128
View File
@@ -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);
+38
View File
@@ -37,6 +37,44 @@
</div>
</div>
<div class="card shadow mb-4">
<div class="card-header py-3">
<p class="text-primary m-0 fw-bold">Phone hotspot internet</p>
</div>
<div class="card-body">
<p id="uplink-status" class="mb-3">Checking network mode...</p>
<p id="last-phone-address" class="small mb-3" hidden>
Last phone-network address:
<a id="last-phone-address-link" href="#"></a>
<span class="text-muted">(DHCP may change it after reconnecting.)</span>
</p>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label" for="phone-hotspot-ssid">Phone hotspot name</label>
<input class="form-control" id="phone-hotspot-ssid" maxlength="32" autocomplete="off">
</div>
<div class="col-md-6">
<label class="form-label" for="phone-hotspot-password">Phone hotspot password</label>
<input class="form-control" id="phone-hotspot-password" type="password" minlength="8" maxlength="63" autocomplete="new-password">
</div>
</div>
<div id="uplink-message" class="alert mt-3 mb-0" role="status" hidden></div>
<div class="d-flex flex-wrap gap-2 mt-3">
<button class="btn btn-primary" id="phone-hotspot-save" type="button">Save hotspot</button>
<button class="btn btn-outline-primary" id="phone-hotspot-connect" type="button" disabled>Wait for hotspot</button>
<button class="btn btn-outline-danger" id="phone-hotspot-remove" type="button" disabled>Remove hotspot</button>
</div>
<p class="form-text mb-0 mt-3">
Save while the phone is still connected to the Pi/home WiFi. Then select
<strong>Wait for hotspot</strong> 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 <strong>yaris.local</strong> 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.
</p>
</div>
</div>
<div class="card shadow mb-4">
<div class="card-header py-3">
<p class="text-primary m-0 fw-bold">Cellular trip-log fallback</p>
+74
View File
@@ -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()
+245
View File
@@ -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()