Files
yaris-obd/modules/app_status.py
2026-08-10 18:40:22 -04:00

236 lines
6.6 KiB
Python

import os
import subprocess
import time
from threading import Thread
from urllib.parse import urlparse
import requests
from modules import (
app_cellular,
app_gps,
app_health,
app_network,
app_obd,
app_remote_config,
app_upload,
)
SERVICE_UNITS = (
("Web app", "yaris-obd.service"),
("NetworkManager", "NetworkManager.service"),
("Hotspot watcher", "yaris-netwatch.timer"),
("Phone uplink wait", "yaris-uplink-wait.service"),
)
RESTART_COMMAND = [
"sudo",
"-n",
"/usr/bin/systemctl",
"restart",
"--no-block",
"yaris-obd.service",
]
def parse_systemctl_show(text):
values = {}
for line in text.splitlines():
key, separator, value = line.partition("=")
if separator:
values[key] = value
restarts = values.get("NRestarts", "")
return {
"load_state": values.get("LoadState", "unknown"),
"active_state": values.get("ActiveState", "unknown"),
"sub_state": values.get("SubState", "unknown"),
"unit_file_state": values.get("UnitFileState", "unknown"),
"result": values.get("Result", "unknown"),
"restarts": int(restarts) if restarts.isdigit() else None,
}
def _run_restart_command():
subprocess.run(
RESTART_COMMAND,
capture_output=True,
text=True,
timeout=10,
check=True,
)
def _delayed_restart(delay_seconds):
time.sleep(delay_seconds)
try:
_run_restart_command()
except (OSError, subprocess.SubprocessError) as error:
print(f"Could not restart yaris-obd.service from status page: {error}")
def request_app_restart():
try:
subprocess.run(
["sudo", "-n", "-l", *RESTART_COMMAND[2:]],
capture_output=True,
text=True,
timeout=5,
check=True,
)
except (OSError, subprocess.SubprocessError) as error:
raise RuntimeError(
"Restart permission is not installed on this Pi yet. "
"Run deploy/install_service.sh once to install the status-action sudoers file."
) from error
Thread(target=_delayed_restart, args=(1,), daemon=True).start()
return {
"status": "restart_scheduled",
"message": "App restart requested. The page may disconnect for a few seconds.",
}
def _systemd_unit_status(label, unit):
try:
result = subprocess.run(
[
"systemctl",
"show",
unit,
"--property=LoadState,ActiveState,SubState,UnitFileState,Result,NRestarts",
],
capture_output=True,
text=True,
timeout=5,
check=True,
)
except (OSError, subprocess.SubprocessError) as error:
return {
"label": label,
"unit": unit,
"ok": False,
"error": str(error),
}
status = parse_systemctl_show(result.stdout)
status.update(
{
"label": label,
"unit": unit,
"ok": True,
}
)
return status
def _server_status():
credentials = app_remote_config.server_credentials()
server_url = credentials["server_url"]
device_name = credentials["device_name"]
api_key = credentials["api_key"]
parsed = urlparse(server_url) if server_url else None
status = {
"configured": bool(server_url),
"server_url": server_url,
"host": parsed.netloc if parsed else "",
"device_name": device_name,
"api_key_configured": bool(api_key),
"reachable": False,
"authenticated": False,
"latency_ms": None,
"health_http_status": None,
"error": None,
}
if not server_url:
status["error"] = "Server URL is not configured."
return status
started = time.monotonic()
try:
health_response = requests.get(f"{server_url}/health", timeout=5)
except requests.RequestException as error:
status["error"] = f"Could not reach server: {error}"
return status
status["latency_ms"] = round((time.monotonic() - started) * 1000)
status["health_http_status"] = health_response.status_code
if health_response.ok:
status["reachable"] = True
else:
status["error"] = f"/health returned HTTP {health_response.status_code}."
return status
if not device_name or not api_key:
status["error"] = "Device credentials are not fully configured yet."
return status
try:
auth_response = requests.get(
f"{server_url}/api/device-config",
headers={
"X-Device-Name": device_name,
"X-Api-Key": api_key,
},
timeout=5,
)
except requests.RequestException as error:
status["error"] = f"Server reachable, but auth check failed: {error}"
return status
if auth_response.status_code == 200:
status["authenticated"] = True
return status
if auth_response.status_code == 401:
status["error"] = "Server reachable, but device credentials were rejected."
return status
status["error"] = (
f"Server reachable, but config auth returned HTTP {auth_response.status_code}."
)
return status
def _cellular_hat_status():
serial_port = app_gps.SERIAL_PORT
serial_port_present = os.path.exists(serial_port)
module_responding = False
if serial_port_present:
with app_gps._serial_lock:
module_responding = app_gps._module_responds()
return {
"serial_port": serial_port,
"serial_port_present": serial_port_present,
"module_responding": module_responding,
"configured": app_cellular.is_configured(),
"apn_configured": bool(app_cellular.get_apn()),
}
def _obd_status():
port = app_obd._find_obd_port()
connected = app_obd._is_car_connected()
return {
"adapter_present": bool(port),
"port": port,
"car_connected": connected,
"status_label": "Connected" if connected else "Disconnected",
}
def collect_status():
try:
uplink = {"ok": True, **app_network.get_status()}
except app_network.NetworkConfigurationError as error:
uplink = {"ok": False, "error": str(error)}
return {
"generated_at": time.time(),
"health": app_health.collect_health(),
"gps": app_gps.get_status(),
"uplink": uplink,
"server": _server_status(),
"upload": app_upload.get_status(),
"cellular_hat": _cellular_hat_status(),
"obd": _obd_status(),
"services": [
_systemd_unit_status(label, unit) for label, unit in SERVICE_UNITS
],
}