From d7b9bb634f8c3341af84a8d0198badce22c5763d Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Mon, 10 Aug 2026 16:41:45 -0400 Subject: [PATCH] feat: add device config settings UI with optional server sync - Add app_device_config.py module for managing device configuration (owner_name, car_model, avatar_path, device_name) - Add sync_enabled flag to control whether Pi syncs config from server - When sync is enabled, device config fields are read-only (server is source of truth) - When sync is disabled, user can edit device config via Settings page - Add /api/device-config endpoint (GET to retrieve, POST to update) - Add Device identity section to settings.html with form fields for all config values - Update settings.js to load/save device config with sync toggle - Gracefully handles both config file (at startup) and database (live updates via settings UI) - All 63 tests passing Phase 25.5: Offline-first device config with eventual consistency. Config loads from JSON at startup (no network needed), can be edited via settings if not syncing, and will sync from server on next connectivity when Phase 25.5a server endpoints are implemented. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app.py | 12 +++- modules/app_account.py | 3 +- modules/app_car.py | 3 +- modules/app_device_config.py | 114 +++++++++++++++++++++++++++++++++++ static/js/settings.js | 64 ++++++++++++++++++++ templates/settings.html | 43 +++++++++++++ 6 files changed, 236 insertions(+), 3 deletions(-) create mode 100644 modules/app_device_config.py diff --git a/app.py b/app.py index b32e6cb..5c43081 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,5 @@ from flask import Flask, render_template, make_response, json, jsonify, request, send_file, Response -from modules import app_gps, app_events, app_car, app_account, app_obd, app_sql, app_dash, app_upload, app_video, app_health, app_alerts, app_settings, app_network +from modules import app_gps, app_events, app_car, app_account, app_obd, app_sql, app_dash, app_upload, app_video, app_health, app_alerts, app_settings, app_network, app_device_config import os, time from gevent.pywsgi import WSGIServer @@ -44,6 +44,16 @@ def api_settings(): return jsonify(error="Invalid settings.", fields=errors), 400 return jsonify(app_settings.get_settings()) +@app.route('/api/device-config', methods=['GET', 'POST']) +def api_device_config(): + if request.method == 'GET': + return jsonify(app_device_config.get_device_config()) + errors = {} + app_device_config.update_device_config(request.get_json(silent=True), errors) + if errors: + return jsonify(error="Invalid device config.", fields=errors), 400 + return jsonify(app_device_config.get_device_config()) + @app.route('/api/uplink/status') def uplink_status(): try: diff --git a/modules/app_account.py b/modules/app_account.py index 0ccb15d..afe970c 100644 --- a/modules/app_account.py +++ b/modules/app_account.py @@ -3,7 +3,8 @@ import os class Account: def __init__(self): - # Load from config/device.json if available, else use defaults + # Load from config/device.json at startup + # Database values are updated via Settings UI, but Account object reflects startup state config = self._load_config() self.name = config.get("owner_name", "quoije") self.avatar_path = config.get("avatar_path", "img/yaris.png") diff --git a/modules/app_car.py b/modules/app_car.py index e8b9dfd..b679add 100644 --- a/modules/app_car.py +++ b/modules/app_car.py @@ -3,7 +3,8 @@ import os class Car: def __init__(self): - # Load from config/device.json if available, else use defaults + # Load from config/device.json at startup + # Database values are updated via Settings UI, but Car object reflects startup state config = self._load_config() self.model = config.get("car_model", "Yaris 2013") self.device_name = config.get("device_name", "Yaris OBD") diff --git a/modules/app_device_config.py b/modules/app_device_config.py new file mode 100644 index 0000000..185ceb4 --- /dev/null +++ b/modules/app_device_config.py @@ -0,0 +1,114 @@ +""" +Device configuration management (owner name, car model, avatar, sync status). +Stores in SQLite with option to sync from server (Phase 25.5a). +""" + +import json +import os +from . import app_sql + + +def get_device_config(): + """Get current device config including sync status.""" + sync_enabled = _get_sync_enabled() + config = { + "owner_name": app_sql.get_setting("device_owner_name", ""), + "car_model": app_sql.get_setting("device_car_model", ""), + "device_name": app_sql.get_setting("device_device_name", ""), + "avatar_path": app_sql.get_setting("device_avatar_path", ""), + "sync_enabled": sync_enabled, + } + # Fill in any missing values from config file or defaults + config = _apply_defaults(config) + return config + + +def update_device_config(body, errors): + """Update device config from request body. Returns errors dict if invalid.""" + if not isinstance(body, dict): + errors["error"] = "Invalid request body" + return errors + + # Check if sync is enabled; if so, only allow sync-related updates + sync_enabled = _get_sync_enabled() + if sync_enabled: + # User cannot edit these fields if syncing from server + if any(k in body for k in ["owner_name", "car_model", "avatar_path"]): + errors["error"] = "Cannot edit device config while server sync is enabled. Server is source of truth." + # Allow toggling sync_enabled off + if "sync_enabled" in body and not body["sync_enabled"]: + _set_sync_enabled(False) + return errors + + # Not syncing: allow all edits + if "owner_name" in body: + value = str(body["owner_name"]).strip() + if not value or len(value) > 100: + errors["owner_name"] = "Owner name must be 1-100 characters." + else: + app_sql.set_setting("device_owner_name", value) + + if "car_model" in body: + value = str(body["car_model"]).strip() + if not value or len(value) > 100: + errors["car_model"] = "Car model must be 1-100 characters." + else: + app_sql.set_setting("device_car_model", value) + + if "device_name" in body: + value = str(body["device_name"]).strip() + if not value or len(value) > 100: + errors["device_name"] = "Device name must be 1-100 characters." + else: + app_sql.set_setting("device_device_name", value) + + if "avatar_path" in body: + value = str(body["avatar_path"]).strip() + if not value or len(value) > 255: + errors["avatar_path"] = "Avatar path must be 1-255 characters." + else: + app_sql.set_setting("device_avatar_path", value) + + if "sync_enabled" in body: + if not isinstance(body["sync_enabled"], bool): + errors["sync_enabled"] = "Must be true or false." + else: + _set_sync_enabled(body["sync_enabled"]) + + return errors + + +def _apply_defaults(config): + """Fill in any empty config values from config/device.json or hardcoded defaults.""" + config_file_path = os.path.join( + os.path.dirname(os.path.dirname(__file__)), "config", "device.json" + ) + file_config = {} + try: + if os.path.exists(config_file_path): + with open(config_file_path, "r") as f: + file_config = json.load(f) + except (IOError, json.JSONDecodeError): + pass + + # Use file config as source of truth for initial values, fallback to hardcoded + defaults = { + "owner_name": "quoije", + "car_model": "Yaris 2013", + "device_name": "Yaris OBD", + "avatar_path": "img/yaris.png", + } + for key in defaults: + if not config[key]: + config[key] = file_config.get(key, defaults[key]) + return config + + +def _get_sync_enabled(): + """Check if device config sync from server is enabled.""" + return app_sql.get_setting("device_sync_enabled", "0") == "1" + + +def _set_sync_enabled(enabled): + """Set device config sync status.""" + app_sql.set_setting("device_sync_enabled", "1" if enabled else "0") diff --git a/static/js/settings.js b/static/js/settings.js index a2419cc..9db825b 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -30,6 +30,11 @@ const fields = { videoBacklog: document.getElementById('video-backlog'), gpsLoss: document.getElementById('gps-loss-seconds'), repeat: document.getElementById('repeat-seconds'), + deviceSyncEnabled: document.getElementById('device-sync-enabled'), + deviceOwnerName: document.getElementById('device-owner-name'), + deviceCarModel: document.getElementById('device-car-model'), + deviceAvatarPath: document.getElementById('device-avatar-path'), + deviceName: document.getElementById('device-name'), }; function showMessage(text, kind) { @@ -69,6 +74,10 @@ function applyErrors(errors) { video_backlog: fields.videoBacklog, gps_loss_seconds: fields.gpsLoss, repeat_seconds: fields.repeat, + owner_name: fields.deviceOwnerName, + car_model: fields.deviceCarModel, + avatar_path: fields.deviceAvatarPath, + device_name: fields.deviceName, }; Object.entries(errors).forEach(([name, text]) => { const input = errorElements[name]; @@ -105,6 +114,23 @@ function populate(settings) { fields.clearNtfyToken.checked = false; } +function populateDeviceConfig(deviceConfig) { + fields.deviceSyncEnabled.checked = deviceConfig.sync_enabled; + fields.deviceOwnerName.value = deviceConfig.owner_name; + fields.deviceCarModel.value = deviceConfig.car_model; + fields.deviceAvatarPath.value = deviceConfig.avatar_path; + fields.deviceName.value = deviceConfig.device_name || ''; + updateDeviceConfigFieldsState(); +} + +function updateDeviceConfigFieldsState() { + const syncEnabled = fields.deviceSyncEnabled.checked; + const configFields = [fields.deviceOwnerName, fields.deviceCarModel, fields.deviceAvatarPath, fields.deviceName]; + configFields.forEach(field => { + field.disabled = syncEnabled; + }); +} + function payload() { return { trips_upload_enabled: fields.tripsUpload.checked, @@ -129,6 +155,16 @@ function payload() { }; } +function deviceConfigPayload() { + return { + sync_enabled: fields.deviceSyncEnabled.checked, + owner_name: fields.deviceOwnerName.value, + car_model: fields.deviceCarModel.value, + avatar_path: fields.deviceAvatarPath.value, + device_name: fields.deviceName.value, + }; +} + async function loadUploadHealth() { const element = document.getElementById('upload-health'); try { @@ -275,12 +311,25 @@ async function loadSettings() { } } +async function loadDeviceConfig() { + try { + const response = await fetch('/api/device-config'); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + populateDeviceConfig(await response.json()); + } catch (error) { + showMessage(`Could not load device config: ${error.message}`, 'danger'); + } +} + +fields.deviceSyncEnabled.addEventListener('change', updateDeviceConfigFieldsState); + form.addEventListener('submit', async event => { event.preventDefault(); clearErrors(); message.hidden = true; setSaving(true); try { + // Save main settings const response = await fetch('/api/settings', { method: 'POST', headers: {'Content-Type': 'application/json'}, @@ -292,6 +341,20 @@ form.addEventListener('submit', async event => { throw new Error(result.error || `HTTP ${response.status}`); } populate(result); + + // Save device config + const deviceConfigResponse = await fetch('/api/device-config', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(deviceConfigPayload()), + }); + const deviceConfigResult = await deviceConfigResponse.json(); + if (!deviceConfigResponse.ok) { + applyErrors(deviceConfigResult.fields || {}); + throw new Error(deviceConfigResult.error || `HTTP ${deviceConfigResponse.status}`); + } + populateDeviceConfig(deviceConfigResult); + showMessage('Settings saved.', 'success'); } catch (error) { if (message.hidden) showMessage(`Could not save settings: ${error.message}`, 'danger'); @@ -301,6 +364,7 @@ form.addEventListener('submit', async event => { }); loadSettings(); +loadDeviceConfig(); loadUploadHealth(); loadUplinkStatus(); setInterval(loadUplinkStatus, 15000); diff --git a/templates/settings.html b/templates/settings.html index ec66a68..3892ddb 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -18,6 +18,49 @@
+ {# ── Device identity ────────────────────────────────────────────── #} +
+
+

Device identity

+
+
+
+ + +
+
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
Relative path from static/ directory.
+
+
+ + +
+
Reserved for future server portal display.
+
+
+

Edit your device identity here. If server sync is enabled, these fields are read-only.

+
+
+
+ {# ── Upload & retention ──────────────────────────────────────────── #}