feat: add multi-device provisioning and local remote-config UI

- add multi-device portal device management, key rotation, and sync request APIs
- add Pi settings UI for remote server URL, device name, and API key
- add local test-connection and sync-now actions
- make upload and config sync reuse dynamic shared credentials
- add Phase 25.5c migration and docs updates

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Mathieu Moran
2026-08-10 18:02:09 -04:00
co-authored by Copilot
parent fbc41a5db5
commit ba682fbf3a
23 changed files with 1357 additions and 167 deletions
+112 -2
View File
@@ -48,10 +48,17 @@ def get_device_by_name(name):
def get_device_by_id(device_id):
"""Get device by ID, including config fields for Phase 25.5a."""
"""Get device by ID, including config + sync metadata."""
with get_cursor() as cur:
cur.execute(
"SELECT id, name, api_key_hash, owner_name, car_model, device_name, avatar_path, config_version FROM devices WHERE id = %s",
"""
SELECT id, name, api_key_hash, owner_name, car_model, device_name, avatar_path,
config_version, config_updated_at, created_at, last_seen_at,
last_config_sync_at, last_config_sync_version, force_sync_requested_at,
api_key_rotated_at
FROM devices
WHERE id = %s
""",
(device_id,),
)
row = cur.fetchone()
@@ -66,6 +73,13 @@ def get_device_by_id(device_id):
"device_name": row[5],
"avatar_path": row[6],
"config_version": row[7],
"config_updated_at": row[8],
"created_at": row[9],
"last_seen_at": row[10],
"last_config_sync_at": row[11],
"last_config_sync_version": row[12],
"force_sync_requested_at": row[13],
"api_key_rotated_at": row[14],
}
@@ -77,6 +91,102 @@ def touch_device_last_seen(device_id):
)
def list_devices():
with get_cursor() as cur:
cur.execute(
"""
SELECT id, name, owner_name, car_model, device_name, avatar_path, config_version,
config_updated_at, created_at, last_seen_at, last_config_sync_at,
last_config_sync_version, force_sync_requested_at, api_key_rotated_at
FROM devices
ORDER BY created_at ASC, id ASC
"""
)
rows = cur.fetchall()
return [
{
"id": row[0],
"name": row[1],
"owner_name": row[2],
"car_model": row[3],
"device_name": row[4],
"avatar_path": row[5],
"config_version": row[6],
"config_updated_at": row[7],
"created_at": row[8],
"last_seen_at": row[9],
"last_config_sync_at": row[10],
"last_config_sync_version": row[11],
"force_sync_requested_at": row[12],
"api_key_rotated_at": row[13],
}
for row in rows
]
def create_device(name, api_key_hash):
with get_cursor(commit=True) as cur:
cur.execute(
"""
INSERT INTO devices (name, api_key_hash, api_key_rotated_at)
VALUES (%s, %s, now())
RETURNING id
""",
(name, api_key_hash),
)
row = cur.fetchone()
return row[0]
def rotate_device_api_key(device_id, api_key_hash):
with get_cursor(commit=True) as cur:
cur.execute(
"""
UPDATE devices
SET api_key_hash = %s, api_key_rotated_at = now()
WHERE id = %s
RETURNING id
""",
(api_key_hash, device_id),
)
row = cur.fetchone()
return row[0] if row else None
def request_device_sync(device_id):
with get_cursor(commit=True) as cur:
cur.execute(
"""
UPDATE devices
SET config_version = config_version + 1,
config_updated_at = now(),
force_sync_requested_at = now()
WHERE id = %s
RETURNING config_version
""",
(device_id,),
)
row = cur.fetchone()
return row[0] if row else None
def acknowledge_device_sync(device_id, config_version):
with get_cursor(commit=True) as cur:
cur.execute(
"""
UPDATE devices
SET last_config_sync_at = now(),
last_config_sync_version = %s,
force_sync_requested_at = CASE
WHEN config_version <= %s THEN NULL
ELSE force_sync_requested_at
END
WHERE id = %s
""",
(config_version, config_version, device_id),
)
def insert_tracking_points(device_id, points):
"""Bulk-insert tracking points, skipping any that already exist
(device_id, recorded_at) so a client can safely re-send a batch after a
+2 -1
View File
@@ -30,6 +30,8 @@ def update_device_config(device_id, data):
Validates input and updates config_version on change.
"""
errors = {}
if db.get_device_by_id(device_id) is None:
return False, {"error": "Device not found."}
if not isinstance(data, dict):
return False, {"error": "Invalid request body"}
@@ -87,4 +89,3 @@ def update_device_config(device_id, data):
)
return True, {}
+71
View File
@@ -0,0 +1,71 @@
"""Multi-device provisioning helpers for the remote portal."""
import hashlib
import re
import secrets
from . import db
_DEVICE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$")
def list_devices():
return db.list_devices()
def validate_device_name(name):
value = (name or "").strip()
if not value:
return None, "Device name is required."
if not _DEVICE_NAME_RE.fullmatch(value):
return None, "Device name must be 1-100 chars using letters, numbers, dot, underscore, or dash."
return value, None
def create_device(name):
value, error = validate_device_name(name)
if error:
return None, {"name": error}
if db.get_device_by_name(value) is not None:
return None, {"name": "A device with that name already exists."}
raw_key = secrets.token_urlsafe(32)
device_id = db.create_device(value, _hash_key(raw_key))
return {
"device": db.get_device_by_id(device_id),
"api_key": raw_key,
}, {}
def rotate_device_key(device_id):
device = db.get_device_by_id(device_id)
if device is None:
return None, {"error": "Device not found."}
raw_key = secrets.token_urlsafe(32)
db.rotate_device_api_key(device_id, _hash_key(raw_key))
return {
"device": db.get_device_by_id(device_id),
"api_key": raw_key,
}, {}
def request_sync(device_id):
config_version = db.request_device_sync(device_id)
if config_version is None:
return None
device = db.get_device_by_id(device_id)
return {
"device": device,
"message": "Sync requested. The Pi will pick it up on its next online contact, or immediately if Sync now is used locally.",
"config_version": config_version,
}
def acknowledge_sync(device_id, config_version):
db.acknowledge_device_sync(device_id, config_version)
def _hash_key(raw_key):
return hashlib.sha256(raw_key.encode("utf-8")).hexdigest()