feat: Pi hotspot SSID/password configurable from Settings page
- Settings page: SSID + password fields in In-car access card - GET /api/hotspot reads current SSID (password never returned) - POST /api/hotspot updates via nmcli (Hotspot-local profile on wlan0) - deploy/yaris-hotspot.sudoers for privileged nmcli access - Password is write-only with placeholder hint
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
from flask import Flask, render_template, make_response, json, jsonify, request, send_file, Response
|
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
|
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
|
from gevent.pywsgi import WSGIServer
|
||||||
|
|
||||||
|
|
||||||
@@ -131,6 +131,59 @@ def uplink_connect():
|
|||||||
return jsonify(error=str(error)), 503
|
return jsonify(error=str(error)), 503
|
||||||
return jsonify(status="connection_requested"), 202
|
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')
|
@app.route('/api/trips')
|
||||||
def trips():
|
def trips():
|
||||||
return jsonify({"trips": app_sql.list_trips()})
|
return jsonify({"trips": app_sql.list_trips()})
|
||||||
|
|||||||
@@ -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 *
|
||||||
@@ -45,6 +45,52 @@ const fields = {
|
|||||||
clearRemoteApiKey: document.getElementById('clear-remote-api-key'),
|
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) {
|
function showMessage(text, kind) {
|
||||||
message.textContent = text;
|
message.textContent = text;
|
||||||
message.className = `alert alert-${kind}`;
|
message.className = `alert alert-${kind}`;
|
||||||
@@ -485,4 +531,5 @@ loadDeviceConfig();
|
|||||||
loadRemoteConfig();
|
loadRemoteConfig();
|
||||||
loadUploadHealth();
|
loadUploadHealth();
|
||||||
loadUplinkStatus();
|
loadUplinkStatus();
|
||||||
|
loadApConfig();
|
||||||
setInterval(loadUplinkStatus, 15000);
|
setInterval(loadUplinkStatus, 15000);
|
||||||
|
|||||||
@@ -146,6 +146,20 @@
|
|||||||
<p class="text-primary m-0 fw-bold small">In-car access</p>
|
<p class="text-primary m-0 fw-bold small">In-car access</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body py-3">
|
<div class="card-body py-3">
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-sm-6">
|
||||||
|
<label class="form-label form-label-sm" for="ap-ssid">Pi hotspot name (SSID)</label>
|
||||||
|
<input class="form-control form-control-sm" id="ap-ssid" maxlength="32" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-6">
|
||||||
|
<label class="form-label form-label-sm" for="ap-password">Pi hotspot password</label>
|
||||||
|
<input class="form-control form-control-sm" id="ap-password" type="password"
|
||||||
|
minlength="8" maxlength="63" autocomplete="new-password" placeholder="Leave blank to keep current">
|
||||||
|
<div class="form-text">Write-only — never sent back to the browser.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-outline-primary mb-3" id="ap-save" type="button">Save hotspot</button>
|
||||||
|
<div id="ap-message" class="alert py-2 mb-3" role="status" hidden></div>
|
||||||
<div id="in-car-hotspot-info" hidden>
|
<div id="in-car-hotspot-info" hidden>
|
||||||
<p class="mb-2 fw-semibold">Your phone can reach the Pi at:</p>
|
<p class="mb-2 fw-semibold">Your phone can reach the Pi at:</p>
|
||||||
<div class="d-flex align-items-center gap-2 mb-3">
|
<div class="d-flex align-items-center gap-2 mb-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user