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.label}
+
${service.unit}
+ + ${statusLabel} + ${service.ok ? service.sub_state : '—'} + ${service.ok ? service.unit_file_state : '—'} + ${service.ok && service.restarts != null ? service.restarts : '—'} + ${service.ok ? service.result : (service.error || '—')} + `; + table.appendChild(row); + }); +} + +async function refreshStatus() { + const response = await fetch('/api/status'); + const status = await response.json(); + if (!response.ok) { + throw new Error(status.error || 'Could not load status page data.'); + } + + document.getElementById('status-error').hidden = true; + document.getElementById('status-updated').textContent = + `Updated ${new Date(status.generated_at * 1000).toLocaleTimeString()}`; + + renderServer(status.server); + renderUplink(status.uplink); + renderUpload(status.upload); + renderHealth(status.health); + renderGpsAndCellular(status.gps, status.cellular_hat); + renderObd(status.obd); + renderServices(status.services); +} + +function showStatusError(error) { + const banner = document.getElementById('status-error'); + banner.textContent = error.message; + banner.hidden = false; +} + +refreshStatus().catch(showStatusError); +window.setInterval(function () { + refreshStatus().catch(showStatusError); +}, 30000); diff --git a/static/js/yaris.js b/static/js/yaris.js index a1f3182..3dfc6cd 100644 --- a/static/js/yaris.js +++ b/static/js/yaris.js @@ -221,9 +221,7 @@ function formatGpsAge(seconds) { function renderGpsStatus(status) { const liveBadge = document.getElementById('gps-live-badge'); const fixDetail = document.getElementById('gps-fix-detail'); - const healthStatus = document.getElementById('pi-gps-status'); - const healthDetail = document.getElementById('pi-gps-detail'); - if (!liveBadge || !fixDetail || !healthStatus || !healthDetail) return; + if (!liveBadge || !fixDetail) return; let label = 'Unavailable'; let badgeClass = 'text-bg-secondary'; @@ -248,9 +246,6 @@ function renderGpsStatus(status) { liveBadge.className = `badge ${badgeClass}`; liveBadge.textContent = label; fixDetail.textContent = detail; - healthStatus.textContent = label; - healthStatus.className = `h6 fw-bold mb-0 ${label === 'Live fix' ? 'text-success' : label === 'Offline' ? 'text-danger' : 'text-warning'}`; - healthDetail.textContent = detail; } async function refreshGpsStatus() { @@ -295,112 +290,14 @@ socket.on('gps_location', function(data) { } }); -function formatUptime(seconds) { - 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 setHealthValue(id, value) { - document.getElementById(id).textContent = value; -} - -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; -} - -async function refreshHealth() { - const response = await fetch("/api/health"); - const health = await response.json(); - if (!response.ok) { - throw new Error(health.error || "Could not load Pi health."); - } - - setHealthValue( - "pi-temperature", - health.temperature_c == null ? "Unavailable" : `${health.temperature_c.toFixed(1)} °C` - ); - setHealthValue( - "pi-memory", - health.memory == null ? "Unavailable" : `${health.memory.used_percent.toFixed(1)}%` - ); - setHealthValue( - "pi-disk", - health.disk == null - ? "Unavailable" - : `${health.disk.used_percent.toFixed(1)}% · ${YarisUI.formatBytes(health.disk.free_bytes)} free` - ); - setHealthValue( - "pi-uptime", - health.uptime_seconds == null ? "Unavailable" : formatUptime(health.uptime_seconds) - ); - - const warning = document.getElementById("pi-health-warning"); - const currentWarnings = health.warnings.map(healthWarningLabel); - const historicalWarnings = health.historical_warnings.map( - historicalHealthWarningLabel - ); - const warnings = currentWarnings.concat( - historicalWarnings.length - ? [`Previously detected: ${historicalWarnings.join(", ")}`] - : [] - ); - warning.hidden = warnings.length === 0; - warning.textContent = warnings.join(" · "); - - const error = document.getElementById("pi-health-error"); - const unavailable = Object.keys(health.errors); - error.hidden = unavailable.length === 0; - error.textContent = unavailable.length - ? `Unavailable health sources: ${unavailable.join(", ")}` - : ""; - document.getElementById("pi-health-updated").textContent = - `Updated ${new Date().toLocaleTimeString()}`; -} - -refreshHealth().catch(function (error) { - const message = document.getElementById("pi-health-error"); - message.textContent = error.message; - message.hidden = false; -}); -window.setInterval(function () { - refreshHealth().catch(function (error) { - const message = document.getElementById("pi-health-error"); - message.textContent = error.message; - message.hidden = false; - }); -}, 30000); - refreshGpsStatus().catch(function (error) { renderGpsStatus({thread_alive: false, has_fix: false, fix_age_seconds: null}); const fixDetail = document.getElementById('gps-fix-detail'); - const healthDetail = document.getElementById('pi-gps-detail'); if (fixDetail) fixDetail.textContent = error.message; - if (healthDetail) healthDetail.textContent = error.message; }); window.setInterval(function () { refreshGpsStatus().catch(function (error) { const fixDetail = document.getElementById('gps-fix-detail'); - const healthDetail = document.getElementById('pi-gps-detail'); if (fixDetail) fixDetail.textContent = error.message; - if (healthDetail) healthDetail.textContent = error.message; }); }, 15000); diff --git a/templates/base.html b/templates/base.html index ef35c15..0226c15 100644 --- a/templates/base.html +++ b/templates/base.html @@ -41,6 +41,11 @@ DTC +