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:
@@ -72,9 +72,18 @@ def update_device_config(body, errors):
|
||||
if "sync_enabled" in body:
|
||||
if not isinstance(body["sync_enabled"], bool):
|
||||
errors["sync_enabled"] = "Must be true or false."
|
||||
elif body["sync_enabled"]:
|
||||
from . import app_remote_config
|
||||
|
||||
remote = app_remote_config.get_remote_config()
|
||||
if not remote["server_url"] or not remote["device_name"] or not remote["api_key_configured"]:
|
||||
errors["sync_enabled"] = "Configure server URL, device name, and API key before enabling server sync."
|
||||
else:
|
||||
_set_sync_enabled(body["sync_enabled"])
|
||||
|
||||
if "sync_enabled" in body and body["sync_enabled"] and "sync_enabled" not in errors:
|
||||
_set_sync_enabled(True)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
|
||||
+52
-35
@@ -10,18 +10,14 @@ import threading
|
||||
import time
|
||||
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from . import app_network, app_sql
|
||||
from . import app_network, app_remote_config, app_sql
|
||||
|
||||
|
||||
_SYNC_THREAD = None
|
||||
_SYNC_STOP = False
|
||||
_SYNC_CONFIG_VERSION = 0 # Track last-synced version to detect changes
|
||||
_RELOAD_CALLBACK = None
|
||||
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
load_dotenv(os.path.join(_BASE_DIR, "db", "upload_creds.env"))
|
||||
|
||||
|
||||
def _has_internet():
|
||||
@@ -39,16 +35,15 @@ def _has_internet():
|
||||
|
||||
|
||||
def _server_credentials():
|
||||
server_url = os.getenv("YARIS_SERVER_URL", "").rstrip("/")
|
||||
device_name = os.getenv("YARIS_DEVICE_NAME") or os.getenv("YARIS_UPLOAD_DEVICE_NAME", "")
|
||||
device_key = os.getenv("YARIS_DEVICE_KEY") or os.getenv("YARIS_API_KEY", "")
|
||||
return server_url, device_name, device_key
|
||||
credentials = app_remote_config.server_credentials()
|
||||
device_key = os.getenv("YARIS_DEVICE_KEY") or credentials["api_key"]
|
||||
return credentials["server_url"], credentials["device_name"], device_key
|
||||
|
||||
|
||||
def start_sync():
|
||||
"""Start background device config sync thread."""
|
||||
global _SYNC_THREAD, _SYNC_STOP
|
||||
if _SYNC_THREAD is not None:
|
||||
if _SYNC_THREAD is not None and _SYNC_THREAD.is_alive():
|
||||
return
|
||||
|
||||
_SYNC_STOP = False
|
||||
@@ -70,36 +65,13 @@ def stop_sync():
|
||||
|
||||
def _sync_loop():
|
||||
"""Periodically sync device config from server when internet available."""
|
||||
global _SYNC_CONFIG_VERSION
|
||||
|
||||
server_url, device_name, device_key = _server_credentials()
|
||||
|
||||
if not server_url or not device_name or not device_key:
|
||||
return
|
||||
|
||||
sync_interval = 300
|
||||
offline_interval = 60
|
||||
|
||||
while not _SYNC_STOP:
|
||||
try:
|
||||
if _has_internet():
|
||||
response = requests.get(
|
||||
f"{server_url}/api/device-config",
|
||||
headers={
|
||||
"X-Device-Name": device_name,
|
||||
"X-Api-Key": device_key,
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
config = response.json()
|
||||
new_version = config.get("config_version", 0)
|
||||
if new_version != _SYNC_CONFIG_VERSION:
|
||||
_SYNC_CONFIG_VERSION = new_version
|
||||
_store_synced_config(config)
|
||||
_on_config_changed()
|
||||
|
||||
result = sync_now()
|
||||
if result["status"] in {"synced", "no_change"}:
|
||||
time.sleep(sync_interval)
|
||||
else:
|
||||
time.sleep(offline_interval)
|
||||
@@ -109,6 +81,39 @@ def _sync_loop():
|
||||
time.sleep(60)
|
||||
|
||||
|
||||
def sync_now():
|
||||
"""Perform one immediate sync attempt."""
|
||||
global _SYNC_CONFIG_VERSION
|
||||
|
||||
server_url, device_name, device_key = _server_credentials()
|
||||
if not server_url or not device_name or not device_key:
|
||||
return {"status": "not_configured", "message": "Remote server credentials are incomplete."}
|
||||
if not _has_internet():
|
||||
return {"status": "offline", "message": "Internet is not currently reachable."}
|
||||
|
||||
response = requests.get(
|
||||
f"{server_url}/api/device-config",
|
||||
headers={
|
||||
"X-Device-Name": device_name,
|
||||
"X-Api-Key": device_key,
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
response.raise_for_status()
|
||||
config = response.json()
|
||||
new_version = config.get("config_version", 0)
|
||||
|
||||
if new_version != _SYNC_CONFIG_VERSION:
|
||||
_SYNC_CONFIG_VERSION = new_version
|
||||
_store_synced_config(config)
|
||||
_acknowledge_sync(server_url, device_name, device_key, new_version)
|
||||
_on_config_changed()
|
||||
return {"status": "synced", "message": f"Synced config version {new_version}."}
|
||||
|
||||
_acknowledge_sync(server_url, device_name, device_key, new_version)
|
||||
return {"status": "no_change", "message": "Already up to date."}
|
||||
|
||||
|
||||
def _store_synced_config(config):
|
||||
"""Store synced config in SQLite database."""
|
||||
app_sql.set_setting("device_owner_name", config.get("owner_name", ""))
|
||||
@@ -131,6 +136,18 @@ def is_sync_enabled():
|
||||
return bool(server_url) and bool(device_name) and bool(device_key)
|
||||
|
||||
|
||||
def _acknowledge_sync(server_url, device_name, device_key, config_version):
|
||||
requests.post(
|
||||
f"{server_url}/api/v1/device-config/ack",
|
||||
json={"config_version": config_version},
|
||||
headers={
|
||||
"X-Device-Name": device_name,
|
||||
"X-Api-Key": device_key,
|
||||
},
|
||||
timeout=5,
|
||||
).raise_for_status()
|
||||
|
||||
|
||||
def get_synced_config_version():
|
||||
"""Get the last-synced config version number."""
|
||||
return int(app_sql.get_setting("device_config_version", "0"))
|
||||
|
||||
@@ -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
|
||||
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
from requests_toolbelt import MultipartEncoder
|
||||
|
||||
from modules import app_sql, app_dash, app_cellular, app_video
|
||||
|
||||
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
load_dotenv(os.path.join(_BASE_DIR, "db", "upload_creds.env"))
|
||||
|
||||
SERVER_URL = os.getenv("YARIS_SERVER_URL", "").rstrip("/")
|
||||
DEVICE_NAME = os.getenv("YARIS_DEVICE_NAME", "")
|
||||
API_KEY = os.getenv("YARIS_API_KEY", "")
|
||||
from modules import app_sql, app_dash, app_cellular, app_remote_config, app_video
|
||||
|
||||
UPLOAD_INTERVAL = 30 # seconds between upload attempts once caught up
|
||||
CATCHUP_INTERVAL = 1 # seconds between batches while draining a backlog
|
||||
@@ -75,12 +67,24 @@ _last_success_at = None
|
||||
_last_network_path = None # "wifi" or "cellular" - last path a batch actually went out on
|
||||
|
||||
|
||||
def _headers():
|
||||
return {"X-Device-Name": DEVICE_NAME, "X-Api-Key": API_KEY}
|
||||
def _credentials():
|
||||
return app_remote_config.server_credentials()
|
||||
|
||||
|
||||
def _headers(credentials):
|
||||
return {
|
||||
"X-Device-Name": credentials["device_name"],
|
||||
"X-Api-Key": credentials["api_key"],
|
||||
}
|
||||
|
||||
|
||||
def is_configured():
|
||||
return bool(SERVER_URL and DEVICE_NAME and API_KEY)
|
||||
credentials = _credentials()
|
||||
return bool(
|
||||
credentials["server_url"]
|
||||
and credentials["device_name"]
|
||||
and credentials["api_key"]
|
||||
)
|
||||
|
||||
|
||||
def is_dashcam_upload_enabled():
|
||||
@@ -151,6 +155,7 @@ def _upload_tracking_batch():
|
||||
rows = app_sql.get_unsent_tracking_rows(limit=TRACKING_BATCH_SIZE)
|
||||
if not rows:
|
||||
return 0
|
||||
credentials = _credentials()
|
||||
|
||||
points = [
|
||||
{
|
||||
@@ -168,9 +173,9 @@ def _upload_tracking_batch():
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{SERVER_URL}/api/v1/tracking/batch",
|
||||
f"{credentials['server_url']}/api/v1/tracking/batch",
|
||||
json={"points": points},
|
||||
headers=_headers(),
|
||||
headers=_headers(credentials),
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
@@ -182,7 +187,9 @@ def _upload_tracking_batch():
|
||||
body_bytes = json.dumps({"points": points}).encode()
|
||||
try:
|
||||
status_code, _body = app_cellular.http_post_json(
|
||||
f"{SERVER_URL}/api/v1/tracking/batch", body_bytes, _headers()
|
||||
f"{credentials['server_url']}/api/v1/tracking/batch",
|
||||
body_bytes,
|
||||
_headers(credentials),
|
||||
)
|
||||
except app_cellular.CellularError as cell_err:
|
||||
# Neither path worked - raise the original WiFi error so the
|
||||
@@ -207,10 +214,11 @@ def _upload_video_chunked(abs_path, rel_path, sha256):
|
||||
re-query /status and resume from the first missing chunk instead of
|
||||
starting over.
|
||||
"""
|
||||
credentials = _credentials()
|
||||
status_resp = requests.get(
|
||||
f"{SERVER_URL}/api/v1/dashcam/upload/status",
|
||||
f"{credentials['server_url']}/api/v1/dashcam/upload/status",
|
||||
params={"relative_path": rel_path, "sha256": sha256},
|
||||
headers=_headers(),
|
||||
headers=_headers(credentials),
|
||||
timeout=15,
|
||||
)
|
||||
status_resp.raise_for_status()
|
||||
@@ -241,9 +249,9 @@ def _upload_video_chunked(abs_path, rel_path, sha256):
|
||||
}
|
||||
)
|
||||
resp = requests.post(
|
||||
f"{SERVER_URL}/api/v1/dashcam/upload/chunk",
|
||||
f"{credentials['server_url']}/api/v1/dashcam/upload/chunk",
|
||||
data=encoder,
|
||||
headers={**_headers(), "Content-Type": encoder.content_type},
|
||||
headers={**_headers(credentials), "Content-Type": encoder.content_type},
|
||||
timeout=120,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
Reference in New Issue
Block a user