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
+82 -1
View File
@@ -17,7 +17,7 @@ from flask_login import login_user, logout_user, current_user
from modules import db
from modules.auth import require_device_auth
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 ──────────────────────────────────────────────────────────
try:
@@ -630,6 +630,75 @@ def portal_trip_compat(trip_id):
# ── 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")
@require_login
def portal_device_config_get():
@@ -660,6 +729,18 @@ def api_device_config_get():
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")
@require_login
def api_device_config_update():