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 @@