- Pi history page gets a select checkbox column + 'Delete selected' button (mirrors the server UI) - POST /api/trips/delete batch endpoint - app_sql.delete_trips_batch() + deleted_trip_markers table so a later trip rebuild can't resurrect locally-deleted trips - Deletion is local-only: raw tracking rows are kept and the remote server is never notified (it keeps its archival copy recomputed from uploaded rows)
485 lines
17 KiB
Python
485 lines
17 KiB
Python
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, subprocess, time
|
|
from gevent.pywsgi import WSGIServer
|
|
|
|
|
|
app = Flask(__name__)
|
|
app_events.socketio.init_app(app)
|
|
|
|
account = app_account.Account()
|
|
obd = app_obd.start_obd()
|
|
car = app_car.Car()
|
|
gps = app_gps.start_gps()
|
|
app_sql.start_logging()
|
|
app_upload.start_uploading()
|
|
app_alerts.start_alerting()
|
|
|
|
|
|
def _reload_runtime_device_config():
|
|
account.reload_runtime_config()
|
|
car.reload_runtime_config()
|
|
|
|
|
|
app_device_sync.register_reload_callback(_reload_runtime_device_config)
|
|
app_device_sync.start_sync()
|
|
|
|
@app.route('/')
|
|
def index():
|
|
return render_template('index.html', account=account, car=car)
|
|
|
|
@app.route('/dtc')
|
|
def dtc():
|
|
return render_template('dtc.html', account = account, car = car)
|
|
|
|
@app.route('/dash')
|
|
def dash():
|
|
return render_template('dash.html', account = account, car = car)
|
|
|
|
@app.route('/history')
|
|
def history():
|
|
return render_template('history.html', account=account, car=car)
|
|
|
|
@app.route('/settings')
|
|
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':
|
|
return jsonify(app_settings.get_settings())
|
|
errors = app_settings.update_settings(request.get_json(silent=True))
|
|
if errors:
|
|
return jsonify(error="Invalid settings.", fields=errors), 400
|
|
return jsonify(app_settings.get_settings())
|
|
|
|
@app.route('/api/device-config', methods=['GET', 'POST'])
|
|
def api_device_config():
|
|
if request.method == 'GET':
|
|
return jsonify(app_device_config.get_device_config())
|
|
errors = {}
|
|
app_device_config.update_device_config(request.get_json(silent=True), errors)
|
|
if errors:
|
|
return jsonify(error="Invalid device config.", fields=errors), 400
|
|
_reload_runtime_device_config()
|
|
return jsonify(app_device_config.get_device_config())
|
|
|
|
@app.route('/api/remote-config', methods=['GET', 'POST'])
|
|
def api_remote_config():
|
|
if request.method == 'GET':
|
|
return jsonify(app_remote_config.get_remote_config())
|
|
errors = {}
|
|
app_remote_config.update_remote_config(request.get_json(silent=True), errors)
|
|
if errors:
|
|
return jsonify(error="Invalid remote config.", fields=errors), 400
|
|
app_upload.start_uploading()
|
|
app_device_sync.start_sync()
|
|
return jsonify(app_remote_config.get_remote_config())
|
|
|
|
@app.route('/api/remote-config/test', methods=['POST'])
|
|
def api_remote_config_test():
|
|
result = app_remote_config.test_connection()
|
|
return jsonify(result), 200 if result.get("ok") else 400
|
|
|
|
@app.route('/api/remote-config/sync-now', methods=['POST'])
|
|
def api_remote_config_sync_now():
|
|
try:
|
|
result = app_device_sync.sync_now()
|
|
except Exception as error:
|
|
return jsonify(status="error", message=str(error)), 502
|
|
status_codes = {
|
|
"synced": 200,
|
|
"no_change": 200,
|
|
"not_configured": 400,
|
|
"offline": 503,
|
|
}
|
|
return jsonify(result), status_codes.get(result["status"], 500)
|
|
|
|
@app.route('/api/uplink/status')
|
|
def uplink_status():
|
|
try:
|
|
return jsonify(app_network.get_status())
|
|
except app_network.NetworkConfigurationError as error:
|
|
return jsonify(error=str(error)), 503
|
|
|
|
@app.route('/api/uplink/config', methods=['POST', 'DELETE'])
|
|
def uplink_config():
|
|
try:
|
|
if request.method == 'DELETE':
|
|
app_network.remove_phone_hotspot()
|
|
else:
|
|
body = request.get_json(silent=True)
|
|
if not isinstance(body, dict):
|
|
return jsonify(error="Expected a JSON object."), 400
|
|
app_network.configure_phone_hotspot(
|
|
body.get("ssid"),
|
|
body.get("password"),
|
|
)
|
|
return jsonify(app_network.get_status())
|
|
except app_network.NetworkConfigurationError as error:
|
|
return jsonify(error=str(error)), 400
|
|
|
|
@app.route('/api/uplink/connect', methods=['POST'])
|
|
def uplink_connect():
|
|
try:
|
|
app_network.request_phone_connection()
|
|
except app_network.NetworkConfigurationError as error:
|
|
return jsonify(error=str(error)), 503
|
|
return jsonify(status="connection_requested"), 202
|
|
|
|
_HOTSPOT_PROFILE = "Hotspot-local" # AP profile name in NetworkManager (hosted on wlan1)
|
|
|
|
|
|
@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,
|
|
)
|
|
# Restart the hotspot so changes take effect immediately.
|
|
subprocess.run(
|
|
["sudo", "-n", "nmcli", "con", "down", _HOTSPOT_PROFILE],
|
|
capture_output=True, text=True, timeout=10,
|
|
)
|
|
time.sleep(1)
|
|
subprocess.run(
|
|
["sudo", "-n", "nmcli", "con", "up", _HOTSPOT_PROFILE],
|
|
capture_output=True, text=True, timeout=15, check=True,
|
|
)
|
|
return jsonify(message=f"Hotspot updated to '{ssid}' and restarted."
|
|
+ (" New password saved." if password else ""))
|
|
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()})
|
|
|
|
@app.route('/api/gps/status')
|
|
def gps_status():
|
|
return jsonify(app_gps.get_status())
|
|
|
|
@app.route('/api/obd/status')
|
|
def obd_status():
|
|
port = app_obd._find_obd_port()
|
|
connected = app_obd._is_car_connected()
|
|
return jsonify({
|
|
"adapter_present": bool(port),
|
|
"port": port,
|
|
"car_connected": connected,
|
|
"status_label": "Connected" if connected else "Disconnected",
|
|
"speed": app_obd.get_fresh_speed(),
|
|
"rpm": app_obd.rpm,
|
|
"coolant_temp": app_obd.coolant_temp,
|
|
"fuel_level": app_obd.fuel_level,
|
|
"throttle_position": app_obd.throttle_position,
|
|
"mil_on": app_obd.get_fresh_mil(),
|
|
})
|
|
|
|
@app.route('/api/trip/<int:trip_id>')
|
|
def trip_route(trip_id):
|
|
trip = app_sql.get_trip_route(trip_id)
|
|
if trip is None:
|
|
return jsonify(error=f"Trip not found: {trip_id}"), 404
|
|
return jsonify(trip)
|
|
|
|
@app.route('/api/trip/<int:trip_id>', methods=['DELETE'])
|
|
def api_delete_trip(trip_id):
|
|
deleted = app_sql.delete_trip(trip_id)
|
|
if not deleted:
|
|
return jsonify(error=f"Trip not found: {trip_id}"), 404
|
|
return jsonify(status="deleted"), 200
|
|
|
|
@app.route('/api/trips/delete', methods=['POST'])
|
|
def api_delete_trips_batch():
|
|
"""Batch-delete computed trips on the Pi only (local archival cleanup).
|
|
|
|
The remote server is NOT told — it keeps its own archival copy of every
|
|
trip, recomputed from the uploaded tracking rows."""
|
|
body = request.get_json(silent=True) or {}
|
|
ids = body.get("ids")
|
|
if not isinstance(ids, list) or not ids:
|
|
return jsonify(error="expected {ids: [...]}"), 400
|
|
deleted = app_sql.delete_trips_batch(ids)
|
|
return jsonify(status="deleted", deleted=deleted), 200
|
|
|
|
import functools
|
|
import reverse_geocoder as rg
|
|
|
|
@functools.lru_cache(maxsize=512)
|
|
def _geocode_cached(lat4, lon4):
|
|
"""Reverse-geocode a coordinate to city/province using the offline
|
|
reverse_geocoder library. Always returns a non-empty label string."""
|
|
lat = lat4 / 10000.0
|
|
lon = lon4 / 10000.0
|
|
try:
|
|
results = rg.search([(lat, lon)], mode=1, verbose=False)
|
|
if results:
|
|
r = results[0]
|
|
label = ", ".join(p for p in [r.get("name", ""), r.get("admin1", "")] if p)
|
|
if label:
|
|
return label
|
|
except Exception:
|
|
pass
|
|
return f"{lat:.5f}, {lon:.5f}"
|
|
|
|
@app.route('/api/geocode')
|
|
def geocode_route():
|
|
try:
|
|
lat = float(request.args['lat'])
|
|
lon = float(request.args['lon'])
|
|
except (KeyError, ValueError):
|
|
return jsonify(error="lat and lon required"), 400
|
|
# Round to 3 decimals (~111m precision) for cache key
|
|
label = _geocode_cached(int(round(lat, 4) * 10000), int(round(lon, 4) * 10000))
|
|
return jsonify(label=label)
|
|
|
|
@app.route('/api/geocode/batch', methods=['POST'])
|
|
def geocode_batch_route():
|
|
body = request.get_json(silent=True) or {}
|
|
coords = body.get("coords")
|
|
if not isinstance(coords, list) or not coords:
|
|
return jsonify(error="expected {coords: [{lat, lon}, ...]}"), 400
|
|
# Deduplicate — same (lat4,lon4) would hit lru_cache anyway but avoids
|
|
# submitting redundant work.
|
|
seen = set()
|
|
result = {}
|
|
for item in coords:
|
|
try:
|
|
lat = float(item["lat"])
|
|
lon = float(item["lon"])
|
|
except (KeyError, ValueError, TypeError):
|
|
continue
|
|
lat4 = int(round(lat, 4) * 10000)
|
|
lon4 = int(round(lon, 4) * 10000)
|
|
if (lat4, lon4) not in seen:
|
|
seen.add((lat4, lon4))
|
|
result[f"{lat:.3f},{lon:.3f}"] = _geocode_cached(lat4, lon4)
|
|
return jsonify(result)
|
|
|
|
@app.route('/api/dash/devices')
|
|
def dash_devices():
|
|
devices = app_dash.list_usb_storage_partitions()
|
|
return jsonify({"devices": devices})
|
|
|
|
@app.route('/api/dash/import', methods=['POST'])
|
|
def dash_import():
|
|
body = request.get_json(silent=True)
|
|
relative_paths = body.get("paths") if isinstance(body, dict) else None
|
|
started, err = app_dash.start_import_async(relative_paths=relative_paths)
|
|
if not started:
|
|
return jsonify({"status": "busy", "message": err}), 409
|
|
return jsonify({"status": "started"})
|
|
|
|
@app.route('/api/dash/import/status')
|
|
def dash_import_status():
|
|
job = app_dash.get_import_status()
|
|
if job is None:
|
|
return jsonify({"status": "none"})
|
|
return jsonify(job)
|
|
|
|
@app.route('/api/dash/files')
|
|
def dash_files():
|
|
summary = app_dash.list_dashcam_files()
|
|
return jsonify(summary)
|
|
|
|
@app.route('/api/dash/files', methods=['DELETE'])
|
|
def dash_delete_all():
|
|
summary = app_dash.delete_dashcam_files(delete_all=True)
|
|
status_code = 200 if summary["status"] in {"ok", "partial"} else 409
|
|
return jsonify(summary), status_code
|
|
|
|
@app.route('/api/dash/file', methods=['DELETE'])
|
|
def dash_delete_file():
|
|
body = request.get_json(silent=True) or {}
|
|
summary = app_dash.delete_dashcam_files(relative_path=body.get("path"))
|
|
status_codes = {
|
|
"ok": 200,
|
|
"partial": 409,
|
|
"invalid_path": 400,
|
|
"not_found": 404,
|
|
}
|
|
return jsonify(summary), status_codes.get(summary["status"], 409)
|
|
|
|
@app.route('/api/local-videos')
|
|
def local_videos():
|
|
return jsonify(app_video.list_local_videos())
|
|
|
|
@app.route('/api/local-video/file')
|
|
def local_video_file():
|
|
relative_path = request.args.get("path", "")
|
|
try:
|
|
path = app_video.get_local_video_path(relative_path)
|
|
except ValueError as error:
|
|
return jsonify(error=str(error)), 400
|
|
except FileNotFoundError:
|
|
return jsonify(error=f"Local video not found: {relative_path}"), 404
|
|
as_attachment = request.args.get("download") == "1"
|
|
return send_file(path, conditional=True, as_attachment=as_attachment)
|
|
|
|
@app.route('/api/dash/file/download')
|
|
def dash_file_download():
|
|
relative_path = request.args.get("path", "")
|
|
try:
|
|
path = app_dash.safe_video_path(app_dash.DEFAULT_MOUNT_POINT, relative_path)
|
|
except (ValueError, FileNotFoundError) as error:
|
|
return jsonify(error=str(error)), 400
|
|
return send_file(path, as_attachment=True)
|
|
|
|
@app.route('/api/local-video/thumbnail')
|
|
def local_video_thumbnail():
|
|
relative_path = request.args.get("path", "")
|
|
try:
|
|
path = app_video.get_thumbnail_path(relative_path)
|
|
except ValueError as error:
|
|
return jsonify(error=str(error)), 400
|
|
except FileNotFoundError:
|
|
return jsonify(error=f"Local video not found: {relative_path}"), 404
|
|
except RuntimeError as error:
|
|
if str(error) == "generating":
|
|
return jsonify(status="generating"), 202
|
|
return jsonify(error=str(error)), 422
|
|
return send_file(path, conditional=True, mimetype="image/jpeg")
|
|
|
|
@app.route('/api/local-video', methods=['DELETE'])
|
|
def local_video_delete():
|
|
body = request.get_json(silent=True) or {}
|
|
summary = app_video.delete_local_video(body.get("path"))
|
|
status_codes = {
|
|
"ok": 200,
|
|
"invalid_path": 400,
|
|
"not_found": 404,
|
|
"busy": 409,
|
|
"delete_failed": 409,
|
|
}
|
|
return jsonify(summary), status_codes.get(summary["status"], 409)
|
|
|
|
@app.route('/api/local-video/convert', methods=['POST'])
|
|
def local_video_convert():
|
|
body = request.get_json(silent=True) or {}
|
|
path = body.get("path", "")
|
|
if not path:
|
|
return jsonify({"status": "invalid_path", "message": "No path provided."}), 400
|
|
queued = app_video.queue_convert(path, force=True)
|
|
if queued is False:
|
|
return jsonify({"status": "busy", "message": "Already converting."}), 409
|
|
return jsonify({"status": "started"})
|
|
|
|
@app.route('/api/upload/status')
|
|
def upload_status():
|
|
return jsonify(app_upload.get_status())
|
|
|
|
@app.route('/api/health')
|
|
def health():
|
|
return jsonify(app_health.collect_health())
|
|
|
|
@app.route('/api/status')
|
|
def api_status():
|
|
return jsonify(app_status.collect_status())
|
|
|
|
@app.route('/api/status/restart-app', methods=['POST'])
|
|
def api_status_restart_app():
|
|
try:
|
|
return jsonify(app_status.request_app_restart()), 202
|
|
except RuntimeError as error:
|
|
return jsonify(error=str(error)), 503
|
|
|
|
@app.route('/api/alerts/status')
|
|
def alert_status():
|
|
return jsonify(app_alerts.get_status())
|
|
|
|
@app.route('/api/upload/dashcam_toggle', methods=['POST'])
|
|
def upload_dashcam_toggle():
|
|
body = request.get_json(silent=True) or {}
|
|
if not isinstance(body.get("enabled"), bool):
|
|
return jsonify(error="expected JSON body {'enabled': true|false}"), 400
|
|
app_upload.set_dashcam_upload_enabled(body["enabled"])
|
|
return jsonify(app_upload.get_status())
|
|
|
|
# not sure if im gonna use this
|
|
@app.route('/api/obd_speed/')
|
|
def data():
|
|
data = app_obd.speed
|
|
response = make_response(json.dumps(data))
|
|
response.content_type = 'application/json'
|
|
return response
|
|
|
|
@app.route('/api/dtc')
|
|
def api_dtc():
|
|
fresh_mil = app_obd.get_fresh_mil()
|
|
fresh_codes = app_obd.get_fresh_dtc_codes()
|
|
if fresh_mil is None and not fresh_codes:
|
|
# OBD offline/stale (disconnected or not yet polled): fall back to the
|
|
# last-persisted DTC snapshot so codes survive a disconnect/reboot.
|
|
persisted = app_sql.get_persisted_dtc_state()
|
|
fresh_mil = persisted["mil"]
|
|
fresh_codes = persisted["codes"]
|
|
return jsonify({
|
|
"mil": fresh_mil,
|
|
"codes": fresh_codes,
|
|
})
|
|
|
|
@app.route('/api/dtc/clear', methods=['POST'])
|
|
def api_dtc_clear():
|
|
result = app_obd.clear_dtc_codes()
|
|
status_codes = {
|
|
"cleared": 200,
|
|
"not_connected": 409,
|
|
"error": 502,
|
|
}
|
|
return jsonify(result), status_codes.get(result["status"], 500)
|
|
|
|
@app.route('/api/stats')
|
|
def api_stats():
|
|
return jsonify(app_sql.get_overview_stats())
|
|
|
|
if __name__ == '__main__':
|
|
# Debug/Development
|
|
# app.run(debug=True, host="0.0.0.0", port="5000")
|
|
# Production
|
|
http_server = WSGIServer(('', 5000), app)
|
|
print("server on http://localhost:5000")
|
|
http_server.serve_forever()
|
|
|