389 lines
14 KiB
Python
389 lines
14 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, 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
|
|
|
|
@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/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
|
|
|
|
import functools
|
|
import urllib.request
|
|
|
|
@functools.lru_cache(maxsize=512)
|
|
def _geocode_cached(lat4, lon4):
|
|
"""Try Nominatim first (street-level), fall back to reverse_geocoder (city)."""
|
|
lat = lat4 / 10000.0
|
|
lon = lon4 / 10000.0
|
|
try:
|
|
url = f"https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat={lat}&lon={lon}&accept-language=en"
|
|
req = urllib.request.Request(url, headers={"User-Agent": "yaris-obd/1.0"})
|
|
with urllib.request.urlopen(req, timeout=4) as resp:
|
|
data = json.loads(resp.read())
|
|
a = data.get("address", {})
|
|
road = a.get("road") or a.get("hamlet") or a.get("suburb") or ""
|
|
city = a.get("city") or a.get("town") or a.get("village") or a.get("municipality") or ""
|
|
label = ", ".join(p for p in [road, city] if p)
|
|
if label:
|
|
return label
|
|
except Exception:
|
|
pass
|
|
# Offline fallback
|
|
import reverse_geocoder as rg
|
|
results = rg.search([(lat, lon)], mode=1, verbose=False)
|
|
if results:
|
|
r = results[0]
|
|
return ", ".join(p for p in [r.get("name", ""), r.get("admin1", "")] if p)
|
|
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
|
|
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)
|
|
label = _geocode_cached(lat4, lon4)
|
|
if label:
|
|
result[f"{lat:.3f},{lon:.3f}"] = label
|
|
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/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()
|
|
|