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:
@@ -1,5 +1,5 @@
|
|||||||
from flask import Flask, render_template, make_response, json, jsonify, request, send_file, Response
|
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
|
import os, time
|
||||||
from gevent.pywsgi import WSGIServer
|
from gevent.pywsgi import WSGIServer
|
||||||
|
|
||||||
@@ -44,6 +44,16 @@ def api_settings():
|
|||||||
return jsonify(error="Invalid settings.", fields=errors), 400
|
return jsonify(error="Invalid settings.", fields=errors), 400
|
||||||
return jsonify(app_settings.get_settings())
|
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')
|
@app.route('/api/uplink/status')
|
||||||
def uplink_status():
|
def uplink_status():
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import os
|
|||||||
|
|
||||||
class Account:
|
class Account:
|
||||||
def __init__(self):
|
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()
|
config = self._load_config()
|
||||||
self.name = config.get("owner_name", "quoije")
|
self.name = config.get("owner_name", "quoije")
|
||||||
self.avatar_path = config.get("avatar_path", "img/yaris.png")
|
self.avatar_path = config.get("avatar_path", "img/yaris.png")
|
||||||
|
|||||||
+2
-1
@@ -3,7 +3,8 @@ import os
|
|||||||
|
|
||||||
class Car:
|
class Car:
|
||||||
def __init__(self):
|
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()
|
config = self._load_config()
|
||||||
self.model = config.get("car_model", "Yaris 2013")
|
self.model = config.get("car_model", "Yaris 2013")
|
||||||
self.device_name = config.get("device_name", "Yaris OBD")
|
self.device_name = config.get("device_name", "Yaris OBD")
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -30,6 +30,11 @@ const fields = {
|
|||||||
videoBacklog: document.getElementById('video-backlog'),
|
videoBacklog: document.getElementById('video-backlog'),
|
||||||
gpsLoss: document.getElementById('gps-loss-seconds'),
|
gpsLoss: document.getElementById('gps-loss-seconds'),
|
||||||
repeat: document.getElementById('repeat-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) {
|
function showMessage(text, kind) {
|
||||||
@@ -69,6 +74,10 @@ function applyErrors(errors) {
|
|||||||
video_backlog: fields.videoBacklog,
|
video_backlog: fields.videoBacklog,
|
||||||
gps_loss_seconds: fields.gpsLoss,
|
gps_loss_seconds: fields.gpsLoss,
|
||||||
repeat_seconds: fields.repeat,
|
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]) => {
|
Object.entries(errors).forEach(([name, text]) => {
|
||||||
const input = errorElements[name];
|
const input = errorElements[name];
|
||||||
@@ -105,6 +114,23 @@ function populate(settings) {
|
|||||||
fields.clearNtfyToken.checked = false;
|
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() {
|
function payload() {
|
||||||
return {
|
return {
|
||||||
trips_upload_enabled: fields.tripsUpload.checked,
|
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() {
|
async function loadUploadHealth() {
|
||||||
const element = document.getElementById('upload-health');
|
const element = document.getElementById('upload-health');
|
||||||
try {
|
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 => {
|
form.addEventListener('submit', async event => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
clearErrors();
|
clearErrors();
|
||||||
message.hidden = true;
|
message.hidden = true;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
|
// Save main settings
|
||||||
const response = await fetch('/api/settings', {
|
const response = await fetch('/api/settings', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
@@ -292,6 +341,20 @@ form.addEventListener('submit', async event => {
|
|||||||
throw new Error(result.error || `HTTP ${response.status}`);
|
throw new Error(result.error || `HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
populate(result);
|
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');
|
showMessage('Settings saved.', 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (message.hidden) showMessage(`Could not save settings: ${error.message}`, 'danger');
|
if (message.hidden) showMessage(`Could not save settings: ${error.message}`, 'danger');
|
||||||
@@ -301,6 +364,7 @@ form.addEventListener('submit', async event => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
loadSettings();
|
loadSettings();
|
||||||
|
loadDeviceConfig();
|
||||||
loadUploadHealth();
|
loadUploadHealth();
|
||||||
loadUplinkStatus();
|
loadUplinkStatus();
|
||||||
setInterval(loadUplinkStatus, 15000);
|
setInterval(loadUplinkStatus, 15000);
|
||||||
|
|||||||
@@ -18,6 +18,49 @@
|
|||||||
|
|
||||||
<form id="settings-form">
|
<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 ──────────────────────────────────────────── #}
|
{# ── Upload & retention ──────────────────────────────────────────── #}
|
||||||
<div class="card shadow-sm mb-3">
|
<div class="card shadow-sm mb-3">
|
||||||
<div class="card-header py-2">
|
<div class="card-header py-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user