diff --git a/PLAN.md b/PLAN.md index d686c5c..5189a83 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1763,6 +1763,10 @@ updates its display-facing values without a manual restart once sync lands. cards no longer stretch to the speed graph's full height, shortened the speed graph on Pi-sized screens, and reduced excess map/footer whitespace that previously forced unnecessary scrolling. + - **Next local UX follow-up 2026-08-10**: split the bulky Pi-health block out of the dashboard + into a dedicated local **Status** page and expand it into a broader runtime diagnostics view: + local `systemd` service states, server reachability/auth status, Wi-Fi uplink state, GPS fix + state, SIM868/cellular-HAT responsiveness, upload backlog, and OBD adapter presence. --- diff --git a/app.py b/app.py index f56dca3..0932b8f 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,5 @@ from flask import Flask, render_template, make_response, json, jsonify, request, send_file, Response -from modules import app_gps, app_events, app_car, app_account, app_obd, app_sql, app_dash, app_upload, app_video, app_health, app_alerts, app_settings, app_network, app_device_config, app_device_sync, app_remote_config +from modules import app_gps, app_events, app_car, app_account, app_obd, app_sql, app_dash, app_upload, app_video, app_health, app_alerts, app_settings, app_network, app_device_config, app_device_sync, app_remote_config, app_status import os, time from gevent.pywsgi import WSGIServer @@ -44,6 +44,10 @@ def history(): def settings(): return render_template('settings.html', account=account, car=car) +@app.route('/status') +def status_page(): + return render_template('status.html', account=account, car=car) + @app.route('/api/settings', methods=['GET', 'POST']) def api_settings(): if request.method == 'GET': @@ -298,6 +302,10 @@ def upload_status(): def health(): return jsonify(app_health.collect_health()) +@app.route('/api/status') +def api_status(): + return jsonify(app_status.collect_status()) + @app.route('/api/alerts/status') def alert_status(): return jsonify(app_alerts.get_status()) diff --git a/modules/app_status.py b/modules/app_status.py new file mode 100644 index 0000000..6d9cbcf --- /dev/null +++ b/modules/app_status.py @@ -0,0 +1,187 @@ +import os +import subprocess +import time +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"), +) + + +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 _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 + ], + } diff --git a/static/js/status.js b/static/js/status.js new file mode 100644 index 0000000..f4408b3 --- /dev/null +++ b/static/js/status.js @@ -0,0 +1,298 @@ +function setBadge(id, label, kind) { + const badge = document.getElementById(id); + if (!badge) return; + badge.className = `badge ${kind}`; + badge.textContent = label; +} + +function setText(id, value) { + const element = document.getElementById(id); + if (element) { + element.textContent = value; + } +} + +function formatUptime(seconds) { + if (seconds == null) return 'Unavailable'; + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + return days ? `${days}d ${hours}h` : `${hours}h ${minutes}m`; +} + +function formatGpsAge(seconds) { + if (seconds == null) return 'No fix yet'; + if (seconds < 10) return 'Just now'; + if (seconds < 60) return `${Math.round(seconds)}s`; + const mins = Math.floor(seconds / 60); + const secs = Math.round(seconds % 60); + return secs ? `${mins}m ${secs}s` : `${mins}m`; +} + +function healthWarningLabel(warning) { + return { + under_voltage: 'Power supply undervoltage', + frequency_capped: 'CPU frequency capped', + throttled: 'CPU currently throttled', + soft_temperature_limit: 'CPU temperature limit active', + high_temperature: 'CPU temperature is high', + disk_nearly_full: 'Disk is nearly full', + memory_pressure: 'Memory usage is high', + }[warning] || warning; +} + +function historicalHealthWarningLabel(warning) { + return { + under_voltage: 'Power supply undervoltage', + frequency_capped: 'CPU frequency capping', + throttled: 'CPU throttling', + soft_temperature_limit: 'CPU temperature limiting', + }[warning] || warning; +} + +function renderServer(server) { + setText('server-url', server.server_url || 'Not configured'); + setText('server-device', server.device_name || 'Not configured'); + setText( + 'server-detail', + server.reachable + ? `HTTP ${server.health_http_status} · ${server.latency_ms} ms` + : 'Unavailable' + ); + setText('server-message', server.error || (server.authenticated + ? 'Server reachable and device credentials are valid.' + : server.reachable + ? 'Server reachable.' + : 'Server is not reachable.')); + + if (!server.configured) { + setBadge('server-badge', 'Not configured', 'text-bg-secondary'); + } else if (server.authenticated) { + setBadge('server-badge', 'Online', 'text-bg-success'); + } else if (server.reachable) { + setBadge('server-badge', 'Reachable', 'text-bg-warning'); + } else { + setBadge('server-badge', 'Offline', 'text-bg-danger'); + } +} + +function renderUplink(uplink) { + if (!uplink.ok) { + setBadge('uplink-badge', 'Unavailable', 'text-bg-danger'); + setText('uplink-mode', 'Unavailable'); + setText('uplink-interface', '—'); + setText('uplink-connection', '—'); + setText('uplink-ip', '—'); + setText('uplink-message', uplink.error || 'Could not load uplink status.'); + return; + } + + const modeLabel = { + phone: 'Phone hotspot', + local_hotspot: 'Local hotspot', + wifi: 'Wi-Fi / Ethernet', + disconnected: 'Disconnected', + }[uplink.mode] || uplink.mode || 'Unknown'; + + if (uplink.internet_reachable) { + setBadge('uplink-badge', 'Internet up', 'text-bg-success'); + } else if (uplink.mode === 'local_hotspot') { + setBadge('uplink-badge', 'Hotspot only', 'text-bg-warning'); + } else { + setBadge('uplink-badge', 'Offline', 'text-bg-danger'); + } + + setText('uplink-mode', modeLabel); + setText('uplink-interface', uplink.interface || '—'); + setText('uplink-connection', uplink.active_connection || 'None'); + setText('uplink-ip', uplink.ip_address || '—'); + + const details = []; + if (uplink.phone_configured && uplink.phone_ssid) { + details.push(`Saved phone SSID: ${uplink.phone_ssid}`); + } + if (uplink.connectivity) { + details.push(`Connectivity: ${uplink.connectivity}`); + } + if (uplink.last_transition && uplink.last_transition.action) { + details.push(`Last action: ${uplink.last_transition.action}`); + } + setText('uplink-message', details.join(' · ') || 'No uplink details available.'); +} + +function renderUpload(upload) { + const configured = upload.configured ? 'Configured' : 'Not configured'; + const dashcam = upload.dashcam_upload_enabled ? 'Enabled' : 'Paused'; + + setText('upload-pending', String(upload.pending_tracking_rows)); + setText('upload-path', upload.last_network_path || '—'); + setText('upload-configured', configured); + setText('upload-dashcam', dashcam); + setText( + 'upload-message', + upload.last_error + ? `Last error: ${upload.last_error}` + : upload.last_success_at + ? `Last success: ${new Date(upload.last_success_at * 1000).toLocaleString()}` + : 'No successful upload yet in this session.' + ); + + if (upload.last_error) { + setBadge('upload-badge', 'Error', 'text-bg-danger'); + } else if (upload.pending_tracking_rows > 0) { + setBadge('upload-badge', 'Queued', 'text-bg-warning'); + } else { + setBadge('upload-badge', 'Idle', 'text-bg-success'); + } +} + +function renderHealth(health) { + setText( + 'health-temperature', + health.temperature_c == null ? 'Unavailable' : `${health.temperature_c.toFixed(1)} °C` + ); + setText( + 'health-memory', + health.memory == null ? 'Unavailable' : `${health.memory.used_percent.toFixed(1)}%` + ); + setText( + 'health-disk', + health.disk == null + ? 'Unavailable' + : `${health.disk.used_percent.toFixed(1)}% · ${YarisUI.formatBytes(health.disk.free_bytes)} free` + ); + setText('health-uptime', formatUptime(health.uptime_seconds)); + + const warning = document.getElementById('health-warning'); + const error = document.getElementById('health-error'); + const currentWarnings = (health.warnings || []).map(healthWarningLabel); + const historicalWarnings = (health.historical_warnings || []).map( + historicalHealthWarningLabel + ); + const combinedWarnings = currentWarnings.concat( + historicalWarnings.length + ? [`Previously detected: ${historicalWarnings.join(', ')}`] + : [] + ); + warning.hidden = combinedWarnings.length === 0; + warning.textContent = combinedWarnings.join(' · '); + + const unavailable = Object.keys(health.errors || {}); + error.hidden = unavailable.length === 0; + error.textContent = unavailable.length + ? `Unavailable health sources: ${unavailable.join(', ')}` + : ''; + + if (health.status === 'warning') { + setBadge('health-badge', 'Warning', 'text-bg-warning'); + } else if (health.status === 'degraded') { + setBadge('health-badge', 'Degraded', 'text-bg-danger'); + } else { + setBadge('health-badge', 'Healthy', 'text-bg-success'); + } +} + +function renderGpsAndCellular(gps, cellularHat) { + const liveFix = gps.thread_alive && gps.has_fix; + if (liveFix) { + setBadge('gps-badge', 'Live fix', 'text-bg-success'); + } else if (gps.thread_alive) { + setBadge('gps-badge', 'No fix', 'text-bg-warning'); + } else { + setBadge('gps-badge', 'Offline', 'text-bg-danger'); + } + + setText('gps-fix', liveFix ? 'Available' : gps.thread_alive ? 'Searching' : 'Offline'); + setText('gps-age', formatGpsAge(gps.fix_age_seconds)); + setText('gps-latitude', gps.latitude || '—'); + setText('gps-longitude', gps.longitude || '—'); + setText( + 'cellular-uart', + cellularHat.serial_port_present ? cellularHat.serial_port : 'Missing' + ); + setText( + 'cellular-modem', + cellularHat.module_responding ? 'Responding' : 'Not responding' + ); + + const details = []; + details.push(cellularHat.configured ? 'Cellular fallback configured' : 'Cellular fallback disabled'); + details.push(cellularHat.apn_configured ? 'APN set' : 'No APN'); + setText('cellular-message', details.join(' · ')); +} + +function renderObd(obd) { + if (obd.car_connected) { + setBadge('obd-badge', 'Connected', 'text-bg-success'); + } else if (obd.adapter_present) { + setBadge('obd-badge', 'Adapter only', 'text-bg-warning'); + } else { + setBadge('obd-badge', 'Missing', 'text-bg-danger'); + } + + setText('obd-adapter', obd.adapter_present ? 'Detected' : 'Not detected'); + setText('obd-port', obd.port || '—'); + setText('obd-status', obd.status_label || 'Unknown'); +} + +function renderServices(services) { + const table = document.getElementById('services-table'); + table.replaceChildren(); + + services.forEach((service) => { + const row = document.createElement('tr'); + const statusLabel = service.ok + ? `${service.active_state}` + : 'Unavailable'; + const statusClass = !service.ok + ? 'text-danger' + : service.active_state === 'active' + ? 'text-success' + : service.active_state === 'inactive' + ? 'text-muted' + : 'text-warning'; + row.innerHTML = ` +
| Service | +Status | +Sub-state | +Enabled | +Restarts | +Result | +
|---|---|---|---|---|---|
| Loading… | +|||||