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
+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);