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
+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()