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:
+112
-2
@@ -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
|
||||
|
||||
@@ -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, {}
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user