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:
@@ -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())
|
||||
Reference in New Issue
Block a user