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:
@@ -17,6 +17,7 @@ from flask_login import login_user, logout_user, current_user
|
|||||||
from modules import db
|
from modules import db
|
||||||
from modules.auth import require_device_auth
|
from modules.auth import require_device_auth
|
||||||
from modules.user_auth import login_manager, User, check_password, require_login
|
from modules.user_auth import login_manager, User, check_password, require_login
|
||||||
|
from modules import device_config
|
||||||
|
|
||||||
# ── Geocode cache ──────────────────────────────────────────────────────────
|
# ── Geocode cache ──────────────────────────────────────────────────────────
|
||||||
try:
|
try:
|
||||||
@@ -618,6 +619,44 @@ def portal_trip_compat(trip_id):
|
|||||||
return portal_trip_detail(trip_id)
|
return portal_trip_detail(trip_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Phase 25.5a: Device configuration sync ──────────────────────────────────
|
||||||
|
|
||||||
|
@app.get("/api/device-config")
|
||||||
|
@require_device_auth
|
||||||
|
def api_device_config_get():
|
||||||
|
"""Get device configuration for the authenticated device.
|
||||||
|
|
||||||
|
Pi calls this to fetch config from server (connection-driven sync).
|
||||||
|
Returns owner_name, car_model, device_name, avatar_path, config_version.
|
||||||
|
"""
|
||||||
|
config = device_config.get_device_config(g.device_id)
|
||||||
|
if not config:
|
||||||
|
return jsonify(error="device not found"), 404
|
||||||
|
return jsonify(config)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/device-config")
|
||||||
|
@require_login
|
||||||
|
def api_device_config_update():
|
||||||
|
"""Update device configuration (server portal/admin only).
|
||||||
|
|
||||||
|
Updates owner_name, car_model, device_name, avatar_path.
|
||||||
|
Increments config_version to signal change to Pi.
|
||||||
|
"""
|
||||||
|
device_id = _portal_device_id()
|
||||||
|
if device_id is None:
|
||||||
|
return jsonify(error="device not configured"), 404
|
||||||
|
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
success, errors = device_config.update_device_config(device_id, body)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
return jsonify(error="Invalid device config.", fields=errors), 400
|
||||||
|
|
||||||
|
config = device_config.get_device_config(device_id)
|
||||||
|
return jsonify(config)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
app.run(host="0.0.0.0", port=8098)
|
app.run(host="0.0.0.0", port=8098)
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,28 @@ def get_device_by_name(name):
|
|||||||
return {"id": row[0], "name": row[1], "api_key_hash": row[2]}
|
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):
|
def touch_device_last_seen(device_id):
|
||||||
with get_cursor(commit=True) as cur:
|
with get_cursor(commit=True) as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
|
|||||||
@@ -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, {}
|
||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Phase 25.5a: Device configuration sync
|
||||||
|
-- Adds columns to store device identity (name, car, avatar, etc) on server
|
||||||
|
-- Pi fetches and caches these values when internet available (connection-driven sync)
|
||||||
|
|
||||||
|
ALTER TABLE devices ADD COLUMN IF NOT EXISTS owner_name TEXT DEFAULT '';
|
||||||
|
ALTER TABLE devices ADD COLUMN IF NOT EXISTS car_model TEXT DEFAULT '';
|
||||||
|
ALTER TABLE devices ADD COLUMN IF NOT EXISTS device_name TEXT DEFAULT '';
|
||||||
|
ALTER TABLE devices ADD COLUMN IF NOT EXISTS avatar_path TEXT DEFAULT 'img/yaris.png';
|
||||||
|
ALTER TABLE devices ADD COLUMN IF NOT EXISTS config_version INTEGER DEFAULT 0;
|
||||||
|
ALTER TABLE devices ADD COLUMN IF NOT EXISTS config_updated_at TIMESTAMPTZ DEFAULT now();
|
||||||
|
|
||||||
|
-- Index for faster lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_devices_name ON devices(name);
|
||||||
Reference in New Issue
Block a user