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:
@@ -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)
|
### Phase 25.5c: Multi-device provisioning + force-sync UX — 🚧 PLANNED
|
||||||
Revives the phone-relay goal (Phase 11/12) properly this time, using a real native app instead of
|
Extend the current single-device portal page into a real multi-device management
|
||||||
a web page - the only way to actually hold a WiFi connection to the Pi's hotspot and a cellular
|
surface: register devices from the server, mint/rotate API keys, help the Pi
|
||||||
connection to the internet open simultaneously (`ConnectivityManager.bindProcessToNetwork()`,
|
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
|
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
|
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
|
test are done. Permanent vehicle installation remains owner-handled and is not an implementation
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from flask import Flask, render_template, make_response, json, jsonify, request, send_file, Response
|
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
|
import os, time
|
||||||
from gevent.pywsgi import WSGIServer
|
from gevent.pywsgi import WSGIServer
|
||||||
|
|
||||||
@@ -64,6 +64,37 @@ def api_device_config():
|
|||||||
_reload_runtime_device_config()
|
_reload_runtime_device_config()
|
||||||
return jsonify(app_device_config.get_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')
|
@app.route('/api/uplink/status')
|
||||||
def uplink_status():
|
def uplink_status():
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -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
|
server pushes new identity values, the running Pi UI hot-reloads them on the next
|
||||||
successful sync.
|
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:
|
After editing, restart the app:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -72,9 +72,18 @@ def update_device_config(body, errors):
|
|||||||
if "sync_enabled" in body:
|
if "sync_enabled" in body:
|
||||||
if not isinstance(body["sync_enabled"], bool):
|
if not isinstance(body["sync_enabled"], bool):
|
||||||
errors["sync_enabled"] = "Must be true or false."
|
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:
|
else:
|
||||||
_set_sync_enabled(body["sync_enabled"])
|
_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
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+44
-27
@@ -10,18 +10,14 @@ import threading
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
import requests
|
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_THREAD = None
|
||||||
_SYNC_STOP = False
|
_SYNC_STOP = False
|
||||||
_SYNC_CONFIG_VERSION = 0 # Track last-synced version to detect changes
|
_SYNC_CONFIG_VERSION = 0 # Track last-synced version to detect changes
|
||||||
_RELOAD_CALLBACK = None
|
_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():
|
def _has_internet():
|
||||||
@@ -39,16 +35,15 @@ def _has_internet():
|
|||||||
|
|
||||||
|
|
||||||
def _server_credentials():
|
def _server_credentials():
|
||||||
server_url = os.getenv("YARIS_SERVER_URL", "").rstrip("/")
|
credentials = app_remote_config.server_credentials()
|
||||||
device_name = os.getenv("YARIS_DEVICE_NAME") or os.getenv("YARIS_UPLOAD_DEVICE_NAME", "")
|
device_key = os.getenv("YARIS_DEVICE_KEY") or credentials["api_key"]
|
||||||
device_key = os.getenv("YARIS_DEVICE_KEY") or os.getenv("YARIS_API_KEY", "")
|
return credentials["server_url"], credentials["device_name"], device_key
|
||||||
return server_url, device_name, device_key
|
|
||||||
|
|
||||||
|
|
||||||
def start_sync():
|
def start_sync():
|
||||||
"""Start background device config sync thread."""
|
"""Start background device config sync thread."""
|
||||||
global _SYNC_THREAD, _SYNC_STOP
|
global _SYNC_THREAD, _SYNC_STOP
|
||||||
if _SYNC_THREAD is not None:
|
if _SYNC_THREAD is not None and _SYNC_THREAD.is_alive():
|
||||||
return
|
return
|
||||||
|
|
||||||
_SYNC_STOP = False
|
_SYNC_STOP = False
|
||||||
@@ -70,19 +65,32 @@ def stop_sync():
|
|||||||
|
|
||||||
def _sync_loop():
|
def _sync_loop():
|
||||||
"""Periodically sync device config from server when internet available."""
|
"""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
|
sync_interval = 300
|
||||||
offline_interval = 60
|
offline_interval = 60
|
||||||
|
|
||||||
while not _SYNC_STOP:
|
while not _SYNC_STOP:
|
||||||
try:
|
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(
|
response = requests.get(
|
||||||
f"{server_url}/api/device-config",
|
f"{server_url}/api/device-config",
|
||||||
headers={
|
headers={
|
||||||
@@ -91,22 +99,19 @@ def _sync_loop():
|
|||||||
},
|
},
|
||||||
timeout=5,
|
timeout=5,
|
||||||
)
|
)
|
||||||
|
response.raise_for_status()
|
||||||
if response.status_code == 200:
|
|
||||||
config = response.json()
|
config = response.json()
|
||||||
new_version = config.get("config_version", 0)
|
new_version = config.get("config_version", 0)
|
||||||
|
|
||||||
if new_version != _SYNC_CONFIG_VERSION:
|
if new_version != _SYNC_CONFIG_VERSION:
|
||||||
_SYNC_CONFIG_VERSION = new_version
|
_SYNC_CONFIG_VERSION = new_version
|
||||||
_store_synced_config(config)
|
_store_synced_config(config)
|
||||||
|
_acknowledge_sync(server_url, device_name, device_key, new_version)
|
||||||
_on_config_changed()
|
_on_config_changed()
|
||||||
|
return {"status": "synced", "message": f"Synced config version {new_version}."}
|
||||||
|
|
||||||
time.sleep(sync_interval)
|
_acknowledge_sync(server_url, device_name, device_key, new_version)
|
||||||
else:
|
return {"status": "no_change", "message": "Already up to date."}
|
||||||
time.sleep(offline_interval)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Device config sync error: {e}")
|
|
||||||
time.sleep(60)
|
|
||||||
|
|
||||||
|
|
||||||
def _store_synced_config(config):
|
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)
|
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():
|
def get_synced_config_version():
|
||||||
"""Get the last-synced config version number."""
|
"""Get the last-synced config version number."""
|
||||||
return int(app_sql.get_setting("device_config_version", "0"))
|
return int(app_sql.get_setting("device_config_version", "0"))
|
||||||
|
|||||||
@@ -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
@@ -41,17 +41,9 @@ import time
|
|||||||
from threading import Thread
|
from threading import Thread
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from dotenv import load_dotenv
|
|
||||||
from requests_toolbelt import MultipartEncoder
|
from requests_toolbelt import MultipartEncoder
|
||||||
|
|
||||||
from modules import app_sql, app_dash, app_cellular, app_video
|
from modules import app_sql, app_dash, app_cellular, app_remote_config, 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", "")
|
|
||||||
|
|
||||||
UPLOAD_INTERVAL = 30 # seconds between upload attempts once caught up
|
UPLOAD_INTERVAL = 30 # seconds between upload attempts once caught up
|
||||||
CATCHUP_INTERVAL = 1 # seconds between batches while draining a backlog
|
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
|
_last_network_path = None # "wifi" or "cellular" - last path a batch actually went out on
|
||||||
|
|
||||||
|
|
||||||
def _headers():
|
def _credentials():
|
||||||
return {"X-Device-Name": DEVICE_NAME, "X-Api-Key": API_KEY}
|
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():
|
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():
|
def is_dashcam_upload_enabled():
|
||||||
@@ -151,6 +155,7 @@ def _upload_tracking_batch():
|
|||||||
rows = app_sql.get_unsent_tracking_rows(limit=TRACKING_BATCH_SIZE)
|
rows = app_sql.get_unsent_tracking_rows(limit=TRACKING_BATCH_SIZE)
|
||||||
if not rows:
|
if not rows:
|
||||||
return 0
|
return 0
|
||||||
|
credentials = _credentials()
|
||||||
|
|
||||||
points = [
|
points = [
|
||||||
{
|
{
|
||||||
@@ -168,9 +173,9 @@ def _upload_tracking_batch():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
f"{SERVER_URL}/api/v1/tracking/batch",
|
f"{credentials['server_url']}/api/v1/tracking/batch",
|
||||||
json={"points": points},
|
json={"points": points},
|
||||||
headers=_headers(),
|
headers=_headers(credentials),
|
||||||
timeout=15,
|
timeout=15,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
@@ -182,7 +187,9 @@ def _upload_tracking_batch():
|
|||||||
body_bytes = json.dumps({"points": points}).encode()
|
body_bytes = json.dumps({"points": points}).encode()
|
||||||
try:
|
try:
|
||||||
status_code, _body = app_cellular.http_post_json(
|
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:
|
except app_cellular.CellularError as cell_err:
|
||||||
# Neither path worked - raise the original WiFi error so the
|
# 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
|
re-query /status and resume from the first missing chunk instead of
|
||||||
starting over.
|
starting over.
|
||||||
"""
|
"""
|
||||||
|
credentials = _credentials()
|
||||||
status_resp = requests.get(
|
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},
|
params={"relative_path": rel_path, "sha256": sha256},
|
||||||
headers=_headers(),
|
headers=_headers(credentials),
|
||||||
timeout=15,
|
timeout=15,
|
||||||
)
|
)
|
||||||
status_resp.raise_for_status()
|
status_resp.raise_for_status()
|
||||||
@@ -241,9 +249,9 @@ def _upload_video_chunked(abs_path, rel_path, sha256):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
f"{SERVER_URL}/api/v1/dashcam/upload/chunk",
|
f"{credentials['server_url']}/api/v1/dashcam/upload/chunk",
|
||||||
data=encoder,
|
data=encoder,
|
||||||
headers={**_headers(), "Content-Type": encoder.content_type},
|
headers={**_headers(credentials), "Content-Type": encoder.content_type},
|
||||||
timeout=120,
|
timeout=120,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
|||||||
@@ -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
|
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
|
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
|
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-
|
pulls from the server. The portal can register multiple devices, reveal a one-time API key at
|
||||||
reload into the running local UI on the next successful sync instead of waiting for a manual
|
creation/rotation time, and request a sync for a selected device. When the Pi has
|
||||||
restart. Shared `history.js` code runs on both the Pi and the server — tile
|
**Sync with server** enabled, those identity changes hot-reload into the running local UI on the
|
||||||
source and zoom limits are configured per-deployment via HTML data attributes. Accessible at
|
next successful sync instead of waiting for a manual restart. Shared `history.js` code runs on
|
||||||
`https://your-cloudflare-tunnel-url/`
|
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
|
- 📡 **Optional cellular fallback** through the SIM868 for small trip-log uploads when normal
|
||||||
WiFi/Ethernet is unavailable (dashcam video stays off cellular)
|
WiFi/Ethernet is unavailable (dashcam video stays off cellular)
|
||||||
- 🗄️ **Motion-aware local-first SQLite logging** — five-second motion checks without five-second
|
- 🗄️ **Motion-aware local-first SQLite logging** — five-second motion checks without five-second
|
||||||
|
|||||||
+13
-3
@@ -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
|
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
|
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
|
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
|
### 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
|
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_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_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
|
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` /
|
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
|
`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
|
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).
|
|
||||||
|
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
|
## Exposing it outside the LAN
|
||||||
|
|
||||||
|
|||||||
+82
-1
@@ -17,7 +17,7 @@ from flask_login import login_user, logout_user, current_user
|
|||||||
from modules import db
|
from modules import db
|
||||||
from modules.auth import require_device_auth
|
from modules.auth import require_device_auth
|
||||||
from modules.user_auth import login_manager, User, check_password, require_login
|
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 ──────────────────────────────────────────────────────────
|
# ── Geocode cache ──────────────────────────────────────────────────────────
|
||||||
try:
|
try:
|
||||||
@@ -630,6 +630,75 @@ def portal_trip_compat(trip_id):
|
|||||||
|
|
||||||
# ── Phase 25.5a: Device configuration sync ──────────────────────────────────
|
# ── 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")
|
@app.get("/api/v1/portal/device-config")
|
||||||
@require_login
|
@require_login
|
||||||
def portal_device_config_get():
|
def portal_device_config_get():
|
||||||
@@ -660,6 +729,18 @@ def api_device_config_get():
|
|||||||
return jsonify(config)
|
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")
|
@app.post("/api/device-config")
|
||||||
@require_login
|
@require_login
|
||||||
def api_device_config_update():
|
def api_device_config_update():
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ services:
|
|||||||
- ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
- ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||||
- ./sql/migrate_phase24.sql:/docker-entrypoint-initdb.d/migrate_phase24.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_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:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-yarisobd}"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-yarisobd}"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
|
|||||||
+112
-2
@@ -48,10 +48,17 @@ def get_device_by_name(name):
|
|||||||
|
|
||||||
|
|
||||||
def get_device_by_id(device_id):
|
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:
|
with get_cursor() as cur:
|
||||||
cur.execute(
|
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,),
|
(device_id,),
|
||||||
)
|
)
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
@@ -66,6 +73,13 @@ def get_device_by_id(device_id):
|
|||||||
"device_name": row[5],
|
"device_name": row[5],
|
||||||
"avatar_path": row[6],
|
"avatar_path": row[6],
|
||||||
"config_version": row[7],
|
"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):
|
def insert_tracking_points(device_id, points):
|
||||||
"""Bulk-insert tracking points, skipping any that already exist
|
"""Bulk-insert tracking points, skipping any that already exist
|
||||||
(device_id, recorded_at) so a client can safely re-send a batch after a
|
(device_id, recorded_at) so a client can safely re-send a batch after a
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ def update_device_config(device_id, data):
|
|||||||
Validates input and updates config_version on change.
|
Validates input and updates config_version on change.
|
||||||
"""
|
"""
|
||||||
errors = {}
|
errors = {}
|
||||||
|
if db.get_device_by_id(device_id) is None:
|
||||||
|
return False, {"error": "Device not found."}
|
||||||
|
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
return False, {"error": "Invalid request body"}
|
return False, {"error": "Invalid request body"}
|
||||||
@@ -87,4 +89,3 @@ def update_device_config(device_id, data):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return True, {}
|
return True, {}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
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.
|
upload-client config immediately. Only its sha256 hash is stored server-side.
|
||||||
"""
|
"""
|
||||||
import hashlib
|
|
||||||
import secrets
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, "/app")
|
sys.path.insert(0, "/app")
|
||||||
|
|
||||||
from modules import db # noqa: E402
|
from modules import device_registry # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -23,26 +21,16 @@ def main():
|
|||||||
print(f"usage: {sys.argv[0]} <device-name>", file=sys.stderr)
|
print(f"usage: {sys.argv[0]} <device-name>", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
device_name = sys.argv[1]
|
result, errors = device_registry.create_device(sys.argv[1])
|
||||||
raw_key = secrets.token_urlsafe(32)
|
if errors:
|
||||||
key_hash = hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
|
print(f"error: {errors}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
with db.get_cursor(commit=True) as cur:
|
print(f"Device '{result['device']['name']}' registered (id={result['device']['id']}).")
|
||||||
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()
|
print()
|
||||||
print("Save these on the Pi (e.g. in db/upload_creds.env, gitignored):")
|
print("Save these on the Pi (e.g. in db/upload_creds.env, gitignored):")
|
||||||
print(f" YARIS_DEVICE_NAME={device_name}")
|
print(f" YARIS_DEVICE_NAME={result['device']['name']}")
|
||||||
print(f" YARIS_API_KEY={raw_key}")
|
print(f" YARIS_API_KEY={result['api_key']}")
|
||||||
print()
|
print()
|
||||||
print("This key will not be shown again - only its hash is stored server-side.")
|
print("This key will not be shown again - only its hash is stored server-side.")
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -8,7 +8,17 @@ CREATE TABLE IF NOT EXISTS devices (
|
|||||||
name TEXT NOT NULL UNIQUE, -- e.g. "yaris-2013"
|
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
|
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(),
|
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 (
|
CREATE TABLE IF NOT EXISTS tracking_points (
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<h3 class="text-dark mb-1 fw-bold">Device settings</h3>
|
<h3 class="text-dark mb-1 fw-bold">Device settings</h3>
|
||||||
<p class="text-muted mb-0">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary" id="device-settings-save" type="submit" form="device-settings-form" disabled>
|
<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 id="device-settings-message" class="alert" role="status" hidden></div>
|
||||||
|
|
||||||
<div class="row g-3">
|
<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 shadow-sm">
|
||||||
<div class="card-header py-2">
|
<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>
|
||||||
<div class="card-body">
|
<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>
|
<form id="device-settings-form" novalidate>
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
<label class="form-label" for="owner_name">Owner name</label>
|
<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>
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
<label class="form-label" for="car_model">Car model</label>
|
<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>
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
<label class="form-label" for="device_name">Device name</label>
|
<label class="form-label" for="device_name">Display device name</label>
|
||||||
<input class="form-control" id="device_name" name="device_name" maxlength="100">
|
<input class="form-control" id="device_name" name="device_name" maxlength="100" disabled>
|
||||||
<div class="form-text">Used by the remote portal and future multi-device views.</div>
|
<div class="form-text">Friendly label shown in the portal/device UI.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
<label class="form-label" for="avatar_path">Avatar path</label>
|
<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 class="form-text">Relative to the shared <code>static/</code> folder.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12 col-xl-4">
|
|
||||||
<div class="card shadow-sm mb-3">
|
<div class="card shadow-sm mb-3">
|
||||||
<div class="card-header py-2">
|
<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>
|
||||||
<div class="card-body small text-muted">
|
<div class="card-body">
|
||||||
<p class="mb-2">
|
<p class="small text-muted mb-3">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
<ul class="mb-0 ps-3">
|
<div class="row g-3">
|
||||||
<li>Expected propagation delay: up to ~5 minutes while online.</li>
|
<div class="col-md-4">
|
||||||
<li>The Pi must have <strong>Sync with server</strong> enabled locally.</li>
|
<label class="form-label" for="credential-server-url">Server URL</label>
|
||||||
<li>Offline cars keep the last synced config until connectivity returns.</li>
|
<input class="form-control" id="credential-server-url" readonly>
|
||||||
</ul>
|
</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>
|
</div>
|
||||||
|
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-header py-2">
|
<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>
|
||||||
<div class="card-body text-center">
|
<div class="card-body small text-muted">
|
||||||
<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;">
|
<ul class="mb-0 ps-3">
|
||||||
<div class="small text-muted mb-1">Config version</div>
|
<li><strong>Request sync</strong> means “fetch this config on the Pi's next online contact”.</li>
|
||||||
<div class="fw-bold fs-5" id="config-version">—</div>
|
<li>The car can still be offline; the remote server cannot push directly into it.</li>
|
||||||
<div class="small text-muted mt-3" id="preview-caption">Preview updates as you type.</div>
|
<li>Use the Pi's local <strong>Sync now</strong> button for immediate pull attempts.</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,26 @@
|
|||||||
const form = document.getElementById('device-settings-form');
|
const form = document.getElementById('device-settings-form');
|
||||||
const saveButton = document.getElementById('device-settings-save');
|
const saveButton = document.getElementById('device-settings-save');
|
||||||
const message = document.getElementById('device-settings-message');
|
const message = document.getElementById('device-settings-message');
|
||||||
const configVersion = document.getElementById('config-version');
|
const deviceList = document.getElementById('device-list');
|
||||||
const avatarPreview = document.getElementById('avatar-preview');
|
const deviceCount = document.getElementById('device-count');
|
||||||
const previewCaption = document.getElementById('preview-caption');
|
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 = {
|
const fields = {
|
||||||
owner_name: document.getElementById('owner_name'),
|
owner_name: document.getElementById('owner_name'),
|
||||||
@@ -12,7 +29,10 @@ const fields = {
|
|||||||
avatar_path: document.getElementById('avatar_path'),
|
avatar_path: document.getElementById('avatar_path'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let devices = [];
|
||||||
|
let selectedDeviceId = null;
|
||||||
let initialState = null;
|
let initialState = null;
|
||||||
|
let latestApiKey = '';
|
||||||
|
|
||||||
function showMessage(text, kind) {
|
function showMessage(text, kind) {
|
||||||
message.className = `alert alert-${kind}`;
|
message.className = `alert alert-${kind}`;
|
||||||
@@ -24,6 +44,11 @@ function hideMessage() {
|
|||||||
message.hidden = true;
|
message.hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDate(isoString) {
|
||||||
|
if (!isoString) return '—';
|
||||||
|
return new Date(isoString).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
function currentState() {
|
function currentState() {
|
||||||
return {
|
return {
|
||||||
owner_name: fields.owner_name.value.trim(),
|
owner_name: fields.owner_name.value.trim(),
|
||||||
@@ -33,89 +58,234 @@ function currentState() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function avatarUrl(path) {
|
function setFormEnabled(enabled) {
|
||||||
if (!path) return '/static/img/yaris.png';
|
for (const field of Object.values(fields)) {
|
||||||
if (/^https?:\/\//.test(path)) return path;
|
field.disabled = !enabled;
|
||||||
return `/static/${path.replace(/^\/+/, '')}`;
|
}
|
||||||
}
|
rotateKeyButton.disabled = !enabled;
|
||||||
|
requestSyncButton.disabled = !enabled;
|
||||||
function updatePreview() {
|
updateDirtyState();
|
||||||
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 updateDirtyState() {
|
function updateDirtyState() {
|
||||||
if (!initialState) {
|
if (!initialState || selectedDeviceId === null) {
|
||||||
saveButton.disabled = true;
|
saveButton.disabled = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
saveButton.disabled = JSON.stringify(currentState()) === JSON.stringify(initialState);
|
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.owner_name.value = config.owner_name || '';
|
||||||
fields.car_model.value = config.car_model || '';
|
fields.car_model.value = config.car_model || '';
|
||||||
fields.device_name.value = config.device_name || '';
|
fields.device_name.value = config.device_name || '';
|
||||||
fields.avatar_path.value = config.avatar_path || '';
|
fields.avatar_path.value = config.avatar_path || '';
|
||||||
configVersion.textContent = config.config_version ?? '—';
|
|
||||||
initialState = currentState();
|
initialState = currentState();
|
||||||
updatePreview();
|
credentialFields.deviceName.value = device.name;
|
||||||
updateDirtyState();
|
renderDeviceList();
|
||||||
|
setFormEnabled(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadConfig() {
|
async function loadDevices(preferredDeviceId = null) {
|
||||||
try {
|
const response = await fetch('/api/v1/portal/devices');
|
||||||
const response = await fetch('/api/v1/portal/device-config');
|
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();
|
const payload = await response.json();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(payload.error || 'Could not load device config.');
|
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) {
|
} catch (error) {
|
||||||
showMessage(error.message, 'danger');
|
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) {
|
async function saveConfig(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (selectedDeviceId === null) return;
|
||||||
hideMessage();
|
hideMessage();
|
||||||
saveButton.disabled = true;
|
saveButton.disabled = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/device-config', {
|
const response = await fetch(`/api/v1/portal/devices/${selectedDeviceId}/config`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(currentState()),
|
body: JSON.stringify(currentState()),
|
||||||
});
|
});
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const detail = payload.fields
|
throw new Error(payload.error || 'Could not save device config.');
|
||||||
? Object.values(payload.fields).join(' ')
|
|
||||||
: (payload.error || 'Could not save device config.');
|
|
||||||
throw new Error(detail);
|
|
||||||
}
|
}
|
||||||
populate(payload);
|
await loadDevices(selectedDeviceId);
|
||||||
showMessage('Device settings saved. The Pi will pick them up on its next sync.', 'success');
|
showMessage('Device settings saved.', 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showMessage(error.message, 'danger');
|
showMessage(error.message, 'danger');
|
||||||
updateDirtyState();
|
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)) {
|
for (const field of Object.values(fields)) {
|
||||||
field.addEventListener('input', () => {
|
field.addEventListener('input', () => {
|
||||||
hideMessage();
|
hideMessage();
|
||||||
updatePreview();
|
|
||||||
updateDirtyState();
|
updateDirtyState();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
avatarPreview.addEventListener('error', () => {
|
form.addEventListener('submit', saveConfig);
|
||||||
previewCaption.textContent = 'Preview image not found on the server static path.';
|
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);
|
loadDevices().catch(error => showMessage(error.message, 'danger'));
|
||||||
loadConfig();
|
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ const phonePassword = document.getElementById('phone-hotspot-password');
|
|||||||
const phoneSave = document.getElementById('phone-hotspot-save');
|
const phoneSave = document.getElementById('phone-hotspot-save');
|
||||||
const phoneConnect = document.getElementById('phone-hotspot-connect');
|
const phoneConnect = document.getElementById('phone-hotspot-connect');
|
||||||
const phoneRemove = document.getElementById('phone-hotspot-remove');
|
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 lastPhoneAddress = document.getElementById('last-phone-address');
|
||||||
const lastPhoneAddressLink = document.getElementById('last-phone-address-link');
|
const lastPhoneAddressLink = document.getElementById('last-phone-address-link');
|
||||||
let initialUplinkStatusLoaded = false;
|
let initialUplinkStatusLoaded = false;
|
||||||
@@ -35,6 +38,10 @@ const fields = {
|
|||||||
deviceCarModel: document.getElementById('device-car-model'),
|
deviceCarModel: document.getElementById('device-car-model'),
|
||||||
deviceAvatarPath: document.getElementById('device-avatar-path'),
|
deviceAvatarPath: document.getElementById('device-avatar-path'),
|
||||||
deviceName: document.getElementById('device-name'),
|
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) {
|
function showMessage(text, kind) {
|
||||||
@@ -49,6 +56,12 @@ function showUplinkMessage(text, kind) {
|
|||||||
uplinkMessage.hidden = false;
|
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) {
|
function setSaving(disabled) {
|
||||||
saveButtons.forEach(button => {
|
saveButtons.forEach(button => {
|
||||||
button.disabled = disabled;
|
button.disabled = disabled;
|
||||||
@@ -78,6 +91,9 @@ function applyErrors(errors) {
|
|||||||
car_model: fields.deviceCarModel,
|
car_model: fields.deviceCarModel,
|
||||||
avatar_path: fields.deviceAvatarPath,
|
avatar_path: fields.deviceAvatarPath,
|
||||||
device_name: fields.deviceName,
|
device_name: fields.deviceName,
|
||||||
|
server_url: fields.remoteServerUrl,
|
||||||
|
remote_device_name: fields.remoteDeviceName,
|
||||||
|
api_key: fields.remoteApiKey,
|
||||||
};
|
};
|
||||||
Object.entries(errors).forEach(([name, text]) => {
|
Object.entries(errors).forEach(([name, text]) => {
|
||||||
const input = errorElements[name];
|
const input = errorElements[name];
|
||||||
@@ -123,6 +139,16 @@ function populateDeviceConfig(deviceConfig) {
|
|||||||
updateDeviceConfigFieldsState();
|
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() {
|
function updateDeviceConfigFieldsState() {
|
||||||
const syncEnabled = fields.deviceSyncEnabled.checked;
|
const syncEnabled = fields.deviceSyncEnabled.checked;
|
||||||
const configFields = [fields.deviceOwnerName, fields.deviceCarModel, fields.deviceAvatarPath, fields.deviceName];
|
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() {
|
async function loadUploadHealth() {
|
||||||
const element = document.getElementById('upload-health');
|
const element = document.getElementById('upload-health');
|
||||||
try {
|
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);
|
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 => {
|
form.addEventListener('submit', async event => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
clearErrors();
|
clearErrors();
|
||||||
message.hidden = true;
|
message.hidden = true;
|
||||||
|
remoteConfigMessage.hidden = true;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
// Save main settings
|
// Save main settings
|
||||||
@@ -342,6 +419,19 @@ form.addEventListener('submit', async event => {
|
|||||||
}
|
}
|
||||||
populate(result);
|
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
|
// Save device config
|
||||||
const deviceConfigResponse = await fetch('/api/device-config', {
|
const deviceConfigResponse = await fetch('/api/device-config', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -365,6 +455,7 @@ form.addEventListener('submit', async event => {
|
|||||||
|
|
||||||
loadSettings();
|
loadSettings();
|
||||||
loadDeviceConfig();
|
loadDeviceConfig();
|
||||||
|
loadRemoteConfig();
|
||||||
loadUploadHealth();
|
loadUploadHealth();
|
||||||
loadUplinkStatus();
|
loadUplinkStatus();
|
||||||
setInterval(loadUplinkStatus, 15000);
|
setInterval(loadUplinkStatus, 15000);
|
||||||
|
|||||||
@@ -61,6 +61,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 ──────────────────────────────────────────── #}
|
{# ── Upload & retention ──────────────────────────────────────────── #}
|
||||||
<div class="card shadow-sm mb-3">
|
<div class="card shadow-sm mb-3">
|
||||||
<div class="card-header py-2">
|
<div class="card-header py-2">
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import unittest
|
import unittest
|
||||||
import os
|
|
||||||
from unittest.mock import Mock, patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
from modules import app_account, app_car, app_device_sync
|
from modules import app_account, app_car, app_device_sync
|
||||||
@@ -56,16 +55,13 @@ class DeviceSyncReloadCallbackTest(unittest.TestCase):
|
|||||||
finally:
|
finally:
|
||||||
app_device_sync._RELOAD_CALLBACK = original
|
app_device_sync._RELOAD_CALLBACK = original
|
||||||
|
|
||||||
@patch.dict(
|
@patch("modules.app_remote_config.server_credentials")
|
||||||
os.environ,
|
def test_sync_credentials_fall_back_to_upload_api_key(self, server_credentials):
|
||||||
{
|
server_credentials.return_value = {
|
||||||
"YARIS_SERVER_URL": "https://example.test",
|
"server_url": "https://example.test",
|
||||||
"YARIS_DEVICE_NAME": "yaris-2013",
|
"device_name": "yaris-2013",
|
||||||
"YARIS_API_KEY": "upload-key",
|
"api_key": "upload-key",
|
||||||
},
|
}
|
||||||
clear=True,
|
|
||||||
)
|
|
||||||
def test_sync_credentials_fall_back_to_upload_api_key(self):
|
|
||||||
server_url, device_name, device_key = app_device_sync._server_credentials()
|
server_url, device_name, device_key = app_device_sync._server_credentials()
|
||||||
|
|
||||||
self.assertEqual(server_url, "https://example.test")
|
self.assertEqual(server_url, "https://example.test")
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user