From ba682fbf3a32ca67fa0663936ce243111fb3d0dc Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Mon, 10 Aug 2026 18:02:09 -0400 Subject: [PATCH] feat: add multi-device provisioning and local remote-config UI - add multi-device portal device management, key rotation, and sync request APIs - add Pi settings UI for remote server URL, device name, and API key - add local test-connection and sync-now actions - make upload and config sync reuse dynamic shared credentials - add Phase 25.5c migration and docs updates Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PLAN.md | 164 +++++++++++++++- app.py | 33 +++- deploy/readme.md | 8 + modules/app_device_config.py | 9 + modules/app_device_sync.py | 87 +++++---- modules/app_remote_config.py | 154 +++++++++++++++ modules/app_upload.py | 46 +++-- readme.md | 11 +- server/README.md | 16 +- server/app.py | 83 +++++++- server/docker-compose.yml | 1 + server/modules/db.py | 114 ++++++++++- server/modules/device_config.py | 3 +- server/modules/device_registry.py | 71 +++++++ server/scripts/create_device.py | 28 +-- server/sql/init.sql | 12 +- server/sql/migrate_phase25_5c.sql | 10 + server/templates/device_settings.html | 121 +++++++++--- static/js/portal-device-settings.js | 244 ++++++++++++++++++++---- static/js/settings.js | 93 ++++++++- templates/settings.html | 39 ++++ tests/test_app_device_config_runtime.py | 18 +- tests/test_app_remote_config.py | 159 +++++++++++++++ 23 files changed, 1357 insertions(+), 167 deletions(-) create mode 100644 modules/app_remote_config.py create mode 100644 server/modules/device_registry.py create mode 100644 server/sql/migrate_phase25_5c.sql create mode 100644 tests/test_app_remote_config.py diff --git a/PLAN.md b/PLAN.md index d70fac4..058a636 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1570,10 +1570,166 @@ updates its display-facing values without a manual restart once sync lands. --- -### Phase 26: Native Android companion app β€” 🚧 PLANNED (low priority, always last) -Revives the phone-relay goal (Phase 11/12) properly this time, using a real native app instead of -a web page - the only way to actually hold a WiFi connection to the Pi's hotspot and a cellular -connection to the internet open simultaneously (`ConnectivityManager.bindProcessToNetwork()`, + ### Phase 25.5c: Multi-device provisioning + force-sync UX β€” 🚧 PLANNED + Extend the current single-device portal page into a real multi-device management + surface: register devices from the server, mint/rotate API keys, help the Pi + consume those credentials without SSH-heavy manual steps, and expose an honest + **force sync** action that works with the system's pull-based/offline-first + architecture. + + #### Goals + - **Multi-device server view**: stop assuming one `DEVICE_NAME` in the portal; + show all registered devices and let the user select which one is being edited + - **Device registration from the portal**: create a new device row and return a + one-time raw API key directly in the browser UI (same behavior as + `server/scripts/create_device.py`, but web-managed) + - **Credential handoff to the Pi**: make it possible to put `YARIS_SERVER_URL`, + `YARIS_DEVICE_NAME`, and `YARIS_API_KEY` onto the Pi from the app UI rather + than editing `db/upload_creds.env` manually over SSH + - **Force sync button**: let the user request an immediate config pull without + pretending the cloud server can magically push into an offline car + + #### Proposed implementation + 1. **Server: device management model + portal** + - Add a device list page/card in the remote portal, with: + - registered devices table + - "Add device" action + - "Rotate API key" action + - per-device "Edit settings" action + - Replace the single-device lookup helper with: + - list devices + - fetch one device by id + - update one device's identity/config + - Keep raw API keys **write-only/one-time reveal**: + - show only immediately after create/rotate + - never store raw keys server-side + - only store hash as today + + 2. **Server: browser APIs** + - Add login-protected endpoints for: + - list devices + - create device + return one-time API key + - rotate device API key + return one-time API key + - get/update selected device config + - request force sync for a selected device + - Device settings UI should populate from the selected device row, not from a + single hardcoded portal `DEVICE_NAME` + + 3. **Pi: credential management UI** + - Add a local Settings section for remote server credentials: + - server URL + - device name + - API key + - connection test button + - Persist those values in the existing gitignored + `db/upload_creds.env` (or an equivalent sqlite-backed secret store if we + later choose to migrate away from env files) + - After saving: + - reload upload client / sync client config + - allow enabling **Sync with server** only when creds test cleanly + + 4. **Force sync semantics** + - **Constraint**: the server cannot directly reach a Pi behind hotspot/NAT, + and the car is often offline. So a server-side force sync button cannot be + a true push. + - Proposed behavior: + - server sets a per-device `force_sync_requested_at` flag/version bump + - Pi checks that flag during normal online polling + - local Pi UI also gets a **Sync now** button that triggers an immediate + fetch against the server using the saved credentials + - Result: + - from server: "sync on next online contact" + - from Pi: "sync immediately if internet is currently available" + + #### Data model changes + - Add server columns such as: + - `last_config_sync_at` + - `last_config_sync_version` + - `force_sync_requested_at` + - `api_key_rotated_at` + - Optionally add a lightweight pairing/claim model later if copying raw API keys + manually into the Pi UI feels too clunky + + #### UI/UX shape + - **Remote portal** + - left: device list / selector + - right: settings form for selected device + - actions: Add device, Rotate key, Copy creds, Request sync + - status badges: last seen, last config version, last sync requested, maybe + last successful contact + - **Pi local app** + - server credentials card in Settings + - Test connection button + - Sync now button + - clear warning when local edits are locked because server sync is enabled + + #### Security / operational notes + - Never display a stored API key after the creation/rotation response is gone + - Keep key rotation explicit; rotating a key should warn that the old Pi config + will stop authenticating until updated + - Avoid logging raw keys in Flask logs, JS errors, or shell output captured in + committed docs + - Because the Pi is usually offline, all flows must degrade gracefully when the + remote portal requests work the Pi cannot act on yet + + #### Suggested execution order + 1. Server multi-device DB/API refactor + 2. Remote portal device list + register/rotate key UI + 3. Pi local server-credentials UI + connection test + 4. Pi/local immediate **Sync now** button + 5. Server "request sync" flag + per-device status indicators + 6. End-to-end validation: + - create device on server + - copy/paste creds into Pi UI + - test connection + - enable sync + - change device identity on server + - verify Pi receives it + - rotate key and verify old key stops working until Pi updated + + #### Open constraint to keep in mind + - If you want **zero manual copy/paste** for API key provisioning, that becomes + a pairing problem (claim code / QR / short-lived token). That is doable, but + it is a bigger scope than simply exposing key create/copy fields in the two + existing UIs. My recommendation is to ship copy/paste provisioning first, then + add a pairing flow only if it still feels too annoying. + + #### Implementation progress (2026-08-10) + - **Server-side multi-device API/UI** + - Added device listing, create-device, rotate-key, per-device config, and + request-sync endpoints in the remote portal + - Reworked `/settings/device` into a multi-device management page with a + registered-device list, selected-device editor, one-time API-key reveal, and + copy-credentials helper + - **Server schema** + - Added Phase 25.5c migration for `last_config_sync_at`, + `last_config_sync_version`, `force_sync_requested_at`, and + `api_key_rotated_at` + - Added device sync acknowledgement endpoint so the Pi can report when a + config version was actually applied + - **Pi-side provisioning** + - Added local Settings UI/API for `YARIS_SERVER_URL`, `YARIS_DEVICE_NAME`, and + `YARIS_API_KEY` + - Added **Test connection** and **Sync now** actions in the Pi app + - Reused the existing gitignored `db/upload_creds.env` store instead of + inventing a second credential location + - **Runtime behavior fixes** + - Upload client now reads credentials dynamically instead of only at import + - Device sync thread can restart after credentials are added later + - Enabling **Sync with server** now validates that remote credentials exist + + #### Pending validation + - Deploy Phase 25.5c to unraid + Pi + - Live smoke-test device creation / key rotation / local credential paste flow + - Confirm server-side **Request sync** and Pi-side **Sync now** behavior against + the running hardware + + --- + + ### Phase 26: Native Android companion app β€” 🚧 PLANNED (low priority, always last) + Revives the phone-relay goal (Phase 11/12) properly this time, using a real native app instead of + a web page - the only way to actually hold a WiFi connection to the Pi's hotspot and a cellular + connection to the internet open simultaneously (`ConnectivityManager.bindProcessToNetwork()`, unavailable to browser JS - see the Phase 11 update). Explicitly **not urgent** and intentionally kept at the end of the roadmap, after all web/server work and the Phase 25 core in-car acceptance test are done. Permanent vehicle installation remains owner-handled and is not an implementation diff --git a/app.py b/app.py index f776c43..4a616ca 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,5 @@ 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 +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 import os, time from gevent.pywsgi import WSGIServer @@ -64,6 +64,37 @@ def api_device_config(): _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: diff --git a/deploy/readme.md b/deploy/readme.md index f93bbbb..eec7454 100644 --- a/deploy/readme.md +++ b/deploy/readme.md @@ -210,6 +210,14 @@ local Settings page. The same creds file is reused for config sync; when the server pushes new identity values, the running Pi UI hot-reloads them on the next successful sync. +As of Phase 25.5c, you can manage those locally from the Pi UI instead of SSH: + +- open **Settings** +- fill in **Remote server credentials** +- use **Test connection** +- use **Sync now** for an immediate pull attempt +- then enable **Sync with server** if the server should own the device identity + After editing, restart the app: ```bash diff --git a/modules/app_device_config.py b/modules/app_device_config.py index 185ceb4..890e666 100644 --- a/modules/app_device_config.py +++ b/modules/app_device_config.py @@ -72,9 +72,18 @@ def update_device_config(body, errors): if "sync_enabled" in body: if not isinstance(body["sync_enabled"], bool): errors["sync_enabled"] = "Must be true or false." + elif body["sync_enabled"]: + from . import app_remote_config + + remote = app_remote_config.get_remote_config() + if not remote["server_url"] or not remote["device_name"] or not remote["api_key_configured"]: + errors["sync_enabled"] = "Configure server URL, device name, and API key before enabling server sync." else: _set_sync_enabled(body["sync_enabled"]) + if "sync_enabled" in body and body["sync_enabled"] and "sync_enabled" not in errors: + _set_sync_enabled(True) + return errors diff --git a/modules/app_device_sync.py b/modules/app_device_sync.py index a32a789..122890a 100644 --- a/modules/app_device_sync.py +++ b/modules/app_device_sync.py @@ -10,18 +10,14 @@ import threading import time import requests -from dotenv import load_dotenv -from . import app_network, app_sql +from . import app_network, app_remote_config, app_sql _SYNC_THREAD = None _SYNC_STOP = False _SYNC_CONFIG_VERSION = 0 # Track last-synced version to detect changes _RELOAD_CALLBACK = None -_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - -load_dotenv(os.path.join(_BASE_DIR, "db", "upload_creds.env")) def _has_internet(): @@ -39,16 +35,15 @@ def _has_internet(): def _server_credentials(): - server_url = os.getenv("YARIS_SERVER_URL", "").rstrip("/") - device_name = os.getenv("YARIS_DEVICE_NAME") or os.getenv("YARIS_UPLOAD_DEVICE_NAME", "") - device_key = os.getenv("YARIS_DEVICE_KEY") or os.getenv("YARIS_API_KEY", "") - return server_url, device_name, device_key + credentials = app_remote_config.server_credentials() + device_key = os.getenv("YARIS_DEVICE_KEY") or credentials["api_key"] + return credentials["server_url"], credentials["device_name"], device_key def start_sync(): """Start background device config sync thread.""" global _SYNC_THREAD, _SYNC_STOP - if _SYNC_THREAD is not None: + if _SYNC_THREAD is not None and _SYNC_THREAD.is_alive(): return _SYNC_STOP = False @@ -70,36 +65,13 @@ def stop_sync(): def _sync_loop(): """Periodically sync device config from server when internet available.""" - global _SYNC_CONFIG_VERSION - - server_url, device_name, device_key = _server_credentials() - - if not server_url or not device_name or not device_key: - return - sync_interval = 300 offline_interval = 60 while not _SYNC_STOP: try: - if _has_internet(): - response = requests.get( - f"{server_url}/api/device-config", - headers={ - "X-Device-Name": device_name, - "X-Api-Key": device_key, - }, - timeout=5, - ) - - if response.status_code == 200: - config = response.json() - new_version = config.get("config_version", 0) - if new_version != _SYNC_CONFIG_VERSION: - _SYNC_CONFIG_VERSION = new_version - _store_synced_config(config) - _on_config_changed() - + result = sync_now() + if result["status"] in {"synced", "no_change"}: time.sleep(sync_interval) else: time.sleep(offline_interval) @@ -109,6 +81,39 @@ def _sync_loop(): time.sleep(60) +def sync_now(): + """Perform one immediate sync attempt.""" + global _SYNC_CONFIG_VERSION + + server_url, device_name, device_key = _server_credentials() + if not server_url or not device_name or not device_key: + return {"status": "not_configured", "message": "Remote server credentials are incomplete."} + if not _has_internet(): + return {"status": "offline", "message": "Internet is not currently reachable."} + + response = requests.get( + f"{server_url}/api/device-config", + headers={ + "X-Device-Name": device_name, + "X-Api-Key": device_key, + }, + timeout=5, + ) + response.raise_for_status() + config = response.json() + new_version = config.get("config_version", 0) + + if new_version != _SYNC_CONFIG_VERSION: + _SYNC_CONFIG_VERSION = new_version + _store_synced_config(config) + _acknowledge_sync(server_url, device_name, device_key, new_version) + _on_config_changed() + return {"status": "synced", "message": f"Synced config version {new_version}."} + + _acknowledge_sync(server_url, device_name, device_key, new_version) + return {"status": "no_change", "message": "Already up to date."} + + def _store_synced_config(config): """Store synced config in SQLite database.""" app_sql.set_setting("device_owner_name", config.get("owner_name", "")) @@ -131,6 +136,18 @@ def is_sync_enabled(): return bool(server_url) and bool(device_name) and bool(device_key) +def _acknowledge_sync(server_url, device_name, device_key, config_version): + requests.post( + f"{server_url}/api/v1/device-config/ack", + json={"config_version": config_version}, + headers={ + "X-Device-Name": device_name, + "X-Api-Key": device_key, + }, + timeout=5, + ).raise_for_status() + + def get_synced_config_version(): """Get the last-synced config version number.""" return int(app_sql.get_setting("device_config_version", "0")) diff --git a/modules/app_remote_config.py b/modules/app_remote_config.py new file mode 100644 index 0000000..634f33d --- /dev/null +++ b/modules/app_remote_config.py @@ -0,0 +1,154 @@ +"""Manage remote server credentials stored in db/upload_creds.env.""" + +import json +import os +from urllib.parse import urlparse + +import requests +from dotenv import dotenv_values + + +_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_CREDS_PATH = os.path.join(_BASE_DIR, "db", "upload_creds.env") +_ORDERED_KEYS = [ + "YARIS_SERVER_URL", + "YARIS_DEVICE_NAME", + "YARIS_API_KEY", + "YARIS_CELLULAR_ENABLED", + "YARIS_CELLULAR_APN", + "YARIS_CELLULAR_APN_USER", + "YARIS_CELLULAR_APN_PASS", +] + + +def server_credentials(): + values = _read_values() + return { + "server_url": values.get("YARIS_SERVER_URL", "").rstrip("/"), + "device_name": values.get("YARIS_DEVICE_NAME", ""), + "api_key": values.get("YARIS_API_KEY", ""), + } + + +def get_remote_config(): + credentials = server_credentials() + return { + "server_url": credentials["server_url"], + "device_name": credentials["device_name"], + "api_key_configured": bool(credentials["api_key"]), + } + + +def update_remote_config(body, errors): + if not isinstance(body, dict): + errors["error"] = "Invalid request body" + return errors + + values = _read_values() + + if "server_url" in body: + server_url = str(body["server_url"]).strip() + if server_url: + parsed = urlparse(server_url) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + errors["server_url"] = "Server URL must be a valid http(s) URL." + else: + values["YARIS_SERVER_URL"] = server_url.rstrip("/") + else: + values["YARIS_SERVER_URL"] = "" + + if "device_name" in body: + device_name = str(body["device_name"]).strip() + if device_name and len(device_name) > 100: + errors["remote_device_name"] = "Device name must be 1-100 characters." + else: + values["YARIS_DEVICE_NAME"] = device_name + + if body.get("clear_api_key"): + values["YARIS_API_KEY"] = "" + elif "api_key" in body: + api_key = str(body["api_key"]).strip() + if api_key: + if len(api_key) > 512: + errors["api_key"] = "API key must be 1-512 characters." + else: + values["YARIS_API_KEY"] = api_key + + if errors: + return errors + + _write_values(values) + return errors + + +def test_connection(): + credentials = server_credentials() + if not credentials["server_url"]: + return {"ok": False, "error": "Server URL is not configured."} + try: + health_response = requests.get( + f"{credentials['server_url']}/health", + timeout=5, + ) + health_response.raise_for_status() + except requests.RequestException as error: + return {"ok": False, "error": f"Could not reach server: {error}"} + + if not credentials["device_name"] or not credentials["api_key"]: + return { + "ok": True, + "message": "Server is reachable, but device credentials are not fully configured yet.", + "authenticated": False, + } + + try: + auth_response = requests.get( + f"{credentials['server_url']}/api/device-config", + headers=_headers(credentials), + timeout=5, + ) + except requests.RequestException as error: + return {"ok": False, "error": f"Server reachable but auth test failed: {error}"} + + if auth_response.status_code == 200: + return { + "ok": True, + "message": "Server reachable and device credentials are valid.", + "authenticated": True, + } + if auth_response.status_code == 401: + return { + "ok": False, + "error": "Server reachable, but device name or API key is invalid.", + } + return { + "ok": False, + "error": f"Server reachable, but config auth returned HTTP {auth_response.status_code}.", + } + + +def _headers(credentials): + return { + "X-Device-Name": credentials["device_name"], + "X-Api-Key": credentials["api_key"], + } + + +def _read_values(): + values = {key: "" for key in _ORDERED_KEYS} + parsed = dotenv_values(_CREDS_PATH) + for key, value in parsed.items(): + values[key] = value or "" + return values + + +def _write_values(values): + os.makedirs(os.path.dirname(_CREDS_PATH), exist_ok=True) + with open(_CREDS_PATH, "w", encoding="utf-8") as handle: + handle.write("##\n") + handle.write("## yaris-obd remote server upload client credentials\n") + handle.write("##\n") + handle.write("## Managed via the local Settings page. Real values stay local to this Pi.\n") + handle.write("##\n\n") + for key in _ORDERED_KEYS: + handle.write(f"{key} = {json.dumps(values.get(key, ''))}\n") diff --git a/modules/app_upload.py b/modules/app_upload.py index f5c11ed..324a5ac 100644 --- a/modules/app_upload.py +++ b/modules/app_upload.py @@ -41,17 +41,9 @@ import time from threading import Thread import requests -from dotenv import load_dotenv from requests_toolbelt import MultipartEncoder -from modules import app_sql, app_dash, app_cellular, app_video - -_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -load_dotenv(os.path.join(_BASE_DIR, "db", "upload_creds.env")) - -SERVER_URL = os.getenv("YARIS_SERVER_URL", "").rstrip("/") -DEVICE_NAME = os.getenv("YARIS_DEVICE_NAME", "") -API_KEY = os.getenv("YARIS_API_KEY", "") +from modules import app_sql, app_dash, app_cellular, app_remote_config, app_video UPLOAD_INTERVAL = 30 # seconds between upload attempts once caught up CATCHUP_INTERVAL = 1 # seconds between batches while draining a backlog @@ -75,12 +67,24 @@ _last_success_at = None _last_network_path = None # "wifi" or "cellular" - last path a batch actually went out on -def _headers(): - return {"X-Device-Name": DEVICE_NAME, "X-Api-Key": API_KEY} +def _credentials(): + return app_remote_config.server_credentials() + + +def _headers(credentials): + return { + "X-Device-Name": credentials["device_name"], + "X-Api-Key": credentials["api_key"], + } def is_configured(): - return bool(SERVER_URL and DEVICE_NAME and API_KEY) + credentials = _credentials() + return bool( + credentials["server_url"] + and credentials["device_name"] + and credentials["api_key"] + ) def is_dashcam_upload_enabled(): @@ -151,6 +155,7 @@ def _upload_tracking_batch(): rows = app_sql.get_unsent_tracking_rows(limit=TRACKING_BATCH_SIZE) if not rows: return 0 + credentials = _credentials() points = [ { @@ -168,9 +173,9 @@ def _upload_tracking_batch(): try: resp = requests.post( - f"{SERVER_URL}/api/v1/tracking/batch", + f"{credentials['server_url']}/api/v1/tracking/batch", json={"points": points}, - headers=_headers(), + headers=_headers(credentials), timeout=15, ) resp.raise_for_status() @@ -182,7 +187,9 @@ def _upload_tracking_batch(): body_bytes = json.dumps({"points": points}).encode() try: status_code, _body = app_cellular.http_post_json( - f"{SERVER_URL}/api/v1/tracking/batch", body_bytes, _headers() + f"{credentials['server_url']}/api/v1/tracking/batch", + body_bytes, + _headers(credentials), ) except app_cellular.CellularError as cell_err: # Neither path worked - raise the original WiFi error so the @@ -207,10 +214,11 @@ def _upload_video_chunked(abs_path, rel_path, sha256): re-query /status and resume from the first missing chunk instead of starting over. """ + credentials = _credentials() status_resp = requests.get( - f"{SERVER_URL}/api/v1/dashcam/upload/status", + f"{credentials['server_url']}/api/v1/dashcam/upload/status", params={"relative_path": rel_path, "sha256": sha256}, - headers=_headers(), + headers=_headers(credentials), timeout=15, ) status_resp.raise_for_status() @@ -241,9 +249,9 @@ def _upload_video_chunked(abs_path, rel_path, sha256): } ) resp = requests.post( - f"{SERVER_URL}/api/v1/dashcam/upload/chunk", + f"{credentials['server_url']}/api/v1/dashcam/upload/chunk", data=encoder, - headers={**_headers(), "Content-Type": encoder.content_type}, + headers={**_headers(credentials), "Content-Type": encoder.content_type}, timeout=120, ) resp.raise_for_status() diff --git a/readme.md b/readme.md index d4f718f..d3795fd 100644 --- a/readme.md +++ b/readme.md @@ -59,11 +59,12 @@ Dashboard β€” mobile (390 px) and desktop (1280 px): trip history with OSM/CARTO live map and route replay, dashcam archive browsing with inline video player and Range-request seeking, overview stats dashboard, delete buttons for trips and clips, and a device-settings page for editing the synced owner/car/avatar values that the Pi - pulls from the server. When the Pi has **Sync with server** enabled, those identity changes hot- - reload into the running local UI on the next successful sync instead of waiting for a manual - restart. Shared `history.js` code runs on both the Pi and the server β€” tile - source and zoom limits are configured per-deployment via HTML data attributes. Accessible at - `https://your-cloudflare-tunnel-url/` + pulls from the server. The portal can register multiple devices, reveal a one-time API key at + creation/rotation time, and request a sync for a selected device. When the Pi has + **Sync with server** enabled, those identity changes hot-reload into the running local UI on the + next successful sync instead of waiting for a manual restart. Shared `history.js` code runs on + both the Pi and the server β€” tile source and zoom limits are configured per-deployment via HTML + data attributes. Accessible at `https://your-cloudflare-tunnel-url/` - πŸ“‘ **Optional cellular fallback** through the SIM868 for small trip-log uploads when normal WiFi/Ethernet is unavailable (dashcam video stays off cellular) - πŸ—„οΈ **Motion-aware local-first SQLite logging** β€” five-second motion checks without five-second diff --git a/server/README.md b/server/README.md index beebd22..550e33f 100644 --- a/server/README.md +++ b/server/README.md @@ -68,7 +68,9 @@ so a permissive origin can't be used to steal a logged-in session. 5. Sign in to the remote portal and open `/settings/device` to edit the synced owner/car/device/avatar values for the configured `DEVICE_NAME`. If the Pi has local **Sync with server** enabled, those values hot-reload into the - running Pi UI after the next successful sync. + running Pi UI after the next successful sync. Phase 25.5c also adds a + multi-device list, one-time API-key reveal, key rotation, and a request-sync + action on that page. ### Upgrading an existing unraid deployment @@ -79,6 +81,7 @@ again automatically. After pulling new code, apply the new migrations manually: cd /mnt/user/app/git/yaris-obd/server docker compose exec db psql -U yarisobd -d yarisobd -f /docker-entrypoint-initdb.d/migrate_phase24.sql docker compose exec db psql -U yarisobd -d yarisobd -f /docker-entrypoint-initdb.d/migrate_phase25_5a.sql +docker compose exec db psql -U yarisobd -d yarisobd -f /docker-entrypoint-initdb.d/migrate_phase25_5c.sql docker compose up -d --build ``` @@ -99,8 +102,15 @@ docker compose exec api python3 scripts/create_device.py yaris-2013 This prints a one-time API key β€” copy the `YARIS_DEVICE_NAME` / `YARIS_API_KEY` values into the Pi's upload-client config. Only the -key's sha256 hash is stored server-side; the raw key is never shown again -(re-run the script with the same device name to rotate/replace the key). +key's sha256 hash is stored server-side; the raw key is never shown again. + +The browser portal at `/settings/device` can now do the same workflow without +shell access: + +- create a new device +- reveal a one-time API key +- rotate the API key later +- request a sync for the selected device ## Exposing it outside the LAN diff --git a/server/app.py b/server/app.py index b0d771c..3336df2 100644 --- a/server/app.py +++ b/server/app.py @@ -17,7 +17,7 @@ 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 +from modules import device_config, device_registry # ── Geocode cache ────────────────────────────────────────────────────────── try: @@ -630,6 +630,75 @@ def portal_trip_compat(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//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//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//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//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.get("/api/v1/portal/device-config") @require_login def portal_device_config_get(): @@ -660,6 +729,18 @@ def api_device_config_get(): 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(): diff --git a/server/docker-compose.yml b/server/docker-compose.yml index d4b6e57..3f6442f 100644 --- a/server/docker-compose.yml +++ b/server/docker-compose.yml @@ -12,6 +12,7 @@ services: - ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro - ./sql/migrate_phase24.sql:/docker-entrypoint-initdb.d/migrate_phase24.sql:ro - ./sql/migrate_phase25_5a.sql:/docker-entrypoint-initdb.d/migrate_phase25_5a.sql:ro + - ./sql/migrate_phase25_5c.sql:/docker-entrypoint-initdb.d/migrate_phase25_5c.sql:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-yarisobd}"] interval: 10s diff --git a/server/modules/db.py b/server/modules/db.py index 04b505f..ffbb234 100644 --- a/server/modules/db.py +++ b/server/modules/db.py @@ -48,10 +48,17 @@ def get_device_by_name(name): def get_device_by_id(device_id): - """Get device by ID, including config fields for Phase 25.5a.""" + """Get device by ID, including config + sync metadata.""" with get_cursor() as cur: cur.execute( - "SELECT id, name, api_key_hash, owner_name, car_model, device_name, avatar_path, config_version FROM devices WHERE id = %s", + """ + SELECT id, name, api_key_hash, owner_name, car_model, device_name, avatar_path, + config_version, config_updated_at, created_at, last_seen_at, + last_config_sync_at, last_config_sync_version, force_sync_requested_at, + api_key_rotated_at + FROM devices + WHERE id = %s + """, (device_id,), ) row = cur.fetchone() @@ -66,6 +73,13 @@ def get_device_by_id(device_id): "device_name": row[5], "avatar_path": row[6], "config_version": row[7], + "config_updated_at": row[8], + "created_at": row[9], + "last_seen_at": row[10], + "last_config_sync_at": row[11], + "last_config_sync_version": row[12], + "force_sync_requested_at": row[13], + "api_key_rotated_at": row[14], } @@ -77,6 +91,102 @@ def touch_device_last_seen(device_id): ) +def list_devices(): + with get_cursor() as cur: + cur.execute( + """ + SELECT id, name, owner_name, car_model, device_name, avatar_path, config_version, + config_updated_at, created_at, last_seen_at, last_config_sync_at, + last_config_sync_version, force_sync_requested_at, api_key_rotated_at + FROM devices + ORDER BY created_at ASC, id ASC + """ + ) + rows = cur.fetchall() + return [ + { + "id": row[0], + "name": row[1], + "owner_name": row[2], + "car_model": row[3], + "device_name": row[4], + "avatar_path": row[5], + "config_version": row[6], + "config_updated_at": row[7], + "created_at": row[8], + "last_seen_at": row[9], + "last_config_sync_at": row[10], + "last_config_sync_version": row[11], + "force_sync_requested_at": row[12], + "api_key_rotated_at": row[13], + } + for row in rows + ] + + +def create_device(name, api_key_hash): + with get_cursor(commit=True) as cur: + cur.execute( + """ + INSERT INTO devices (name, api_key_hash, api_key_rotated_at) + VALUES (%s, %s, now()) + RETURNING id + """, + (name, api_key_hash), + ) + row = cur.fetchone() + return row[0] + + +def rotate_device_api_key(device_id, api_key_hash): + with get_cursor(commit=True) as cur: + cur.execute( + """ + UPDATE devices + SET api_key_hash = %s, api_key_rotated_at = now() + WHERE id = %s + RETURNING id + """, + (api_key_hash, device_id), + ) + row = cur.fetchone() + return row[0] if row else None + + +def request_device_sync(device_id): + with get_cursor(commit=True) as cur: + cur.execute( + """ + UPDATE devices + SET config_version = config_version + 1, + config_updated_at = now(), + force_sync_requested_at = now() + WHERE id = %s + RETURNING config_version + """, + (device_id,), + ) + row = cur.fetchone() + return row[0] if row else None + + +def acknowledge_device_sync(device_id, config_version): + with get_cursor(commit=True) as cur: + cur.execute( + """ + UPDATE devices + SET last_config_sync_at = now(), + last_config_sync_version = %s, + force_sync_requested_at = CASE + WHEN config_version <= %s THEN NULL + ELSE force_sync_requested_at + END + WHERE id = %s + """, + (config_version, config_version, device_id), + ) + + def insert_tracking_points(device_id, points): """Bulk-insert tracking points, skipping any that already exist (device_id, recorded_at) so a client can safely re-send a batch after a diff --git a/server/modules/device_config.py b/server/modules/device_config.py index e0bff12..7c32f28 100644 --- a/server/modules/device_config.py +++ b/server/modules/device_config.py @@ -30,6 +30,8 @@ def update_device_config(device_id, data): Validates input and updates config_version on change. """ errors = {} + if db.get_device_by_id(device_id) is None: + return False, {"error": "Device not found."} if not isinstance(data, dict): return False, {"error": "Invalid request body"} @@ -87,4 +89,3 @@ def update_device_config(device_id, data): ) return True, {} - diff --git a/server/modules/device_registry.py b/server/modules/device_registry.py new file mode 100644 index 0000000..06b4423 --- /dev/null +++ b/server/modules/device_registry.py @@ -0,0 +1,71 @@ +"""Multi-device provisioning helpers for the remote portal.""" + +import hashlib +import re +import secrets + +from . import db + + +_DEVICE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$") + + +def list_devices(): + return db.list_devices() + + +def validate_device_name(name): + value = (name or "").strip() + if not value: + return None, "Device name is required." + if not _DEVICE_NAME_RE.fullmatch(value): + return None, "Device name must be 1-100 chars using letters, numbers, dot, underscore, or dash." + return value, None + + +def create_device(name): + value, error = validate_device_name(name) + if error: + return None, {"name": error} + if db.get_device_by_name(value) is not None: + return None, {"name": "A device with that name already exists."} + + raw_key = secrets.token_urlsafe(32) + device_id = db.create_device(value, _hash_key(raw_key)) + return { + "device": db.get_device_by_id(device_id), + "api_key": raw_key, + }, {} + + +def rotate_device_key(device_id): + device = db.get_device_by_id(device_id) + if device is None: + return None, {"error": "Device not found."} + + raw_key = secrets.token_urlsafe(32) + db.rotate_device_api_key(device_id, _hash_key(raw_key)) + return { + "device": db.get_device_by_id(device_id), + "api_key": raw_key, + }, {} + + +def request_sync(device_id): + config_version = db.request_device_sync(device_id) + if config_version is None: + return None + device = db.get_device_by_id(device_id) + return { + "device": device, + "message": "Sync requested. The Pi will pick it up on its next online contact, or immediately if Sync now is used locally.", + "config_version": config_version, + } + + +def acknowledge_sync(device_id, config_version): + db.acknowledge_device_sync(device_id, config_version) + + +def _hash_key(raw_key): + return hashlib.sha256(raw_key.encode("utf-8")).hexdigest() diff --git a/server/scripts/create_device.py b/server/scripts/create_device.py index 0f7dbc9..4d0cbc9 100644 --- a/server/scripts/create_device.py +++ b/server/scripts/create_device.py @@ -9,13 +9,11 @@ vars already configured by docker-compose, e.g.: The raw API key is only ever shown once here - copy it into the Pi's upload-client config immediately. Only its sha256 hash is stored server-side. """ -import hashlib -import secrets import sys sys.path.insert(0, "/app") -from modules import db # noqa: E402 +from modules import device_registry # noqa: E402 def main(): @@ -23,26 +21,16 @@ def main(): print(f"usage: {sys.argv[0]} ", file=sys.stderr) sys.exit(1) - device_name = sys.argv[1] - raw_key = secrets.token_urlsafe(32) - key_hash = hashlib.sha256(raw_key.encode("utf-8")).hexdigest() + result, errors = device_registry.create_device(sys.argv[1]) + if errors: + print(f"error: {errors}", file=sys.stderr) + sys.exit(1) - with db.get_cursor(commit=True) as cur: - cur.execute( - """ - INSERT INTO devices (name, api_key_hash) VALUES (%s, %s) - ON CONFLICT (name) DO UPDATE SET api_key_hash = EXCLUDED.api_key_hash - RETURNING id - """, - (device_name, key_hash), - ) - device_id = cur.fetchone()[0] - - print(f"Device '{device_name}' registered (id={device_id}).") + print(f"Device '{result['device']['name']}' registered (id={result['device']['id']}).") print() print("Save these on the Pi (e.g. in db/upload_creds.env, gitignored):") - print(f" YARIS_DEVICE_NAME={device_name}") - print(f" YARIS_API_KEY={raw_key}") + print(f" YARIS_DEVICE_NAME={result['device']['name']}") + print(f" YARIS_API_KEY={result['api_key']}") print() print("This key will not be shown again - only its hash is stored server-side.") diff --git a/server/sql/init.sql b/server/sql/init.sql index ebba7cb..63fc745 100644 --- a/server/sql/init.sql +++ b/server/sql/init.sql @@ -8,7 +8,17 @@ CREATE TABLE IF NOT EXISTS devices ( name TEXT NOT NULL UNIQUE, -- e.g. "yaris-2013" api_key_hash TEXT NOT NULL, -- sha256 hex digest of the device's API key, never the raw key created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - last_seen_at TIMESTAMPTZ + last_seen_at TIMESTAMPTZ, + owner_name TEXT DEFAULT '', + car_model TEXT DEFAULT '', + device_name TEXT DEFAULT '', + avatar_path TEXT DEFAULT 'img/yaris.png', + config_version INTEGER NOT NULL DEFAULT 0, + config_updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_config_sync_at TIMESTAMPTZ, + last_config_sync_version INTEGER, + force_sync_requested_at TIMESTAMPTZ, + api_key_rotated_at TIMESTAMPTZ ); CREATE TABLE IF NOT EXISTS tracking_points ( diff --git a/server/sql/migrate_phase25_5c.sql b/server/sql/migrate_phase25_5c.sql new file mode 100644 index 0000000..20d8b4b --- /dev/null +++ b/server/sql/migrate_phase25_5c.sql @@ -0,0 +1,10 @@ +-- Phase 25.5c migration: multi-device sync/provisioning metadata. + +ALTER TABLE devices ADD COLUMN IF NOT EXISTS last_config_sync_at TIMESTAMPTZ; +ALTER TABLE devices ADD COLUMN IF NOT EXISTS last_config_sync_version INTEGER; +ALTER TABLE devices ADD COLUMN IF NOT EXISTS force_sync_requested_at TIMESTAMPTZ; +ALTER TABLE devices ADD COLUMN IF NOT EXISTS api_key_rotated_at TIMESTAMPTZ; + +UPDATE devices +SET api_key_rotated_at = COALESCE(api_key_rotated_at, created_at) +WHERE api_key_rotated_at IS NULL; diff --git a/server/templates/device_settings.html b/server/templates/device_settings.html index a3ecafc..194611c 100644 --- a/server/templates/device_settings.html +++ b/server/templates/device_settings.html @@ -7,7 +7,7 @@

Device settings

- Edit the identity synced to the Pi for {{ configured_device_name }}. + Manage registered Pi devices, one-time API keys, and synced identity values.

+ + + + +
+
+
+
Selected device
+
+ + +
+
+
+
+
+
Device name
+
β€”
+
+
+
Portal default
+
β€”
+
+
+
Last seen
+
β€”
+
+
+
Last config sync
+
β€”
+
+
+
- +
- +
- - -
Used by the remote portal and future multi-device views.
+ + +
Friendly label shown in the portal/device UI.
- +
Relative to the shared static/ folder.
-
-
-
Sync behavior
+
Pi credential handoff
-
-

- The Pi picks these values up on its next successful internet-backed sync. +

+

+ Create or rotate a key, then paste these into the Pi Settings page under Remote server credentials.

-
    -
  • Expected propagation delay: up to ~5 minutes while online.
  • -
  • The Pi must have Sync with server enabled locally.
  • -
  • Offline cars keep the last synced config until connectivity returns.
  • -
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + Raw key is only shown immediately after create/rotate. +
-
Preview
+
Sync behavior
-
- Avatar preview -
Config version
-
β€”
-
Preview updates as you type.
+
+
    +
  • Request sync means β€œfetch this config on the Pi's next online contact”.
  • +
  • The car can still be offline; the remote server cannot push directly into it.
  • +
  • Use the Pi's local Sync now button for immediate pull attempts.
  • +
diff --git a/static/js/portal-device-settings.js b/static/js/portal-device-settings.js index 5b1d8e0..bd11ada 100644 --- a/static/js/portal-device-settings.js +++ b/static/js/portal-device-settings.js @@ -1,9 +1,26 @@ const form = document.getElementById('device-settings-form'); const saveButton = document.getElementById('device-settings-save'); const message = document.getElementById('device-settings-message'); -const configVersion = document.getElementById('config-version'); -const avatarPreview = document.getElementById('avatar-preview'); -const previewCaption = document.getElementById('preview-caption'); +const deviceList = document.getElementById('device-list'); +const deviceCount = document.getElementById('device-count'); +const createDeviceButton = document.getElementById('create-device-button'); +const newDeviceName = document.getElementById('new-device-name'); +const rotateKeyButton = document.getElementById('rotate-key-button'); +const requestSyncButton = document.getElementById('request-sync-button'); +const copyCredentialsButton = document.getElementById('copy-credentials-button'); + +const selectedSummary = { + name: document.getElementById('selected-device-name'), + portalDefault: document.getElementById('selected-device-default'), + lastSeen: document.getElementById('selected-device-last-seen'), + lastSync: document.getElementById('selected-device-last-sync'), +}; + +const credentialFields = { + serverUrl: document.getElementById('credential-server-url'), + deviceName: document.getElementById('credential-device-name'), + apiKey: document.getElementById('credential-api-key'), +}; const fields = { owner_name: document.getElementById('owner_name'), @@ -12,7 +29,10 @@ const fields = { avatar_path: document.getElementById('avatar_path'), }; +let devices = []; +let selectedDeviceId = null; let initialState = null; +let latestApiKey = ''; function showMessage(text, kind) { message.className = `alert alert-${kind}`; @@ -24,6 +44,11 @@ function hideMessage() { message.hidden = true; } +function formatDate(isoString) { + if (!isoString) return 'β€”'; + return new Date(isoString).toLocaleString(); +} + function currentState() { return { owner_name: fields.owner_name.value.trim(), @@ -33,89 +58,234 @@ function currentState() { }; } -function avatarUrl(path) { - if (!path) return '/static/img/yaris.png'; - if (/^https?:\/\//.test(path)) return path; - return `/static/${path.replace(/^\/+/, '')}`; -} - -function updatePreview() { - const state = currentState(); - avatarPreview.src = avatarUrl(state.avatar_path); - previewCaption.textContent = state.owner_name && state.car_model - ? `${state.owner_name} β€’ ${state.car_model}` - : 'Preview updates as you type.'; +function setFormEnabled(enabled) { + for (const field of Object.values(fields)) { + field.disabled = !enabled; + } + rotateKeyButton.disabled = !enabled; + requestSyncButton.disabled = !enabled; + updateDirtyState(); } function updateDirtyState() { - if (!initialState) { + if (!initialState || selectedDeviceId === null) { saveButton.disabled = true; return; } saveButton.disabled = JSON.stringify(currentState()) === JSON.stringify(initialState); } -function populate(config) { +function renderDeviceList() { + deviceCount.textContent = String(devices.length); + if (!devices.length) { + deviceList.innerHTML = '
No devices registered yet.
'; + selectedDeviceId = null; + setFormEnabled(false); + return; + } + + deviceList.innerHTML = devices.map(device => ` + + `).join(''); + + deviceList.querySelectorAll('[data-device-id]').forEach(button => { + button.addEventListener('click', () => selectDevice(Number(button.dataset.deviceId))); + }); +} + +function populateDevice(device, config) { + selectedSummary.name.textContent = device.name; + selectedSummary.portalDefault.textContent = device.portal_default ? 'Yes' : 'No'; + selectedSummary.lastSeen.textContent = formatDate(device.last_seen_at); + selectedSummary.lastSync.textContent = device.last_config_sync_at + ? `${formatDate(device.last_config_sync_at)} (v${device.last_config_sync_version ?? 'β€”'})` + : 'β€”'; + fields.owner_name.value = config.owner_name || ''; fields.car_model.value = config.car_model || ''; fields.device_name.value = config.device_name || ''; fields.avatar_path.value = config.avatar_path || ''; - configVersion.textContent = config.config_version ?? 'β€”'; initialState = currentState(); - updatePreview(); - updateDirtyState(); + credentialFields.deviceName.value = device.name; + renderDeviceList(); + setFormEnabled(true); } -async function loadConfig() { +async function loadDevices(preferredDeviceId = null) { + const response = await fetch('/api/v1/portal/devices'); + const payload = await response.json(); + if (!response.ok) { + throw new Error(payload.error || 'Could not load devices.'); + } + + devices = payload.devices || []; + credentialFields.serverUrl.value = window.location.origin; + + const nextDevice = preferredDeviceId && devices.some(device => device.id === preferredDeviceId) + ? preferredDeviceId + : selectedDeviceId && devices.some(device => device.id === selectedDeviceId) + ? selectedDeviceId + : devices[0]?.id ?? null; + + selectedDeviceId = nextDevice; + renderDeviceList(); + if (selectedDeviceId !== null) { + await loadSelectedDevice(); + } +} + +async function loadSelectedDevice() { + if (selectedDeviceId === null) { + return; + } + const response = await fetch(`/api/v1/portal/devices/${selectedDeviceId}/config`); + const payload = await response.json(); + if (!response.ok) { + throw new Error(payload.error || 'Could not load device config.'); + } + + const device = devices.find(candidate => candidate.id === selectedDeviceId) || payload.device; + populateDevice(device, payload.config); +} + +async function selectDevice(deviceId) { + hideMessage(); + selectedDeviceId = deviceId; + latestApiKey = ''; + credentialFields.apiKey.value = ''; + copyCredentialsButton.disabled = true; + renderDeviceList(); + await loadSelectedDevice(); +} + +async function createDevice() { + hideMessage(); + createDeviceButton.disabled = true; try { - const response = await fetch('/api/v1/portal/device-config'); + const response = await fetch('/api/v1/portal/devices', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newDeviceName.value.trim() }), + }); const payload = await response.json(); if (!response.ok) { - throw new Error(payload.error || 'Could not load device config.'); + throw new Error(payload.fields?.name || payload.error || 'Could not create device.'); } - populate(payload); + + latestApiKey = payload.api_key; + credentialFields.apiKey.value = payload.api_key; + copyCredentialsButton.disabled = false; + newDeviceName.value = ''; + await loadDevices(payload.device.id); + showMessage('Device created. Copy the credentials now; the raw API key will not be shown again.', 'success'); } catch (error) { showMessage(error.message, 'danger'); + } finally { + createDeviceButton.disabled = false; + } +} + +async function rotateKey() { + if (selectedDeviceId === null) return; + if (!window.confirm('Rotate the API key for this device? The old Pi config will stop authenticating until updated.')) return; + hideMessage(); + rotateKeyButton.disabled = true; + try { + const response = await fetch(`/api/v1/portal/devices/${selectedDeviceId}/rotate-key`, { + method: 'POST', + }); + const payload = await response.json(); + if (!response.ok) { + throw new Error(payload.error || 'Could not rotate API key.'); + } + latestApiKey = payload.api_key; + credentialFields.apiKey.value = payload.api_key; + copyCredentialsButton.disabled = false; + await loadDevices(selectedDeviceId); + showMessage('API key rotated. Copy the new credentials to the Pi now.', 'warning'); + } catch (error) { + showMessage(error.message, 'danger'); + } finally { + rotateKeyButton.disabled = false; + } +} + +async function requestSync() { + if (selectedDeviceId === null) return; + hideMessage(); + requestSyncButton.disabled = true; + try { + const response = await fetch(`/api/v1/portal/devices/${selectedDeviceId}/request-sync`, { + method: 'POST', + }); + const payload = await response.json(); + if (!response.ok) { + throw new Error(payload.error || 'Could not request sync.'); + } + await loadDevices(selectedDeviceId); + showMessage(payload.message, 'info'); + } catch (error) { + showMessage(error.message, 'danger'); + } finally { + requestSyncButton.disabled = false; } } async function saveConfig(event) { event.preventDefault(); + if (selectedDeviceId === null) return; hideMessage(); saveButton.disabled = true; - try { - const response = await fetch('/api/device-config', { + const response = await fetch(`/api/v1/portal/devices/${selectedDeviceId}/config`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(currentState()), }); const payload = await response.json(); if (!response.ok) { - const detail = payload.fields - ? Object.values(payload.fields).join(' ') - : (payload.error || 'Could not save device config.'); - throw new Error(detail); + throw new Error(payload.error || 'Could not save device config.'); } - populate(payload); - showMessage('Device settings saved. The Pi will pick them up on its next sync.', 'success'); + await loadDevices(selectedDeviceId); + showMessage('Device settings saved.', 'success'); } catch (error) { showMessage(error.message, 'danger'); updateDirtyState(); } } +async function copyCredentials() { + if (!latestApiKey || selectedDeviceId === null) return; + const text = [ + `YARIS_SERVER_URL=${window.location.origin}`, + `YARIS_DEVICE_NAME=${credentialFields.deviceName.value}`, + `YARIS_API_KEY=${latestApiKey}`, + ].join('\n'); + await navigator.clipboard.writeText(text); + showMessage('Credentials copied. Paste them into the Pi Settings page.', 'success'); +} + for (const field of Object.values(fields)) { field.addEventListener('input', () => { hideMessage(); - updatePreview(); updateDirtyState(); }); } -avatarPreview.addEventListener('error', () => { - previewCaption.textContent = 'Preview image not found on the server static path.'; +form.addEventListener('submit', saveConfig); +createDeviceButton.addEventListener('click', createDevice); +rotateKeyButton.addEventListener('click', rotateKey); +requestSyncButton.addEventListener('click', requestSync); +copyCredentialsButton.addEventListener('click', () => { + copyCredentials().catch(error => showMessage(`Could not copy credentials: ${error.message}`, 'danger')); }); -form.addEventListener('submit', saveConfig); -loadConfig(); +loadDevices().catch(error => showMessage(error.message, 'danger')); diff --git a/static/js/settings.js b/static/js/settings.js index 9db825b..8d57987 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -8,6 +8,9 @@ const phonePassword = document.getElementById('phone-hotspot-password'); const phoneSave = document.getElementById('phone-hotspot-save'); const phoneConnect = document.getElementById('phone-hotspot-connect'); const phoneRemove = document.getElementById('phone-hotspot-remove'); +const remoteConfigMessage = document.getElementById('remote-config-message'); +const remoteConfigTestButton = document.getElementById('remote-config-test'); +const remoteConfigSyncNowButton = document.getElementById('remote-config-sync-now'); const lastPhoneAddress = document.getElementById('last-phone-address'); const lastPhoneAddressLink = document.getElementById('last-phone-address-link'); let initialUplinkStatusLoaded = false; @@ -35,6 +38,10 @@ const fields = { deviceCarModel: document.getElementById('device-car-model'), deviceAvatarPath: document.getElementById('device-avatar-path'), deviceName: document.getElementById('device-name'), + remoteServerUrl: document.getElementById('remote-server-url'), + remoteDeviceName: document.getElementById('remote-device-name'), + remoteApiKey: document.getElementById('remote-api-key'), + clearRemoteApiKey: document.getElementById('clear-remote-api-key'), }; function showMessage(text, kind) { @@ -49,6 +56,12 @@ function showUplinkMessage(text, kind) { uplinkMessage.hidden = false; } +function showRemoteConfigMessage(text, kind) { + remoteConfigMessage.textContent = text; + remoteConfigMessage.className = `alert alert-${kind} py-2 mb-3`; + remoteConfigMessage.hidden = false; +} + function setSaving(disabled) { saveButtons.forEach(button => { button.disabled = disabled; @@ -78,6 +91,9 @@ function applyErrors(errors) { car_model: fields.deviceCarModel, avatar_path: fields.deviceAvatarPath, device_name: fields.deviceName, + server_url: fields.remoteServerUrl, + remote_device_name: fields.remoteDeviceName, + api_key: fields.remoteApiKey, }; Object.entries(errors).forEach(([name, text]) => { const input = errorElements[name]; @@ -123,6 +139,16 @@ function populateDeviceConfig(deviceConfig) { updateDeviceConfigFieldsState(); } +function populateRemoteConfig(remoteConfig) { + fields.remoteServerUrl.value = remoteConfig.server_url || ''; + fields.remoteDeviceName.value = remoteConfig.device_name || ''; + fields.remoteApiKey.value = ''; + fields.clearRemoteApiKey.checked = false; + document.getElementById('remote-api-key-state').textContent = remoteConfig.api_key_configured + ? 'An API key is saved. Leave blank to keep it unchanged.' + : 'No API key is saved yet.'; +} + function updateDeviceConfigFieldsState() { const syncEnabled = fields.deviceSyncEnabled.checked; const configFields = [fields.deviceOwnerName, fields.deviceCarModel, fields.deviceAvatarPath, fields.deviceName]; @@ -165,6 +191,15 @@ function deviceConfigPayload() { }; } +function remoteConfigPayload() { + return { + server_url: fields.remoteServerUrl.value, + device_name: fields.remoteDeviceName.value, + api_key: fields.remoteApiKey.value, + clear_api_key: fields.clearRemoteApiKey.checked, + }; +} + async function loadUploadHealth() { const element = document.getElementById('upload-health'); try { @@ -321,12 +356,54 @@ async function loadDeviceConfig() { } } +async function loadRemoteConfig() { + try { + const response = await fetch('/api/remote-config'); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + populateRemoteConfig(await response.json()); + } catch (error) { + showMessage(`Could not load remote config: ${error.message}`, 'danger'); + } +} + fields.deviceSyncEnabled.addEventListener('change', updateDeviceConfigFieldsState); +remoteConfigTestButton.addEventListener('click', async () => { + remoteConfigMessage.hidden = true; + remoteConfigTestButton.disabled = true; + try { + const response = await fetch('/api/remote-config/test', {method: 'POST'}); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || `HTTP ${response.status}`); + showRemoteConfigMessage(result.message || 'Remote server connection looks good.', 'success'); + } catch (error) { + showRemoteConfigMessage(error.message, 'danger'); + } finally { + remoteConfigTestButton.disabled = false; + } +}); + +remoteConfigSyncNowButton.addEventListener('click', async () => { + remoteConfigMessage.hidden = true; + remoteConfigSyncNowButton.disabled = true; + try { + const response = await fetch('/api/remote-config/sync-now', {method: 'POST'}); + const result = await response.json(); + if (!response.ok) throw new Error(result.message || `HTTP ${response.status}`); + await loadDeviceConfig(); + showRemoteConfigMessage(result.message || 'Sync completed.', 'success'); + } catch (error) { + showRemoteConfigMessage(error.message, 'danger'); + } finally { + remoteConfigSyncNowButton.disabled = false; + } +}); + form.addEventListener('submit', async event => { event.preventDefault(); clearErrors(); message.hidden = true; + remoteConfigMessage.hidden = true; setSaving(true); try { // Save main settings @@ -341,7 +418,20 @@ form.addEventListener('submit', async event => { throw new Error(result.error || `HTTP ${response.status}`); } populate(result); - + + // Save remote server credentials before device sync toggle validation + const remoteConfigResponse = await fetch('/api/remote-config', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(remoteConfigPayload()), + }); + const remoteConfigResult = await remoteConfigResponse.json(); + if (!remoteConfigResponse.ok) { + applyErrors(remoteConfigResult.fields || {}); + throw new Error(remoteConfigResult.error || `HTTP ${remoteConfigResponse.status}`); + } + populateRemoteConfig(remoteConfigResult); + // Save device config const deviceConfigResponse = await fetch('/api/device-config', { method: 'POST', @@ -365,6 +455,7 @@ form.addEventListener('submit', async event => { loadSettings(); loadDeviceConfig(); +loadRemoteConfig(); loadUploadHealth(); loadUplinkStatus(); setInterval(loadUplinkStatus, 15000); diff --git a/templates/settings.html b/templates/settings.html index 3892ddb..f962248 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -61,6 +61,45 @@
+ {# ── Remote server credentials ────────────────────────────────────── #} +
+
+

Remote server credentials

+
+
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
Leave blank to keep saved key.
+
+ + +
+
+
+ +
+ + +
+

+ These values are stored only on this Pi in db/upload_creds.env and are reused by both uploads and config sync. +

+
+
+ {# ── Upload & retention ──────────────────────────────────────────── #}
diff --git a/tests/test_app_device_config_runtime.py b/tests/test_app_device_config_runtime.py index aa7e2b4..a7f0d89 100644 --- a/tests/test_app_device_config_runtime.py +++ b/tests/test_app_device_config_runtime.py @@ -1,5 +1,4 @@ import unittest -import os from unittest.mock import Mock, patch from modules import app_account, app_car, app_device_sync @@ -56,16 +55,13 @@ class DeviceSyncReloadCallbackTest(unittest.TestCase): finally: app_device_sync._RELOAD_CALLBACK = original - @patch.dict( - os.environ, - { - "YARIS_SERVER_URL": "https://example.test", - "YARIS_DEVICE_NAME": "yaris-2013", - "YARIS_API_KEY": "upload-key", - }, - clear=True, - ) - def test_sync_credentials_fall_back_to_upload_api_key(self): + @patch("modules.app_remote_config.server_credentials") + def test_sync_credentials_fall_back_to_upload_api_key(self, server_credentials): + server_credentials.return_value = { + "server_url": "https://example.test", + "device_name": "yaris-2013", + "api_key": "upload-key", + } server_url, device_name, device_key = app_device_sync._server_credentials() self.assertEqual(server_url, "https://example.test") diff --git a/tests/test_app_remote_config.py b/tests/test_app_remote_config.py new file mode 100644 index 0000000..ac3b5cf --- /dev/null +++ b/tests/test_app_remote_config.py @@ -0,0 +1,159 @@ +import os +import tempfile +import unittest +from unittest.mock import patch + +from modules import app_device_config, app_device_sync, app_remote_config + + +class RemoteConfigTest(unittest.TestCase): + def setUp(self): + self.tmpdir = tempfile.TemporaryDirectory() + self.creds_path = os.path.join(self.tmpdir.name, "upload_creds.env") + + def tearDown(self): + self.tmpdir.cleanup() + + def test_update_and_read_remote_config(self): + with patch.object(app_remote_config, "_CREDS_PATH", self.creds_path): + errors = {} + app_remote_config.update_remote_config( + { + "server_url": "https://example.test/", + "device_name": "yaris-2013", + "api_key": "secret-key", + }, + errors, + ) + + self.assertEqual(errors, {}) + config = app_remote_config.get_remote_config() + self.assertEqual(config["server_url"], "https://example.test") + self.assertEqual(config["device_name"], "yaris-2013") + self.assertTrue(config["api_key_configured"]) + + def test_blank_api_key_preserves_existing_until_cleared(self): + with patch.object(app_remote_config, "_CREDS_PATH", self.creds_path): + app_remote_config.update_remote_config( + { + "server_url": "https://example.test", + "device_name": "yaris-2013", + "api_key": "secret-key", + }, + {}, + ) + + errors = {} + app_remote_config.update_remote_config( + { + "server_url": "https://example.test", + "device_name": "yaris-2013", + "api_key": "", + "clear_api_key": False, + }, + errors, + ) + self.assertEqual(errors, {}) + self.assertTrue(app_remote_config.get_remote_config()["api_key_configured"]) + + app_remote_config.update_remote_config( + {"clear_api_key": True}, + {}, + ) + self.assertFalse(app_remote_config.get_remote_config()["api_key_configured"]) + + @patch("modules.app_remote_config.requests.get") + def test_test_connection_checks_health_and_device_auth(self, requests_get): + class Response: + def __init__(self, status_code): + self.status_code = status_code + + def raise_for_status(self): + if self.status_code >= 400: + raise RuntimeError("bad status") + + requests_get.side_effect = [Response(200), Response(200)] + with patch.object(app_remote_config, "_CREDS_PATH", self.creds_path): + app_remote_config.update_remote_config( + { + "server_url": "https://example.test", + "device_name": "yaris-2013", + "api_key": "secret-key", + }, + {}, + ) + + result = app_remote_config.test_connection() + + self.assertTrue(result["ok"]) + self.assertTrue(result["authenticated"]) + self.assertEqual(requests_get.call_count, 2) + + +class DeviceConfigSyncPrereqTest(unittest.TestCase): + @patch("modules.app_remote_config.get_remote_config") + def test_sync_enable_requires_remote_credentials(self, get_remote_config): + get_remote_config.return_value = { + "server_url": "", + "device_name": "", + "api_key_configured": False, + } + errors = {} + + with patch("modules.app_device_config._get_sync_enabled", return_value=False): + app_device_config.update_device_config({"sync_enabled": True}, errors) + + self.assertIn("sync_enabled", errors) + + +class DeviceSyncNowTest(unittest.TestCase): + @patch("modules.app_device_sync._has_internet", return_value=True) + @patch("modules.app_device_sync._acknowledge_sync") + @patch("modules.app_device_sync._on_config_changed") + @patch("modules.app_device_sync._store_synced_config") + @patch("modules.app_device_sync.requests.get") + @patch("modules.app_remote_config.server_credentials") + def test_sync_now_fetches_and_applies_config( + self, + server_credentials, + requests_get, + store_synced_config, + on_config_changed, + acknowledge_sync, + _has_internet, + ): + class Response: + status_code = 200 + + def raise_for_status(self): + return None + + def json(self): + return {"owner_name": "alice", "config_version": 4} + + server_credentials.return_value = { + "server_url": "https://example.test", + "device_name": "yaris-2013", + "api_key": "secret-key", + } + requests_get.return_value = Response() + original_version = app_device_sync._SYNC_CONFIG_VERSION + app_device_sync._SYNC_CONFIG_VERSION = 0 + try: + result = app_device_sync.sync_now() + finally: + app_device_sync._SYNC_CONFIG_VERSION = original_version + + self.assertEqual(result["status"], "synced") + store_synced_config.assert_called_once() + on_config_changed.assert_called_once() + acknowledge_sync.assert_called_once_with( + "https://example.test", + "yaris-2013", + "secret-key", + 4, + ) + + +if __name__ == "__main__": + unittest.main()