diff --git a/app.py b/app.py index 83d1264..4b9d1a7 100644 --- a/app.py +++ b/app.py @@ -1,6 +1,6 @@ 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, app_status -import os, time +import os, subprocess, time from gevent.pywsgi import WSGIServer @@ -131,6 +131,59 @@ def uplink_connect(): return jsonify(error=str(error)), 503 return jsonify(status="connection_requested"), 202 +_HOTSPOT_PROFILE = "Hotspot-local" # wlan0 AP profile name in NetworkManager + + +@app.route('/api/hotspot') +def hotspot_config(): + """Read current Pi hotspot SSID from NetworkManager (password is never returned).""" + ssid = "" + try: + result = subprocess.run( + ["sudo", "-n", "nmcli", "-t", "-f", "802-11-wireless.ssid", "con", "show", _HOTSPOT_PROFILE], + capture_output=True, text=True, timeout=5, + ) + if result.returncode == 0: + for line in result.stdout.strip().splitlines(): + if line.startswith("802-11-wireless.ssid:"): + ssid = line.split(":", 1)[1] + break + except Exception: + pass + return jsonify(ssid=ssid) + +@app.route('/api/hotspot', methods=['POST']) +def hotspot_config_update(): + """Update Pi hotspot SSID and/or password via NetworkManager.""" + body = request.get_json(silent=True) or {} + ssid = (body.get("ssid") or "").strip() + password = (body.get("password") or "") + + if not ssid: + return jsonify(error="SSID is required."), 400 + if len(ssid) > 32: + return jsonify(error="SSID must be at most 32 characters."), 400 + if password and len(password) < 8: + return jsonify(error="Password must be at least 8 characters."), 400 + if password and len(password) > 63: + return jsonify(error="Password must be at most 63 characters."), 400 + + try: + subprocess.run( + ["sudo", "-n", "nmcli", "con", "mod", _HOTSPOT_PROFILE, "802-11-wireless.ssid", ssid], + capture_output=True, text=True, timeout=10, check=True, + ) + if password: + subprocess.run( + ["sudo", "-n", "nmcli", "con", "mod", _HOTSPOT_PROFILE, "802-11-wireless-security.psk", password], + capture_output=True, text=True, timeout=10, check=True, + ) + return jsonify(message=f"Hotspot SSID updated to '{ssid}'." + + (" Password saved." if password else "") + + " Restart hotspot or device to apply."), + except subprocess.CalledProcessError as e: + return jsonify(error=f"Could not update hotspot: {e.stderr.strip() or 'nmcli failed'}"), 500 + @app.route('/api/trips') def trips(): return jsonify({"trips": app_sql.list_trips()}) diff --git a/deploy/yaris-hotspot.sudoers b/deploy/yaris-hotspot.sudoers new file mode 100644 index 0000000..1ea4d98 --- /dev/null +++ b/deploy/yaris-hotspot.sudoers @@ -0,0 +1,3 @@ +yaris ALL=(root) NOPASSWD: /usr/bin/nmcli -t -f 802-11-wireless.ssid con show Hotspot-local +yaris ALL=(root) NOPASSWD: /usr/bin/nmcli con mod Hotspot-local 802-11-wireless.ssid * +yaris ALL=(root) NOPASSWD: /usr/bin/nmcli con mod Hotspot-local 802-11-wireless-security.psk * diff --git a/static/js/settings.js b/static/js/settings.js index a756fa4..45a9658 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -45,6 +45,52 @@ const fields = { clearRemoteApiKey: document.getElementById('clear-remote-api-key'), }; +// ── Pi hotspot (separate from main settings form) ────────────────────────── +const apSsid = document.getElementById('ap-ssid'); +const apPassword = document.getElementById('ap-password'); +const apSave = document.getElementById('ap-save'); +const apMessage = document.getElementById('ap-message'); + +function showApMessage(text, kind) { + apMessage.textContent = text; + apMessage.className = `alert alert-${kind} py-2 mb-3`; + apMessage.hidden = false; +} + +async function loadApConfig() { + try { + const r = await fetch('/api/hotspot'); + if (!r.ok) throw new Error('Cannot load hotspot config'); + const d = await r.json(); + apSsid.value = d.ssid || ''; + } catch (e) { + apSsid.placeholder = '(cannot read)'; + } +} + +apSave.addEventListener('click', async function () { + apSave.disabled = true; + apMessage.hidden = true; + try { + const r = await fetch('/api/hotspot', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + ssid: apSsid.value.trim(), + password: apPassword.value, + }), + }); + const d = await r.json(); + if (!r.ok) throw new Error(d.error || 'Save failed'); + apPassword.value = ''; + showApMessage(d.message || 'Hotspot updated. Change takes effect on next restart or mode switch.', 'success'); + } catch (e) { + showApMessage(e.message, 'danger'); + } finally { + apSave.disabled = false; + } +}); + function showMessage(text, kind) { message.textContent = text; message.className = `alert alert-${kind}`; @@ -485,4 +531,5 @@ loadDeviceConfig(); loadRemoteConfig(); loadUploadHealth(); loadUplinkStatus(); +loadApConfig(); setInterval(loadUplinkStatus, 15000); diff --git a/templates/settings.html b/templates/settings.html index bd18e47..64a291f 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -146,6 +146,20 @@

In-car access

+
+
+ + +
+
+ + +
Write-only — never sent back to the browser.
+
+
+ +