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 <[email protected]>
This commit is contained in:
Mathieu Moran
2026-08-10 18:02:09 -04:00
co-authored by Copilot
parent fbc41a5db5
commit ba682fbf3a
23 changed files with 1357 additions and 167 deletions
+160 -4
View File
@@ -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
+32 -1
View File
@@ -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:
+8
View File
@@ -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
+9
View File
@@ -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
+44 -27
View File
@@ -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,19 +65,32 @@ 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():
result = sync_now()
if result["status"] in {"synced", "no_change"}:
time.sleep(sync_interval)
else:
time.sleep(offline_interval)
except Exception as e:
print(f"Device config sync error: {e}")
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={
@@ -91,22 +99,19 @@ def _sync_loop():
},
timeout=5,
)
if response.status_code == 200:
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}."}
time.sleep(sync_interval)
else:
time.sleep(offline_interval)
except Exception as e:
print(f"Device config sync error: {e}")
time.sleep(60)
_acknowledge_sync(server_url, device_name, device_key, new_version)
return {"status": "no_change", "message": "Already up to date."}
def _store_synced_config(config):
@@ -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"))
+154
View File
@@ -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")
+27 -19
View File
@@ -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()
+6 -5
View File
@@ -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
+13 -3
View File
@@ -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
+82 -1
View File
@@ -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/<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.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():
+1
View File
@@ -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
+112 -2
View File
@@ -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
+2 -1
View File
@@ -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, {}
+71
View File
@@ -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()
+8 -20
View File
@@ -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]} <device-name>", 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.")
+11 -1
View File
@@ -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 (
+10
View File
@@ -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;
+95 -26
View File
@@ -7,7 +7,7 @@
<div>
<h3 class="text-dark mb-1 fw-bold">Device settings</h3>
<p class="text-muted mb-0">
Edit the identity synced to the Pi for <code>{{ configured_device_name }}</code>.
Manage registered Pi devices, one-time API keys, and synced identity values.
</p>
</div>
<button class="btn btn-primary" id="device-settings-save" type="submit" form="device-settings-form" disabled>
@@ -18,64 +18,133 @@
<div id="device-settings-message" class="alert" role="status" hidden></div>
<div class="row g-3">
<div class="col-12 col-xl-8">
<div class="col-12 col-xl-4">
<div class="card shadow-sm mb-3">
<div class="card-header py-2 d-flex align-items-center justify-content-between">
<h6 class="text-primary fw-bold m-0">Registered devices</h6>
<span class="badge bg-secondary" id="device-count">0</span>
</div>
<div class="list-group list-group-flush" id="device-list">
<div class="list-group-item text-muted small">Loading devices…</div>
</div>
</div>
<div class="card shadow-sm">
<div class="card-header py-2">
<h6 class="text-primary fw-bold m-0">Synced identity</h6>
<h6 class="text-primary fw-bold m-0">Register new device</h6>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label" for="new-device-name">Device name</label>
<input class="form-control" id="new-device-name" maxlength="100" placeholder="yaris-2013">
<div class="form-text">This becomes <code>YARIS_DEVICE_NAME</code> on the Pi.</div>
</div>
<button class="btn btn-outline-primary" id="create-device-button" type="button">
<i class="fas fa-plus me-1"></i>Create device + API key
</button>
</div>
</div>
</div>
<div class="col-12 col-xl-8">
<div class="card shadow-sm mb-3">
<div class="card-header py-2 d-flex flex-wrap align-items-center justify-content-between gap-2">
<h6 class="text-primary fw-bold m-0">Selected device</h6>
<div class="d-flex flex-wrap gap-2">
<button class="btn btn-outline-secondary btn-sm" id="rotate-key-button" type="button" disabled>
<i class="fas fa-key me-1"></i>Rotate API key
</button>
<button class="btn btn-outline-primary btn-sm" id="request-sync-button" type="button" disabled>
<i class="fas fa-rotate me-1"></i>Request sync
</button>
</div>
</div>
<div class="card-body">
<div class="row g-3 mb-3 small">
<div class="col-md-6">
<div class="text-muted">Device name</div>
<div class="fw-semibold" id="selected-device-name">—</div>
</div>
<div class="col-md-6">
<div class="text-muted">Portal default</div>
<div class="fw-semibold" id="selected-device-default">—</div>
</div>
<div class="col-md-6">
<div class="text-muted">Last seen</div>
<div class="fw-semibold" id="selected-device-last-seen">—</div>
</div>
<div class="col-md-6">
<div class="text-muted">Last config sync</div>
<div class="fw-semibold" id="selected-device-last-sync">—</div>
</div>
</div>
<form id="device-settings-form" novalidate>
<div class="row g-3">
<div class="col-12 col-md-6">
<label class="form-label" for="owner_name">Owner name</label>
<input class="form-control" id="owner_name" name="owner_name" maxlength="100" required>
<input class="form-control" id="owner_name" name="owner_name" maxlength="100" required disabled>
</div>
<div class="col-12 col-md-6">
<label class="form-label" for="car_model">Car model</label>
<input class="form-control" id="car_model" name="car_model" maxlength="100" required>
<input class="form-control" id="car_model" name="car_model" maxlength="100" required disabled>
</div>
<div class="col-12 col-md-6">
<label class="form-label" for="device_name">Device name</label>
<input class="form-control" id="device_name" name="device_name" maxlength="100">
<div class="form-text">Used by the remote portal and future multi-device views.</div>
<label class="form-label" for="device_name">Display device name</label>
<input class="form-control" id="device_name" name="device_name" maxlength="100" disabled>
<div class="form-text">Friendly label shown in the portal/device UI.</div>
</div>
<div class="col-12 col-md-6">
<label class="form-label" for="avatar_path">Avatar path</label>
<input class="form-control" id="avatar_path" name="avatar_path" maxlength="255" placeholder="img/yaris.png">
<input class="form-control" id="avatar_path" name="avatar_path" maxlength="255" placeholder="img/yaris.png" disabled>
<div class="form-text">Relative to the shared <code>static/</code> folder.</div>
</div>
</div>
</form>
</div>
</div>
</div>
<div class="col-12 col-xl-4">
<div class="card shadow-sm mb-3">
<div class="card-header py-2">
<h6 class="text-primary fw-bold m-0">Sync behavior</h6>
<h6 class="text-primary fw-bold m-0">Pi credential handoff</h6>
</div>
<div class="card-body small text-muted">
<p class="mb-2">
The Pi picks these values up on its next successful internet-backed sync.
<div class="card-body">
<p class="small text-muted mb-3">
Create or rotate a key, then paste these into the Pi Settings page under Remote server credentials.
</p>
<ul class="mb-0 ps-3">
<li>Expected propagation delay: up to ~5 minutes while online.</li>
<li>The Pi must have <strong>Sync with server</strong> enabled locally.</li>
<li>Offline cars keep the last synced config until connectivity returns.</li>
</ul>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label" for="credential-server-url">Server URL</label>
<input class="form-control" id="credential-server-url" readonly>
</div>
<div class="col-md-4">
<label class="form-label" for="credential-device-name">Device name</label>
<input class="form-control" id="credential-device-name" readonly>
</div>
<div class="col-md-4">
<label class="form-label" for="credential-api-key">API key</label>
<input class="form-control" id="credential-api-key" readonly placeholder="Shown only after create / rotate">
</div>
</div>
<div class="d-flex flex-wrap gap-2 mt-3">
<button class="btn btn-outline-secondary btn-sm" id="copy-credentials-button" type="button" disabled>
<i class="fas fa-copy me-1"></i>Copy credentials
</button>
<span class="small text-muted align-self-center">Raw key is only shown immediately after create/rotate.</span>
</div>
</div>
</div>
<div class="card shadow-sm">
<div class="card-header py-2">
<h6 class="text-primary fw-bold m-0">Preview</h6>
<h6 class="text-primary fw-bold m-0">Sync behavior</h6>
</div>
<div class="card-body text-center">
<img id="avatar-preview" alt="Avatar preview" class="rounded-circle border mb-3" src="{{ url_for('static', filename='img/yaris.png') }}" style="width:96px;height:96px;object-fit:cover;">
<div class="small text-muted mb-1">Config version</div>
<div class="fw-bold fs-5" id="config-version">—</div>
<div class="small text-muted mt-3" id="preview-caption">Preview updates as you type.</div>
<div class="card-body small text-muted">
<ul class="mb-0 ps-3">
<li><strong>Request sync</strong> means “fetch this config on the Pi's next online contact”.</li>
<li>The car can still be offline; the remote server cannot push directly into it.</li>
<li>Use the Pi's local <strong>Sync now</strong> button for immediate pull attempts.</li>
</ul>
</div>
</div>
</div>
+207 -37
View File
@@ -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 = '<div class="list-group-item text-muted small">No devices registered yet.</div>';
selectedDeviceId = null;
setFormEnabled(false);
return;
}
deviceList.innerHTML = devices.map(device => `
<button type="button" class="list-group-item list-group-item-action${device.id === selectedDeviceId ? ' active' : ''}" data-device-id="${device.id}">
<div class="d-flex w-100 justify-content-between align-items-start gap-2">
<div class="text-start">
<div class="fw-semibold">${device.device_name || device.name}</div>
<div class="small ${device.id === selectedDeviceId ? 'text-white-50' : 'text-muted'}">${device.name}</div>
</div>
${device.portal_default ? '<span class="badge text-bg-warning">Default</span>' : ''}
</div>
</button>
`).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() {
try {
const response = await fetch('/api/v1/portal/device-config');
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.');
}
populate(payload);
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/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.fields?.name || payload.error || 'Could not create device.');
}
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'));
+91
View File
@@ -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
@@ -342,6 +419,19 @@ form.addEventListener('submit', async event => {
}
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);
+39
View File
@@ -61,6 +61,45 @@
</div>
</div>
{# ── Remote server credentials ────────────────────────────────────── #}
<div class="card shadow-sm mb-3">
<div class="card-header py-2">
<p class="text-primary m-0 fw-bold small">Remote server credentials</p>
</div>
<div class="card-body py-3">
<div class="row g-3 mb-3">
<div class="col-md-4">
<label class="form-label form-label-sm" for="remote-server-url">Server URL</label>
<input class="form-control form-control-sm" id="remote-server-url" type="url" placeholder="https://your-server.example">
<div class="invalid-feedback" data-error-for="server_url"></div>
</div>
<div class="col-md-4">
<label class="form-label form-label-sm" for="remote-device-name">Device name</label>
<input class="form-control form-control-sm" id="remote-device-name" maxlength="100" placeholder="yaris-2013">
<div class="invalid-feedback" data-error-for="remote_device_name"></div>
</div>
<div class="col-md-4">
<label class="form-label form-label-sm" for="remote-api-key">API key</label>
<input class="form-control form-control-sm" id="remote-api-key" type="password" maxlength="512" autocomplete="new-password" placeholder="Paste after create/rotate">
<div class="invalid-feedback" data-error-for="api_key"></div>
<div class="form-text" id="remote-api-key-state">Leave blank to keep saved key.</div>
<div class="form-check mt-1">
<input class="form-check-input" type="checkbox" id="clear-remote-api-key">
<label class="form-check-label small" for="clear-remote-api-key">Remove saved API key</label>
</div>
</div>
</div>
<div id="remote-config-message" class="alert py-2 mb-3" role="status" hidden></div>
<div class="d-flex flex-wrap gap-2 mb-2">
<button class="btn btn-sm btn-outline-primary" id="remote-config-test" type="button">Test connection</button>
<button class="btn btn-sm btn-outline-secondary" id="remote-config-sync-now" type="button">Sync now</button>
</div>
<p class="form-text small mb-0">
These values are stored only on this Pi in <code>db/upload_creds.env</code> and are reused by both uploads and config sync.
</p>
</div>
</div>
{# ── Upload & retention ──────────────────────────────────────────── #}
<div class="card shadow-sm mb-3">
<div class="card-header py-2">
+7 -11
View File
@@ -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")
+159
View File
@@ -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()