Files
yaris-obd/static/js/settings.js
T
Mathieu MoranandCopilot d7b9bb634f 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]>
2026-08-10 16:41:45 -04:00

371 lines
15 KiB
JavaScript

const form = document.getElementById('settings-form');
const saveButtons = Array.from(document.querySelectorAll('.settings-save'));
const message = document.getElementById('settings-message');
const uplinkStatus = document.getElementById('uplink-status');
const uplinkMessage = document.getElementById('uplink-message');
const phoneSsid = document.getElementById('phone-hotspot-ssid');
const phonePassword = document.getElementById('phone-hotspot-password');
const phoneSave = document.getElementById('phone-hotspot-save');
const phoneConnect = document.getElementById('phone-hotspot-connect');
const phoneRemove = document.getElementById('phone-hotspot-remove');
const lastPhoneAddress = document.getElementById('last-phone-address');
const lastPhoneAddressLink = document.getElementById('last-phone-address-link');
let initialUplinkStatusLoaded = false;
const fields = {
tripsUpload: document.getElementById('trips-upload-enabled'),
dashcamUpload: document.getElementById('dashcam-upload-enabled'),
retention: document.getElementById('retention-days'),
videoConvert: document.getElementById('video-convert-enabled'),
cellularEnabled: document.getElementById('cellular-enabled'),
cellularApn: document.getElementById('cellular-apn'),
ntfyEnabled: document.getElementById('ntfy-enabled'),
ntfyUrl: document.getElementById('ntfy-url'),
ntfyToken: document.getElementById('ntfy-token'),
clearNtfyUrl: document.getElementById('clear-ntfy-url'),
clearNtfyToken: document.getElementById('clear-ntfy-token'),
temperature: document.getElementById('temperature-c'),
disk: document.getElementById('disk-percent'),
trackingBacklog: document.getElementById('tracking-backlog'),
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) {
message.textContent = text;
message.className = `alert alert-${kind}`;
message.hidden = false;
}
function showUplinkMessage(text, kind) {
uplinkMessage.textContent = text;
uplinkMessage.className = `alert alert-${kind} mt-3 mb-0`;
uplinkMessage.hidden = false;
}
function setSaving(disabled) {
saveButtons.forEach(button => {
button.disabled = disabled;
});
}
function clearErrors() {
form.querySelectorAll('.is-invalid').forEach(element => element.classList.remove('is-invalid'));
form.querySelectorAll('[data-error-for]').forEach(element => {
element.textContent = '';
});
}
function applyErrors(errors) {
const errorElements = {
local_video_retention_days: fields.retention,
cellular_apn: fields.cellularApn,
ntfy_url: fields.ntfyUrl,
ntfy_token: fields.ntfyToken,
temperature_c: fields.temperature,
disk_percent: fields.disk,
tracking_backlog: fields.trackingBacklog,
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];
const feedback = form.querySelector(`[data-error-for="${name}"]`);
if (input) input.classList.add('is-invalid');
if (feedback) feedback.textContent = text;
});
}
function populate(settings) {
const thresholds = settings.alert_thresholds;
fields.tripsUpload.checked = settings.trips_upload_enabled;
fields.dashcamUpload.checked = settings.dashcam_upload_enabled;
fields.retention.value = settings.local_video_retention_days;
fields.videoConvert.checked = settings.video_convert_enabled;
fields.cellularEnabled.checked = settings.cellular_enabled;
fields.cellularApn.value = settings.cellular_apn;
fields.ntfyEnabled.checked = settings.ntfy_enabled;
fields.temperature.value = thresholds.temperature_c;
fields.disk.value = thresholds.disk_percent;
fields.trackingBacklog.value = thresholds.tracking_backlog;
fields.videoBacklog.value = thresholds.video_backlog;
fields.gpsLoss.value = thresholds.gps_loss_seconds;
fields.repeat.value = thresholds.repeat_seconds;
document.getElementById('ntfy-url-state').textContent = settings.ntfy_url_configured
? 'A URL is saved. Leave blank to keep it unchanged.'
: 'No URL is saved.';
document.getElementById('ntfy-token-state').textContent = settings.ntfy_token_configured
? 'A token is saved. Leave blank to keep it unchanged.'
: 'No token is saved; public ntfy topics do not require one.';
fields.ntfyUrl.value = '';
fields.ntfyToken.value = '';
fields.clearNtfyUrl.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() {
return {
trips_upload_enabled: fields.tripsUpload.checked,
dashcam_upload_enabled: fields.dashcamUpload.checked,
local_video_retention_days: Number(fields.retention.value),
video_convert_enabled: fields.videoConvert.checked,
cellular_enabled: fields.cellularEnabled.checked,
cellular_apn: fields.cellularApn.value,
ntfy_enabled: fields.ntfyEnabled.checked,
ntfy_url: fields.ntfyUrl.value,
ntfy_token: fields.ntfyToken.value,
clear_ntfy_url: fields.clearNtfyUrl.checked,
clear_ntfy_token: fields.clearNtfyToken.checked,
alert_thresholds: {
temperature_c: Number(fields.temperature.value),
disk_percent: Number(fields.disk.value),
tracking_backlog: Number(fields.trackingBacklog.value),
video_backlog: Number(fields.videoBacklog.value),
gps_loss_seconds: Number(fields.gpsLoss.value),
repeat_seconds: Number(fields.repeat.value),
},
};
}
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 {
const response = await fetch('/api/upload/status');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const status = await response.json();
if (!status.configured) {
element.textContent = 'Remote server upload is not configured on this Pi.';
element.className = 'small text-muted mb-3';
return;
}
const parts = [`${status.pending_tracking_rows} tracking row(s) pending.`];
if (status.last_success_at) {
parts.push(`Last success: ${status.last_success_at}${status.last_network_path ? ` via ${status.last_network_path}.` : '.'}`);
}
if (status.last_error) parts.push(`Last error: ${status.last_error}`);
element.textContent = parts.join(' ');
element.className = `small mb-3 ${status.last_error ? 'text-danger' : 'text-muted'}`;
} catch (error) {
element.textContent = `Could not load upload status: ${error.message}`;
element.className = 'small text-danger mb-3';
}
}
function describeUplink(status) {
const modeNames = {
phone: 'Phone hotspot',
local_hotspot: 'Pi local hotspot',
wifi: status.active_connection || 'WiFi',
disconnected: 'Disconnected',
};
const parts = [
`Mode: ${modeNames[status.mode] || status.mode}.`,
status.ip_address ? `IP: ${status.ip_address}.` : '',
status.internet_reachable ? 'Internet reachable.' : 'Internet not verified.',
];
if (status.last_transition && status.last_transition.error) {
parts.push(`Last error: ${status.last_transition.error}`);
}
return parts.filter(Boolean).join(' ');
}
async function loadUplinkStatus() {
try {
const response = await fetch('/api/uplink/status');
const status = await response.json();
if (!response.ok) throw new Error(status.error || `HTTP ${response.status}`);
uplinkStatus.textContent = describeUplink(status);
const phoneAddress = status.mode === 'phone'
? status.ip_address
: status.last_transition && status.last_transition.last_phone_ip;
lastPhoneAddress.hidden = !phoneAddress;
if (phoneAddress) {
const url = `http://${phoneAddress}/`;
lastPhoneAddressLink.href = url;
lastPhoneAddressLink.textContent = url;
}
if (
!initialUplinkStatusLoaded
&& status.phone_configured
&& !phoneSsid.value
) {
phoneSsid.value = status.phone_ssid;
}
initialUplinkStatusLoaded = true;
phoneConnect.disabled = !status.phone_configured;
phoneRemove.disabled = !status.phone_configured;
} catch (error) {
uplinkStatus.textContent = `Could not load network status: ${error.message}`;
uplinkStatus.className = 'text-danger mb-3';
}
}
phoneSave.addEventListener('click', async () => {
uplinkMessage.hidden = true;
phoneSave.disabled = true;
try {
const response = await fetch('/api/uplink/config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
ssid: phoneSsid.value,
password: phonePassword.value,
}),
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || `HTTP ${response.status}`);
phonePassword.value = '';
showUplinkMessage(
'Phone hotspot saved. Select Wait for hotspot, then enable the hotspot on your phone within two minutes.',
'success',
);
await loadUplinkStatus();
} catch (error) {
showUplinkMessage(`Could not save phone hotspot: ${error.message}`, 'danger');
} finally {
phoneSave.disabled = false;
}
});
phoneConnect.addEventListener('click', async () => {
phoneConnect.disabled = true;
try {
const response = await fetch('/api/uplink/connect', {method: 'POST'});
const result = await response.json();
if (!response.ok) throw new Error(result.error || `HTTP ${response.status}`);
showUplinkMessage(
'Waiting for the saved hotspot. Enable it on your phone within two minutes. This page may disconnect while networks switch.',
'info',
);
} catch (error) {
showUplinkMessage(`Could not request connection: ${error.message}`, 'danger');
} finally {
phoneConnect.disabled = false;
}
});
phoneRemove.addEventListener('click', async () => {
if (!window.confirm('Remove the saved phone hotspot from the Pi?')) return;
phoneRemove.disabled = true;
try {
const response = await fetch('/api/uplink/config', {method: 'DELETE'});
const result = await response.json();
if (!response.ok) throw new Error(result.error || `HTTP ${response.status}`);
phoneSsid.value = '';
phonePassword.value = '';
showUplinkMessage('Saved phone hotspot removed.', 'success');
await loadUplinkStatus();
} catch (error) {
showUplinkMessage(`Could not remove phone hotspot: ${error.message}`, 'danger');
phoneRemove.disabled = false;
}
});
async function loadSettings() {
setSaving(true);
try {
const response = await fetch('/api/settings');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
populate(await response.json());
setSaving(false);
} catch (error) {
showMessage(`Could not load settings: ${error.message}`, 'danger');
}
}
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'},
body: JSON.stringify(payload()),
});
const result = await response.json();
if (!response.ok) {
applyErrors(result.fields || {});
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');
} finally {
setSaving(false);
}
});
loadSettings();
loadDeviceConfig();
loadUploadHealth();
loadUplinkStatus();
setInterval(loadUplinkStatus, 15000);