diff --git a/server/app.py b/server/app.py index eb3650c..80229d8 100644 --- a/server/app.py +++ b/server/app.py @@ -17,6 +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 # ── Geocode cache ────────────────────────────────────────────────────────── try: @@ -618,6 +619,44 @@ def portal_trip_compat(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(): app.run(host="0.0.0.0", port=8098) diff --git a/server/modules/db.py b/server/modules/db.py index 092747d..04b505f 100644 --- a/server/modules/db.py +++ b/server/modules/db.py @@ -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( diff --git a/server/modules/device_config.py b/server/modules/device_config.py new file mode 100644 index 0000000..e0bff12 --- /dev/null +++ b/server/modules/device_config.py @@ -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, {} + diff --git a/server/sql/migrate_phase25_5a.sql b/server/sql/migrate_phase25_5a.sql new file mode 100644 index 0000000..457334e --- /dev/null +++ b/server/sql/migrate_phase25_5a.sql @@ -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);