feat: add Phase 25.5a server-side device config API endpoints

- Add database migration (migrate_phase25_5a.sql):
  * Add owner_name, car_model, device_name, avatar_path columns to devices table
  * Add config_version and config_updated_at for cache invalidation

- Add server/modules/device_config.py:
  * get_device_config(device_id): retrieve config for Pi sync
  * update_device_config(device_id, data): update config with validation

- Add server API endpoints:
  * GET /api/device-config (authenticated with device_key, Pi fetches config)
  * POST /api/device-config (authenticated as user, admin updates config)

- Enhance server/modules/db.py:
  * Add get_device_by_id() function to retrieve all device config fields

Phase 25.5a enables server-side device identity management and connection-driven sync.
Pi will fetch from this endpoint when internet available, keeping config in sync.

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Mathieu Moran
2026-08-10 16:46:52 -04:00
co-authored by Copilot
parent ed78700005
commit 2395cf6dc4
4 changed files with 164 additions and 0 deletions
+22
View File
@@ -47,6 +47,28 @@ def get_device_by_name(name):
return {"id": row[0], "name": row[1], "api_key_hash": row[2]}
def get_device_by_id(device_id):
"""Get device by ID, including config fields for Phase 25.5a."""
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",
(device_id,),
)
row = cur.fetchone()
if row is None:
return None
return {
"id": row[0],
"name": row[1],
"api_key_hash": row[2],
"owner_name": row[3],
"car_model": row[4],
"device_name": row[5],
"avatar_path": row[6],
"config_version": row[7],
}
def touch_device_last_seen(device_id):
with get_cursor(commit=True) as cur:
cur.execute(
+90
View File
@@ -0,0 +1,90 @@
"""Server-side device configuration management for Phase 25.5a sync.
Provides API endpoints for Pi to fetch device config and portal to update it.
"""
from . import db
def get_device_config(device_id):
"""Get device config for a specific device.
Returns dict with owner_name, car_model, device_name, avatar_path, config_version.
"""
dev = db.get_device_by_id(device_id)
if not dev:
return None
return {
"owner_name": dev.get("owner_name") or "",
"car_model": dev.get("car_model") or "",
"device_name": dev.get("device_name") or "",
"avatar_path": dev.get("avatar_path") or "img/yaris.png",
"config_version": dev.get("config_version") or 0,
}
def update_device_config(device_id, data):
"""Update device config. Returns (success, errors_dict).
Validates input and updates config_version on change.
"""
errors = {}
if not isinstance(data, dict):
return False, {"error": "Invalid request body"}
# Validate fields
owner_name = data.get("owner_name", "").strip() if "owner_name" in data else None
car_model = data.get("car_model", "").strip() if "car_model" in data else None
device_name = data.get("device_name", "").strip() if "device_name" in data else None
avatar_path = data.get("avatar_path", "").strip() if "avatar_path" in data else None
if owner_name is not None and len(owner_name) > 100:
errors["owner_name"] = "Owner name must be 1-100 characters."
if car_model is not None and len(car_model) > 100:
errors["car_model"] = "Car model must be 1-100 characters."
if device_name is not None and len(device_name) > 100:
errors["device_name"] = "Device name must be 1-100 characters."
if avatar_path is not None and len(avatar_path) > 255:
errors["avatar_path"] = "Avatar path must be 1-255 characters."
if errors:
return False, errors
# Build update dict (only include provided fields)
with db.get_cursor(commit=True) as cur:
# Increment config_version if any field changed
if owner_name is not None or car_model is not None or device_name is not None or avatar_path is not None:
cur.execute(
"UPDATE devices SET config_version = config_version + 1, config_updated_at = now() WHERE id = %s",
(device_id,)
)
# Update individual fields
if owner_name is not None:
cur.execute(
"UPDATE devices SET owner_name = %s WHERE id = %s",
(owner_name, device_id)
)
if car_model is not None:
cur.execute(
"UPDATE devices SET car_model = %s WHERE id = %s",
(car_model, device_id)
)
if device_name is not None:
cur.execute(
"UPDATE devices SET device_name = %s WHERE id = %s",
(device_name, device_id)
)
if avatar_path is not None:
cur.execute(
"UPDATE devices SET avatar_path = %s WHERE id = %s",
(avatar_path, device_id)
)
return True, {}