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
+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())