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:
@@ -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
|
top-bar overlap by adding a dedicated bottom mobile save action; the real save then completed with
|
||||||
no horizontal overflow or console errors.
|
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
|
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
|
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
|
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
|
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.
|
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
|
### Phase 23: Finish web UI redesign — 🚧 PLANNED
|
||||||
**Roadmap decision 2026-08-09: keep the redesign after functional uplink work.** Phase 15
|
**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
|
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
|
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
|
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
|
pass here lets it cover the complete product once, while new phases still build on consistent
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from flask import Flask, render_template, make_response, json, jsonify, request, send_file
|
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
|
import os, time
|
||||||
from gevent.pywsgi import WSGIServer
|
from gevent.pywsgi import WSGIServer
|
||||||
|
|
||||||
@@ -46,6 +46,38 @@ def api_settings():
|
|||||||
return jsonify(error="Invalid settings.", fields=errors), 400
|
return jsonify(error="Invalid settings.", fields=errors), 400
|
||||||
return jsonify(app_settings.get_settings())
|
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')
|
@app.route('/api/trips')
|
||||||
def trips():
|
def trips():
|
||||||
return jsonify({"trips": app_sql.list_trips()})
|
return jsonify({"trips": app_sql.list_trips()})
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,19 +1,34 @@
|
|||||||
#!/usr/bin/env bash
|
#!/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):
|
# 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
|
# cd /home/yaris/app/yaris-obd && bash deploy/install_netwatch.sh
|
||||||
#
|
#
|
||||||
# Prerequisite: the "home-wifi" (home WiFi) and "Hotspot" (AP mode) NetworkManager
|
# Prerequisite: the "home-wifi" and "Hotspot" NetworkManager profiles must
|
||||||
# connection profiles must already exist (see PLAN.md Phase 6 for the nmcli
|
# already exist (see PLAN.md Phase 6). The optional "phone-hotspot" profile is
|
||||||
# commands used to create them) with home-wifi having a higher
|
# created through the Settings page.
|
||||||
# connection.autoconnect-priority than Hotspot.
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
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.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-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 daemon-reload
|
||||||
sudo systemctl enable --now yaris-netwatch.timer
|
sudo systemctl enable --now yaris-netwatch.timer
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=yaris-obd network watcher (hotspot -> home WiFi reconnect check)
|
Description=yaris-obd phone/home/local hotspot uplink watcher
|
||||||
After=NetworkManager.service
|
After=NetworkManager.service
|
||||||
Wants=NetworkManager.service
|
Wants=NetworkManager.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
# Retry loop (down AP, rescan, retry connect up to 2x) can take ~80s worst
|
TimeoutStartSec=120
|
||||||
# case - default 90s systemd start timeout was too tight a margin.
|
ExecStart=/usr/local/bin/yaris-uplink check
|
||||||
TimeoutStartSec=180
|
|
||||||
ExecStart=/usr/local/bin/yaris-netwatch.sh
|
|
||||||
|
|||||||
@@ -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,5 +1,5 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Periodically check if home WiFi is back in range while in hotspot mode
|
Description=Periodically select the preferred Yaris internet uplink
|
||||||
|
|
||||||
[Timer]
|
[Timer]
|
||||||
OnBootSec=1min
|
OnBootSec=1min
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
|
SIM868 first and WiFi/Ethernet only as fallback; that modem path is implemented and mock-tested but
|
||||||
still needs real-SIM/carrier verification.
|
still needs real-SIM/carrier verification.
|
||||||
|
|
||||||
A planned pre-redesign phase will also let the Pi join a configured phone hotspot for outbound
|
The Pi can also join a configured phone hotspot for outbound internet when no SIM is available,
|
||||||
internet when no SIM is available, including queued trip/alert synchronization and WiFi dashcam
|
including trip synchronization and WiFi dashcam uploads, with automatic recovery to home WiFi or
|
||||||
uploads, with automatic recovery to the Pi-hosted local hotspot when the phone is unavailable.
|
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.
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
const form = document.getElementById('settings-form');
|
const form = document.getElementById('settings-form');
|
||||||
const saveButtons = Array.from(document.querySelectorAll('.settings-save'));
|
const saveButtons = Array.from(document.querySelectorAll('.settings-save'));
|
||||||
const message = document.getElementById('settings-message');
|
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 = {
|
const fields = {
|
||||||
dashcamUpload: document.getElementById('dashcam-upload-enabled'),
|
dashcamUpload: document.getElementById('dashcam-upload-enabled'),
|
||||||
@@ -26,6 +36,12 @@ function showMessage(text, kind) {
|
|||||||
message.hidden = false;
|
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) {
|
function setSaving(disabled) {
|
||||||
saveButtons.forEach(button => {
|
saveButtons.forEach(button => {
|
||||||
button.disabled = disabled;
|
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() {
|
async function loadSettings() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
@@ -170,3 +296,5 @@ form.addEventListener('submit', async event => {
|
|||||||
|
|
||||||
loadSettings();
|
loadSettings();
|
||||||
loadUploadHealth();
|
loadUploadHealth();
|
||||||
|
loadUplinkStatus();
|
||||||
|
setInterval(loadUplinkStatus, 15000);
|
||||||
|
|||||||
@@ -37,6 +37,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 shadow mb-4">
|
||||||
<div class="card-header py-3">
|
<div class="card-header py-3">
|
||||||
<p class="text-primary m-0 fw-bold">Cellular trip-log fallback</p>
|
<p class="text-primary m-0 fw-bold">Cellular trip-log fallback</p>
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user