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 <[email protected]>
This commit is contained in:
Mathieu Moran
2026-08-10 16:41:45 -04:00
co-authored by Copilot
parent c65a635906
commit d7b9bb634f
6 changed files with 236 additions and 3 deletions
+11 -1
View File
@@ -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:
+2 -1
View File
@@ -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")
+2 -1
View File
@@ -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")
+114
View File
@@ -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")
+64
View File
@@ -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);
+43
View File
@@ -18,6 +18,49 @@
<form id="settings-form">
{# ── Device identity ────────────────────────────────────────────── #}
<div class="card shadow-sm mb-3">
<div class="card-header py-2">
<p class="text-primary m-0 fw-bold small">Device identity</p>
</div>
<div class="card-body py-3">
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" role="switch" id="device-sync-enabled">
<label class="form-check-label" for="device-sync-enabled">
Sync with server
<span class="text-muted small d-block">If enabled, server is source of truth and these fields become read-only.</span>
</label>
</div>
<div id="device-config-fields">
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label form-label-sm" for="device-owner-name">Owner name</label>
<input class="form-control form-control-sm" id="device-owner-name" maxlength="100" required>
<div class="invalid-feedback" data-error-for="owner_name"></div>
</div>
<div class="col-md-6">
<label class="form-label form-label-sm" for="device-car-model">Car model</label>
<input class="form-control form-control-sm" id="device-car-model" maxlength="100" required>
<div class="invalid-feedback" data-error-for="car_model"></div>
</div>
<div class="col-md-6">
<label class="form-label form-label-sm" for="device-avatar-path">Avatar path</label>
<input class="form-control form-control-sm" id="device-avatar-path" maxlength="255" placeholder="img/yaris.png" required>
<div class="invalid-feedback" data-error-for="avatar_path"></div>
<div class="form-text">Relative path from <code>static/</code> directory.</div>
</div>
<div class="col-md-6">
<label class="form-label form-label-sm" for="device-name">Device name</label>
<input class="form-control form-control-sm" id="device-name" maxlength="100">
<div class="invalid-feedback" data-error-for="device_name"></div>
<div class="form-text">Reserved for future server portal display.</div>
</div>
</div>
<p class="text-muted small mb-0">Edit your device identity here. If server sync is enabled, these fields are read-only.</p>
</div>
</div>
</div>
{# ── Upload & retention ──────────────────────────────────────────── #}
<div class="card shadow-sm mb-3">
<div class="card-header py-2">