Files
yaris-obd/server/app.py
T

1103 lines
39 KiB
Python

"""yaris-obd remote server: receives trip/GPS log batches and dashcam files
uploaded by the Pi, storing them in Postgres + on-disk file storage.
Also serves the remote web portal for browsing and managing the configured car.
"""
import functools
import hashlib
import json
import os
import re
import secrets
import shutil
import threading
import time
from collections import defaultdict
from flask import (
Flask, request, jsonify, g, render_template, redirect, url_for,
flash, session, Response, abort, send_file
)
from flask_login import login_user, logout_user, current_user
from modules import db
from modules.auth import require_device_auth
from modules.user_auth import login_manager, User, check_password, require_login
from modules import device_config, device_registry
# ── Geocode cache ──────────────────────────────────────────────────────────
# reverse_geocoder is an offline Python library that maps lat/lon →
# city/province using a precomputed gazetteer (~40MB). Instant, no network,
# always available — a much better fit for trip scrubbing than Nominatim
# which is slow, rate-limited, and often unreachable from Docker/Cloudflare.
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 = Flask(__name__)
# Secret key for browser sessions — read from env in production.
app.secret_key = os.environ.get("SECRET_KEY") or secrets.token_hex(32)
# Session-cookie hardening. The portal is served over HTTPS via Cloudflare
# Tunnel, so the cookie is marked Secure by default; set COOKIE_SECURE=0 in
# .env only if you also log in to the portal over plain HTTP on the LAN.
app.config.update(
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
SESSION_COOKIE_SECURE=os.environ.get("COOKIE_SECURE", "1") == "1",
)
# Where uploaded dashcam files get written on disk (mounted to the unraid
# array via docker-compose - see DASHCAM_STORAGE_DIR in .env).
STORAGE_DIR = os.environ.get("DASHCAM_STORAGE_DIR", "/data/dashcam")
MAX_DASHCAM_UPLOAD_MB = int(os.environ.get("MAX_DASHCAM_UPLOAD_MB", "2048"))
# Where the Android APK (and its version manifest) live — under the mounted
# dashcam volume so the user can drop files in without a new mount.
APP_DIR = os.path.join(STORAGE_DIR, "app")
APP_APK_PATH = os.path.join(APP_DIR, "yaris-obd.apk")
APP_VERSION_PATH = os.path.join(APP_DIR, "version.json")
app.config["MAX_CONTENT_LENGTH"] = MAX_DASHCAM_UPLOAD_MB * 1024 * 1024
# Sanity cap on tracking points per request - the Pi client batches in
# groups of 500, this just guards against a runaway/misbehaving client.
MAX_TRACKING_BATCH_POINTS = 5000
# Dashcam uploads are content-addressed by sha256, and that digest also names
# the staging directory for chunked uploads. It MUST be a plain 64-char hex
# string so a crafted digest can't traverse out of the staging directory.
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
def _is_valid_sha256(value):
return bool(_SHA256_RE.fullmatch(value or ""))
# Initialise Flask-Login
login_manager.init_app(app)
def _portal_device_id():
"""Return the device id for the user's selected device, falling back to
the DEVICE_NAME env var default. Selection is stored in the Flask session
so it persists across pages without a URL parameter on every link."""
# Check session first (user explicitly picked a device)
session_id = session.get("selected_device_id")
if session_id is not None:
dev = db.get_device_by_id(session_id)
if dev is not None:
return dev["id"]
# Selected device was deleted — clear stale session entry
session.pop("selected_device_id", None)
# Fall back to configured default
name = os.environ.get("DEVICE_NAME", "yaris-2013")
dev = db.get_device_by_name(name)
return dev["id"] if dev else None
@app.get("/api/v1/portal/select-device")
@require_login
def portal_select_device_state():
"""Return current selection and available devices for the dropdown."""
devices = device_registry.list_devices()
configured_name = os.environ.get("DEVICE_NAME", "yaris-2013")
for device in devices:
device["portal_default"] = device["name"] == configured_name
current_id = session.get("selected_device_id")
return jsonify(
devices=devices,
selected_device_id=current_id,
configured_device_name=configured_name,
)
@app.post("/api/v1/portal/select-device")
@require_login
def portal_select_device():
"""Set the active device for the current session."""
body = request.get_json(silent=True) or {}
device_id = body.get("device_id")
if device_id is None:
session.pop("selected_device_id", None)
session.pop("selected_device_name", None)
return jsonify(status="ok", message="Device selection cleared.")
dev = db.get_device_by_id(int(device_id))
if dev is None:
return jsonify(error="Device not found"), 404
session["selected_device_id"] = dev["id"]
session["selected_device_name"] = dev["device_name"] or dev["name"]
return jsonify(status="ok", device_id=dev["id"], device_name=dev["device_name"] or dev["name"])
@app.get("/health")
def health():
"""Unauthenticated liveness check - used by docker-compose healthcheck
and to sanity-test connectivity from the Pi before attempting uploads."""
return jsonify(status="ok"), 200
# ── Android app distribution ──────────────────────────────────────────────
# The Pi polls /api/v1/app/latest and mirrors the APK to its in-car hotspot so
# the phone can install/update without leaving the car WiFi. The APK lands here
# either via the portal upload page below or by dropping it directly into the
# mounted dashcam volume at app/yaris-obd.apk.
def _app_manifest():
if not os.path.isfile(APP_VERSION_PATH) or not os.path.isfile(APP_APK_PATH):
return None
try:
with open(APP_VERSION_PATH) as f:
return json.load(f)
except Exception:
return None
@app.get("/api/v1/app/latest")
def app_latest():
"""Latest APK version + checksum. Public — the Pi polls this unauthenticated."""
manifest = _app_manifest()
if manifest is None:
return jsonify(error="no app published"), 404
return jsonify(manifest)
@app.get("/app/yaris-obd.apk")
def app_download_apk():
if not os.path.isfile(APP_APK_PATH):
return jsonify(error="no app published"), 404
return send_file(APP_APK_PATH, as_attachment=True,
download_name="yaris-obd.apk",
mimetype="application/vnd.android.package-archive")
@app.get("/app/upload")
@require_login
def app_upload_page():
csrf_token = secrets.token_hex(16)
session["csrf_token"] = csrf_token
return render_template("app_upload.html", csrf_token=csrf_token)
@app.post("/app/upload")
@require_login
def app_upload_submit():
token = request.form.get("csrf_token", "")
if not token or token != session.pop("csrf_token", None):
return "Invalid request. Please try again.", 400
if "file" not in request.files:
return "missing file", 400
version = (request.form.get("version") or "").strip()
if not version:
return "version required", 400
uploaded = request.files["file"]
if not uploaded.filename or not uploaded.filename.lower().endswith(".apk"):
return "expected a .apk file", 400
os.makedirs(APP_DIR, exist_ok=True)
hasher = hashlib.sha256()
tmp_path = APP_APK_PATH + ".part"
size_bytes = 0
with open(tmp_path, "wb") as f:
while True:
chunk = uploaded.stream.read(1024 * 1024)
if not chunk:
break
hasher.update(chunk)
size_bytes += len(chunk)
f.write(chunk)
os.replace(tmp_path, APP_APK_PATH)
with open(APP_VERSION_PATH, "w") as f:
json.dump({"version": version, "sha256": hasher.hexdigest(), "size": size_bytes}, f)
return redirect(url_for("app_upload_page"))
@app.post("/api/v1/tracking/batch")
@require_device_auth
def upload_tracking_batch():
"""Bulk-insert a batch of GPS/trip tracking points for the authenticated device.
Expected JSON body: {"points": [{"recorded_at": "...", "gps_lat": ..., ...}, ...]}
recorded_at must be an ISO-8601 timestamp string. Duplicate (device,
recorded_at) pairs are silently ignored, so the Pi can safely re-send a
batch after a network failure without creating duplicate rows.
"""
body = request.get_json(silent=True) or {}
points = body.get("points")
if not isinstance(points, list) or not points:
return jsonify(error="expected a non-empty 'points' list"), 400
if len(points) > MAX_TRACKING_BATCH_POINTS:
return jsonify(
error=f"batch too large - max {MAX_TRACKING_BATCH_POINTS} points per request"
), 400
for p in points:
if "recorded_at" not in p:
return jsonify(error="every point requires 'recorded_at'"), 400
inserted = db.insert_tracking_points(g.device_id, points)
# Recompute trips in background thread so portal reflects new data immediately.
if inserted > 0:
import threading
threading.Thread(
target=db.compute_trips_for_device,
args=(g.device_id,),
daemon=True,
).start()
return jsonify(status="ok", received=inserted), 201
@app.post("/api/v1/dashcam/upload")
@require_device_auth
def upload_dashcam_file():
"""Upload a single dashcam video file.
Multipart form fields:
file: the video file itself
relative_path: original sub-folder path on the dashcam SD card (form field)
sha256: hex digest of the file, computed by the Pi before sending
Skips (returns 200, not re-saved) if a file with the same
(device, relative_path, sha256) was already uploaded - safe to retry.
"""
if "file" not in request.files:
return jsonify(error="missing 'file' in multipart form"), 400
relative_path = request.form.get("relative_path", "").strip()
expected_sha256 = request.form.get("sha256", "").strip().lower()
if not relative_path or not _is_valid_sha256(expected_sha256):
return jsonify(error="missing or invalid 'relative_path' or 'sha256' form field"), 400
device_id = g.device_id
if db.dashcam_file_exists(device_id, relative_path, expected_sha256):
return jsonify(status="already_uploaded"), 200
uploaded = request.files["file"]
device_dir = os.path.join(STORAGE_DIR, g.device_name)
dest_path = os.path.normpath(os.path.join(device_dir, relative_path))
# Guard against a malicious/buggy relative_path (e.g. "../../etc") escaping
# the per-device storage directory.
if os.path.commonpath([device_dir, dest_path]) != os.path.normpath(device_dir):
return jsonify(error="invalid relative_path"), 400
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
hasher = hashlib.sha256()
tmp_path = dest_path + ".part"
size_bytes = 0
with open(tmp_path, "wb") as f:
while True:
chunk = uploaded.stream.read(1024 * 1024)
if not chunk:
break
hasher.update(chunk)
size_bytes += len(chunk)
f.write(chunk)
actual_sha256 = hasher.hexdigest()
if actual_sha256 != expected_sha256:
os.remove(tmp_path)
return jsonify(error="sha256 mismatch, upload corrupted in transit"), 422
os.replace(tmp_path, dest_path)
db.insert_dashcam_file(device_id, relative_path, size_bytes, actual_sha256, dest_path)
return jsonify(status="ok", size_bytes=size_bytes), 201
# --- Chunked dashcam upload ---------------------------------------------
# Real dashcam clips run 300+MB, well past Cloudflare Tunnel's ~100MB max
# request body size (confirmed with a real 250MB file: a direct LAN upload
# succeeds, the same request through the tunnel comes back 413). The Pi
# client splits files into small chunks (see CHUNK_SIZE in
# modules/app_upload.py) and PUTs them one at a time through this endpoint
# instead, staying comfortably under that limit. Chunks for a given upload
# are staged on disk under _CHUNK_TMP_DIR and reassembled once complete.
_CHUNK_TMP_DIRNAME = ".upload_tmp"
def _chunk_tmp_dir(device_name, sha256):
return os.path.join(STORAGE_DIR, _CHUNK_TMP_DIRNAME, device_name, sha256)
def _received_chunk_indices(tmp_dir):
if not os.path.isdir(tmp_dir):
return set()
indices = set()
for name in os.listdir(tmp_dir):
if name.endswith(".chunk"):
try:
indices.add(int(name[: -len(".chunk")]))
except ValueError:
continue
return indices
@app.get("/api/v1/dashcam/upload/status")
@require_device_auth
def dashcam_upload_status():
"""Report upload progress for (relative_path, sha256) so the Pi client
can resume a chunked upload without re-sending chunks the server
already has (e.g. after a network drop or app restart mid-transfer)."""
relative_path = request.args.get("relative_path", "").strip()
sha256 = request.args.get("sha256", "").strip().lower()
if not relative_path or not _is_valid_sha256(sha256):
return jsonify(error="missing or invalid 'relative_path' or 'sha256' query param"), 400
if db.dashcam_file_exists(g.device_id, relative_path, sha256):
return jsonify(status="already_uploaded"), 200
tmp_dir = _chunk_tmp_dir(g.device_name, sha256)
received = sorted(_received_chunk_indices(tmp_dir))
return jsonify(status="in_progress" if received else "not_started", received_chunk_indices=received), 200
@app.post("/api/v1/dashcam/upload/chunk")
@require_device_auth
def dashcam_upload_chunk():
"""Receive one chunk of a dashcam video file.
Multipart form fields:
file: the chunk's bytes
relative_path: original sub-folder path on the dashcam SD card
sha256: hex digest of the *whole* (reassembled) file
chunk_index: 0-based index of this chunk
total_chunks: total number of chunks for this file
Once every chunk 0..total_chunks-1 has arrived, the chunks are
concatenated in order, the combined sha256 is verified, and the result
is recorded exactly like the single-shot /upload endpoint.
"""
if "file" not in request.files:
return jsonify(error="missing 'file' in multipart form"), 400
relative_path = request.form.get("relative_path", "").strip()
expected_sha256 = request.form.get("sha256", "").strip().lower()
try:
chunk_index = int(request.form.get("chunk_index", ""))
total_chunks = int(request.form.get("total_chunks", ""))
except ValueError:
return jsonify(error="'chunk_index'/'total_chunks' must be integers"), 400
if (not relative_path or not _is_valid_sha256(expected_sha256)
or total_chunks < 1 or not (0 <= chunk_index < total_chunks)):
return jsonify(error="missing/invalid form fields"), 400
device_id = g.device_id
if db.dashcam_file_exists(device_id, relative_path, expected_sha256):
return jsonify(status="already_uploaded"), 200
device_dir = os.path.join(STORAGE_DIR, g.device_name)
dest_path = os.path.normpath(os.path.join(device_dir, relative_path))
if os.path.commonpath([device_dir, dest_path]) != os.path.normpath(device_dir):
return jsonify(error="invalid relative_path"), 400
tmp_dir = _chunk_tmp_dir(g.device_name, expected_sha256)
# A stale tmp_dir left over from a previous attempt with a *different*
# total_chunks (e.g. the client's chunk size changed between app
# versions) would reassemble incorrectly - start clean if that happens.
marker_path = os.path.join(tmp_dir, ".total_chunks")
if os.path.isdir(tmp_dir) and os.path.exists(marker_path):
with open(marker_path) as marker_file:
if marker_file.read().strip() != str(total_chunks):
shutil.rmtree(tmp_dir)
os.makedirs(tmp_dir, exist_ok=True)
with open(marker_path, "w") as marker_file:
marker_file.write(str(total_chunks))
chunk_path = os.path.join(tmp_dir, f"{chunk_index:06d}.chunk")
uploaded = request.files["file"]
with open(chunk_path, "wb") as f:
while True:
data = uploaded.stream.read(1024 * 1024)
if not data:
break
f.write(data)
received = _received_chunk_indices(tmp_dir)
if len(received) < total_chunks:
return jsonify(status="chunk_received", received_chunks=len(received), total_chunks=total_chunks), 202
# All chunks in - reassemble, verify, and store.
hasher = hashlib.sha256()
tmp_path = dest_path + ".part"
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
size_bytes = 0
with open(tmp_path, "wb") as out:
for idx in range(total_chunks):
with open(os.path.join(tmp_dir, f"{idx:06d}.chunk"), "rb") as chunk_file:
while True:
data = chunk_file.read(1024 * 1024)
if not data:
break
hasher.update(data)
size_bytes += len(data)
out.write(data)
actual_sha256 = hasher.hexdigest()
shutil.rmtree(tmp_dir, ignore_errors=True)
if actual_sha256 != expected_sha256:
os.remove(tmp_path)
return jsonify(error="sha256 mismatch, upload corrupted in transit"), 422
os.replace(tmp_path, dest_path)
db.insert_dashcam_file(device_id, relative_path, size_bytes, actual_sha256, dest_path)
return jsonify(status="complete", size_bytes=size_bytes), 201
# ── Login rate limiting ────────────────────────────────────────────────────
# In-memory failed-attempt limiter. The app runs as a single gunicorn worker
# (threads=4), so process-local state is sufficient; a multi-worker deployment
# would need a shared store (Redis/DB) instead. Keys are the client IP (from
# Cloudflare's CF-Connecting-IP) plus the submitted username, so a distributed
# brute-force can't bypass the per-IP cap by rotating source addresses.
_LOGIN_FAIL_WINDOW_S = 900 # 15 minutes
_LOGIN_FAIL_MAX = 8 # failed attempts per key before blocking
_login_failures = defaultdict(list)
_login_failures_lock = threading.Lock()
def _login_failure_key_ip():
return (
request.headers.get("CF-Connecting-IP")
or (request.headers.get("X-Forwarded-For") or "").split(",")[0].strip()
or request.remote_addr
or "unknown"
)
def _login_blocked(key):
if not key:
return False
now = time.time()
cutoff = now - _LOGIN_FAIL_WINDOW_S
with _login_failures_lock:
_login_failures[key] = [t for t in _login_failures[key] if t > cutoff]
return len(_login_failures[key]) >= _LOGIN_FAIL_MAX
def _record_login_failure(key):
if not key:
return
with _login_failures_lock:
_login_failures[key].append(time.time())
# ── Browser auth ───────────────────────────────────────────────────────────
@app.get("/login")
def login_page():
if current_user.is_authenticated:
return redirect(url_for("portal_index"))
csrf_token = secrets.token_hex(16)
session["csrf_token"] = csrf_token
return render_template("login.html", csrf_token=csrf_token)
@app.post("/login")
def login_submit():
# CSRF check
token = request.form.get("csrf_token", "")
if not token or token != session.pop("csrf_token", None):
flash("Invalid request. Please try again.", "error")
return redirect(url_for("login_page"))
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
client_ip = _login_failure_key_ip()
if _login_blocked(client_ip) or _login_blocked(f"user:{username}"):
csrf_token = secrets.token_hex(16)
session["csrf_token"] = csrf_token
flash("Too many failed attempts. Please try again later.", "error")
return render_template("login.html", csrf_token=csrf_token), 429
user_row = db.get_user_by_username(username)
if user_row and check_password(password, user_row["password_hash"]):
login_user(User(user_row["id"], user_row["username"]), remember=False)
return redirect(url_for("portal_index"))
_record_login_failure(client_ip)
if username:
_record_login_failure(f"user:{username}")
flash("Invalid username or password.", "error")
return redirect(url_for("login_page"))
@app.get("/logout")
@require_login
def logout():
logout_user()
return redirect(url_for("login_page"))
@app.get("/account/password")
@require_login
def change_password_page():
csrf_token = secrets.token_hex(16)
session["csrf_token"] = csrf_token
return render_template("change_password.html", csrf_token=csrf_token)
@app.post("/account/password")
@require_login
def change_password():
token = request.form.get("csrf_token", "")
if not token or token != session.pop("csrf_token", None):
flash("Invalid request. Please try again.", "error")
return redirect(url_for("change_password_page"))
current = request.form.get("current_password", "")
new_pw = request.form.get("new_password", "")
confirm = request.form.get("confirm_password", "")
user_row = db.get_user_by_id(current_user.id)
if not user_row or not check_password(current, user_row["password_hash"]):
flash("Current password is incorrect.", "error")
return redirect(url_for("change_password_page"))
if len(new_pw) < 8:
flash("New password must be at least 8 characters.", "error")
return redirect(url_for("change_password_page"))
if new_pw != confirm:
flash("Passwords do not match.", "error")
return redirect(url_for("change_password_page"))
from modules.user_auth import hash_password
with db.get_cursor(commit=True) as cur:
cur.execute("UPDATE users SET password_hash = %s WHERE id = %s",
(hash_password(new_pw), current_user.id))
flash("Password updated successfully.", "success")
return redirect(url_for("change_password_page"))
# ── Portal UI pages ────────────────────────────────────────────────────────
@app.get("/")
@require_login
def portal_index():
return render_template("index.html")
@app.get("/history")
@require_login
def portal_history():
return render_template("history.html")
@app.get("/dashcam")
@require_login
def portal_dashcam():
return render_template("dashcam.html")
@app.get("/settings/device")
@require_login
def portal_device_settings_page():
return render_template(
"device_settings.html",
configured_device_name=os.environ.get("DEVICE_NAME", "yaris-2013"),
)
@app.get("/dtc")
@require_login
def portal_dtc_page():
return render_template("dtc.html")
# ── Portal JSON API ────────────────────────────────────────────────────────
@app.get("/api/v1/portal/stats")
@require_login
def portal_stats():
device_id = _portal_device_id()
if device_id is None:
return jsonify(error="device not configured"), 404
return jsonify(db.get_overview_stats(device_id))
@app.get("/api/v1/portal/trips")
@require_login
def portal_trips():
device_id = _portal_device_id()
if device_id is None:
return jsonify(error="device not configured"), 404
try:
limit = min(int(request.args.get("limit", 200)), 1000)
except (ValueError, TypeError):
limit = 200
return jsonify(db.list_trips(device_id, limit=limit))
@app.get("/api/v1/portal/trip/<int:trip_id>")
@require_login
def portal_trip_detail(trip_id):
device_id = _portal_device_id()
if device_id is None:
return jsonify(error="device not configured"), 404
data = db.get_trip_route(device_id, trip_id)
if data is None:
return jsonify(error="trip not found"), 404
return jsonify(data)
@app.get("/api/v1/portal/dashcam")
@require_login
def portal_dashcam_list():
device_id = _portal_device_id()
if device_id is None:
return jsonify(error="device not configured"), 404
try:
limit = min(int(request.args.get("limit", 500)), 2000)
except (ValueError, TypeError):
limit = 500
return jsonify(db.list_dashcam_files(device_id, limit=limit))
@app.get("/api/v1/portal/dashcam/stream/<int:file_id>")
@require_login
def portal_dashcam_stream(file_id):
"""Stream a dashcam file with Range request support for video seeking."""
device_id = _portal_device_id()
if device_id is None:
abort(404)
file_row = db.get_dashcam_file(device_id, file_id)
if file_row is None:
abort(404)
storage_path = file_row["storage_path"]
# Safety check: must still be under STORAGE_DIR
try:
safe = os.path.commonpath([STORAGE_DIR, os.path.normpath(storage_path)]) == os.path.normpath(STORAGE_DIR)
except ValueError:
safe = False
if not safe:
abort(403)
if not os.path.isfile(storage_path):
abort(404)
file_size = os.path.getsize(storage_path)
range_header = request.headers.get("Range")
mime = "video/mp4"
filename = file_row["relative_path"].split("/")[-1]
if not range_header:
# Full file download
def generate_full():
with open(storage_path, "rb") as f:
while True:
chunk = f.read(1024 * 1024)
if not chunk:
break
yield chunk
headers = {
"Content-Disposition": f'inline; filename="{filename}"',
"Content-Length": file_size,
"Accept-Ranges": "bytes",
}
return Response(generate_full(), status=200, headers=headers, mimetype=mime)
# Parse Range header (bytes=start-end)
try:
range_val = range_header.replace("bytes=", "")
start_s, end_s = range_val.split("-")
start = int(start_s)
end = int(end_s) if end_s else file_size - 1
except Exception:
abort(416)
if start >= file_size or end >= file_size or start > end:
abort(416)
length = end - start + 1
def generate_range():
with open(storage_path, "rb") as f:
f.seek(start)
remaining = length
while remaining > 0:
chunk = f.read(min(1024 * 1024, remaining))
if not chunk:
break
remaining -= len(chunk)
yield chunk
headers = {
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Content-Length": length,
"Accept-Ranges": "bytes",
"Content-Disposition": f'inline; filename="{filename}"',
}
return Response(generate_range(), status=206, headers=headers, mimetype=mime)
# ── Geocode (same offline logic as Pi) ────────────────────────────────────
@app.get("/api/geocode")
@require_login
def geocode():
try:
lat = float(request.args["lat"])
lon = float(request.args["lon"])
except (KeyError, ValueError):
return jsonify(error="lat and lon required"), 400
lat4 = int(round(lat, 4) * 10000)
lon4 = int(round(lon, 4) * 10000)
label = _geocode_cached(lat4, lon4)
return jsonify(label=label)
@app.post("/api/geocode/batch")
@require_login
def geocode_batch():
"""Batch geocode — returns {key: label} for all unique coords in one request.
Uses the offline reverse_geocoder library (instant, no network). Deduplicates
coords before lookup; lru_cache absorbs any remaining duplicates."""
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
seen = set()
items = []
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))
items.append((f"{lat:.3f},{lon:.3f}", lat4, lon4))
result = {}
for key, lat4, lon4 in items:
result[key] = _geocode_cached(lat4, lon4)
return jsonify(result)
# ── Uplink trigger (recompute trips after new batch) ──────────────────────
@app.post("/api/v1/portal/recompute-trips")
@require_login
def recompute_trips():
"""Recompute trips from scratch, clearing all deletion markers."""
device_id = _portal_device_id()
if device_id is None:
return jsonify(error="device not configured"), 404
db.clear_deleted_trip_markers(device_id)
n = db.compute_trips_for_device(device_id)
return jsonify(status="ok", trips_computed=n)
@app.delete("/api/v1/portal/trip/<int:trip_id>")
@require_login
def portal_delete_trip(trip_id):
device_id = _portal_device_id()
if device_id is None:
return jsonify(error="device not configured"), 404
deleted = db.delete_trip(device_id, trip_id)
if not deleted:
return jsonify(error="trip not found"), 404
return jsonify(status="ok")
@app.post("/api/v1/portal/trips/delete")
@require_login
def portal_delete_trips_batch():
device_id = _portal_device_id()
if device_id is None:
return jsonify(error="device not configured"), 404
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 = db.delete_trips_batch(device_id, ids)
return jsonify(status="ok", deleted=deleted)
@app.delete("/api/v1/portal/dashcam/<int:file_id>")
@require_login
def portal_delete_dashcam(file_id):
device_id = _portal_device_id()
if device_id is None:
return jsonify(error="device not configured"), 404
storage_path = db.delete_dashcam_file(device_id, file_id)
if storage_path is None:
return jsonify(error="file not found"), 404
# Remove from disk (best-effort — don't fail if file is already gone)
try:
if os.path.isfile(storage_path):
os.remove(storage_path)
except OSError:
pass
return jsonify(status="ok")
@app.get("/api/trips")
@require_login
def portal_trips_compat():
"""Pi history.js compatibility alias — wraps response in {trips: [...]}."""
device_id = _portal_device_id()
if device_id is None:
return jsonify(error="device not configured"), 404
limit = min(int(request.args.get("limit", 200)), 1000)
trips = db.list_trips(device_id, limit=limit)
return jsonify(trips=trips)
@app.get("/api/trip/<int:trip_id>")
@require_login
def portal_trip_compat(trip_id):
"""Pi history.js compatibility alias."""
return portal_trip_detail(trip_id)
# ── Phase 25.5a: Device configuration sync ──────────────────────────────────
@app.get("/api/v1/portal/devices")
@require_login
def portal_devices():
devices = device_registry.list_devices()
configured_name = os.environ.get("DEVICE_NAME", "yaris-2013")
for device in devices:
device["portal_default"] = device["name"] == configured_name
return jsonify(
devices=devices,
configured_device_name=configured_name,
)
@app.post("/api/v1/portal/devices")
@require_login
def portal_device_create():
body = request.get_json(silent=True) or {}
result, errors = device_registry.create_device(body.get("name"))
if errors:
return jsonify(error="Invalid device.", fields=errors), 400
return jsonify(result), 201
@app.get("/api/v1/portal/devices/<int:device_id>/config")
@require_login
def portal_device_config_detail(device_id):
config = device_config.get_device_config(device_id)
if not config:
return jsonify(error="device not found"), 404
device = db.get_device_by_id(device_id)
return jsonify(
device=device,
config=config,
)
@app.post("/api/v1/portal/devices/<int:device_id>/config")
@require_login
def portal_device_config_update(device_id):
success, errors = device_config.update_device_config(
device_id,
request.get_json(silent=True) or {},
)
if not success:
return jsonify(error="Invalid device config.", fields=errors), 400
return jsonify(
device=db.get_device_by_id(device_id),
config=device_config.get_device_config(device_id),
)
@app.post("/api/v1/portal/devices/<int:device_id>/rotate-key")
@require_login
def portal_device_rotate_key(device_id):
result, errors = device_registry.rotate_device_key(device_id)
if errors:
return jsonify(error=errors.get("error", "Could not rotate key.")), 404
return jsonify(result)
@app.post("/api/v1/portal/devices/<int:device_id>/request-sync")
@require_login
def portal_device_request_sync(device_id):
result = device_registry.request_sync(device_id)
if result is None:
return jsonify(error="device not found"), 404
return jsonify(result)
@app.delete("/api/v1/portal/devices/<int:device_id>")
@require_login
def portal_device_delete(device_id):
success, errors = device_registry.delete_device(
device_id,
protected_device_name=os.environ.get("DEVICE_NAME", "yaris-2013"),
)
if not success:
status = 404 if errors.get("error") == "Device not found." else 409
return jsonify(error=errors.get("error", "Could not delete device.")), status
return jsonify(status="deleted")
@app.get("/api/v1/portal/device-config")
@require_login
def portal_device_config_get():
"""Get the configured device identity for the remote portal."""
device_id = _portal_device_id()
if device_id is None:
return jsonify(error="device not configured"), 404
config = device_config.get_device_config(device_id)
if not config:
return jsonify(error="device not found"), 404
config["configured_device_name"] = os.environ.get("DEVICE_NAME", "yaris-2013")
return jsonify(config)
@app.get("/api/device-config")
@require_device_auth
def api_device_config_get():
"""Get device configuration for the authenticated device.
Pi calls this to fetch config from server (connection-driven sync).
Returns owner_name, car_model, device_name, avatar_path, config_version.
"""
config = device_config.get_device_config(g.device_id)
if not config:
return jsonify(error="device not found"), 404
return jsonify(config)
@app.post("/api/device-config/push")
@require_device_auth
def api_device_config_push():
"""Update device configuration from the Pi (device-authenticated).
Pi pushes local config changes to server. Accepts a partial update:
any of owner_name, car_model, device_name, avatar_path, odometer_baseline.
odometer_baseline is Pi→Server only — the portal cannot edit it.
"""
body = request.get_json(silent=True) or {}
# Handle odometer_baseline separately — Pi→Server only.
odometer = body.pop("odometer_baseline", None)
if odometer is not None:
try:
odometer = float(odometer)
if odometer < 0 or odometer > 999999:
return jsonify(error="Invalid device config.",
fields={"odometer_baseline": "Must be between 0 and 999,999 km."}), 400
except (TypeError, ValueError):
return jsonify(error="Invalid device config.",
fields={"odometer_baseline": "Must be a number."}), 400
with db.get_cursor(commit=True) as cur:
cur.execute(
"UPDATE devices SET odometer_baseline = %s, config_version = config_version + 1, config_updated_at = now() WHERE id = %s",
(odometer, g.device_id),
)
success, errors = device_config.update_device_config(g.device_id, body)
if not success:
return jsonify(error="Invalid device config.", fields=errors), 400
config = device_config.get_device_config(g.device_id)
return jsonify(config)
@app.post("/api/v1/device-config/ack")
@require_device_auth
def api_device_config_ack():
body = request.get_json(silent=True) or {}
try:
config_version = int(body.get("config_version"))
except (TypeError, ValueError):
return jsonify(error="config_version is required"), 400
device_registry.acknowledge_sync(g.device_id, config_version)
return jsonify(status="ok")
@app.post("/api/device-config")
@require_login
def api_device_config_update():
"""Update device configuration (server portal/admin only).
Updates owner_name, car_model, device_name, avatar_path.
Increments config_version to signal change to Pi.
"""
device_id = _portal_device_id()
if device_id is None:
return jsonify(error="device not configured"), 404
body = request.get_json(silent=True) or {}
success, errors = device_config.update_device_config(device_id, body)
if not success:
return jsonify(error="Invalid device config.", fields=errors), 400
config = device_config.get_device_config(device_id)
return jsonify(config)
# ── DTC sync (FEAT-05) ────────────────────────────────────────────────────────
@app.post("/api/v1/dtc/batch")
@require_device_auth
def upload_dtc_batch():
"""Receive a DTC snapshot from the Pi. Body: {mil_on: bool, codes: [{code, description}, ...]}."""
body = request.get_json(silent=True) or {}
mil_on = bool(body.get("mil_on", False))
codes = body.get("codes")
if not isinstance(codes, list):
return jsonify(error="expected 'codes' as a list"), 400
for c in codes:
if not isinstance(c.get("code"), str) or not c["code"].strip():
return jsonify(error="each code must have a non-empty 'code' string"), 400
result = db.upsert_dtc_snapshot(g.device_id, mil_on, codes)
return jsonify(result), 200
@app.get("/api/v1/portal/dtc")
@require_login
def portal_dtc():
device_id = _portal_device_id()
if device_id is None:
return jsonify(error="device not configured"), 404
return jsonify(db.list_dtc_events(device_id))
@app.delete("/api/v1/portal/dtc/<int:dtc_id>")
@require_login
def portal_dtc_delete(dtc_id):
device_id = _portal_device_id()
if device_id is None:
return jsonify(error="device not configured"), 404
deleted = db.delete_dtc_event(device_id, dtc_id)
if not deleted:
return jsonify(error="DTC event not found"), 404
return jsonify(status="ok")
def main():
app.run(host="0.0.0.0", port=8098)
if __name__ == "__main__":
main()