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 remoteConfigMessage = document.getElementById('remote-config-message'); const remoteConfigTestButton = document.getElementById('remote-config-test'); const remoteConfigSyncNowButton = document.getElementById('remote-config-sync-now'); 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'), odometerBaseline: document.getElementById('odometer-baseline'), remoteServerUrl: document.getElementById('remote-server-url'), remoteDeviceName: document.getElementById('remote-device-name'), remoteApiKey: document.getElementById('remote-api-key'), clearRemoteApiKey: document.getElementById('clear-remote-api-key'), }; // ── Pi hotspot (separate from main settings form) ────────────────────────── const apSsid = document.getElementById('ap-ssid'); const apPassword = document.getElementById('ap-password'); const apSave = document.getElementById('ap-save'); const apMessage = document.getElementById('ap-message'); function showApMessage(text, kind) { apMessage.textContent = text; apMessage.className = `alert alert-${kind} py-2 mb-3`; apMessage.hidden = false; } async function loadApConfig() { try { const r = await fetch('/api/hotspot'); if (!r.ok) throw new Error('Cannot load hotspot config'); const d = await r.json(); apSsid.value = d.ssid || ''; } catch (e) { apSsid.placeholder = '(cannot read)'; } } apSave.addEventListener('click', async function () { apSave.disabled = true; apMessage.hidden = true; try { const r = await fetch('/api/hotspot', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ ssid: apSsid.value.trim(), password: apPassword.value, }), }); const d = await r.json(); if (!r.ok) throw new Error(d.error || 'Save failed'); apPassword.value = ''; showApMessage(d.message || 'Hotspot updated. Change takes effect on next restart or mode switch.', 'success'); } catch (e) { showApMessage(e.message, 'danger'); } finally { apSave.disabled = false; } }); 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 showRemoteConfigMessage(text, kind) { remoteConfigMessage.textContent = text; remoteConfigMessage.className = `alert alert-${kind} py-2 mb-3`; remoteConfigMessage.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, odometer_baseline: Number(fields.odometerBaseline.value) || 0, server_url: fields.remoteServerUrl, remote_device_name: fields.remoteDeviceName, api_key: fields.remoteApiKey, }; 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; fields.odometerBaseline.value = (settings.odometer_baseline || 0).toFixed(1); } 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 || ''; fields.odometerBaseline.value = (deviceConfig.odometer_baseline || 0).toFixed(1); updateDeviceConfigFieldsState(); } function populateRemoteConfig(remoteConfig) { fields.remoteServerUrl.value = remoteConfig.server_url || ''; fields.remoteDeviceName.value = remoteConfig.device_name || ''; fields.remoteApiKey.value = ''; fields.clearRemoteApiKey.checked = false; document.getElementById('remote-api-key-state').textContent = remoteConfig.api_key_configured ? 'An API key is saved. Leave blank to keep it unchanged.' : 'No API key is saved yet.'; } 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), }, odometer_baseline: Number(fields.odometerBaseline.value) || 0, }; } 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, }; } function remoteConfigPayload() { return { server_url: fields.remoteServerUrl.value, device_name: fields.remoteDeviceName.value, api_key: fields.remoteApiKey.value, clear_api_key: fields.clearRemoteApiKey.checked, }; } 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); // In-car access card const hotspotInfo = document.getElementById('in-car-hotspot-info'); const phoneInfo = document.getElementById('in-car-phone-info'); const wifiInfo = document.getElementById('in-car-wifi-info'); const disconnectedInfo = document.getElementById('in-car-disconnected-info'); const hotspotUrl = document.getElementById('hotspot-url'); const phoneUrl = document.getElementById('phone-url'); const copyBtn = document.getElementById('copy-hotspot-url'); const copyPhoneBtn = document.getElementById('copy-phone-url'); if (status.mode === 'phone' && status.ip_address) { phoneUrl.textContent = `http://${status.ip_address}`; hotspotInfo.hidden = true; phoneInfo.hidden = false; wifiInfo.hidden = true; disconnectedInfo.hidden = true; if (copyPhoneBtn) copyPhoneBtn.onclick = () => navigator.clipboard.writeText(`http://${status.ip_address}`); } else if (status.ap_active && status.ap_ip) { hotspotUrl.textContent = `http://${status.ap_ip}`; hotspotInfo.hidden = false; phoneInfo.hidden = true; wifiInfo.hidden = true; disconnectedInfo.hidden = true; if (copyBtn) copyBtn.onclick = () => navigator.clipboard.writeText(`http://${status.ap_ip}`); } else if (status.mode === 'wifi') { hotspotInfo.hidden = true; phoneInfo.hidden = true; wifiInfo.hidden = false; disconnectedInfo.hidden = true; } else { hotspotInfo.hidden = true; phoneInfo.hidden = true; wifiInfo.hidden = true; disconnectedInfo.hidden = false; } 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'); } } async function loadRemoteConfig() { try { const response = await fetch('/api/remote-config'); if (!response.ok) throw new Error(`HTTP ${response.status}`); populateRemoteConfig(await response.json()); } catch (error) { showMessage(`Could not load remote config: ${error.message}`, 'danger'); } } fields.deviceSyncEnabled.addEventListener('change', updateDeviceConfigFieldsState); remoteConfigTestButton.addEventListener('click', async () => { remoteConfigMessage.hidden = true; remoteConfigTestButton.disabled = true; try { const response = await fetch('/api/remote-config/test', {method: 'POST'}); const result = await response.json(); if (!response.ok) throw new Error(result.error || `HTTP ${response.status}`); showRemoteConfigMessage(result.message || 'Remote server connection looks good.', 'success'); } catch (error) { showRemoteConfigMessage(error.message, 'danger'); } finally { remoteConfigTestButton.disabled = false; } }); remoteConfigSyncNowButton.addEventListener('click', async () => { remoteConfigMessage.hidden = true; remoteConfigSyncNowButton.disabled = true; try { const response = await fetch('/api/remote-config/sync-now', {method: 'POST'}); const result = await response.json(); if (!response.ok) throw new Error(result.message || `HTTP ${response.status}`); await loadDeviceConfig(); showRemoteConfigMessage(result.message || 'Sync completed.', 'success'); } catch (error) { showRemoteConfigMessage(error.message, 'danger'); } finally { remoteConfigSyncNowButton.disabled = false; } }); form.addEventListener('submit', async event => { event.preventDefault(); clearErrors(); message.hidden = true; remoteConfigMessage.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 remote server credentials before device sync toggle validation const remoteConfigResponse = await fetch('/api/remote-config', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(remoteConfigPayload()), }); const remoteConfigResult = await remoteConfigResponse.json(); if (!remoteConfigResponse.ok) { applyErrors(remoteConfigResult.fields || {}); throw new Error(remoteConfigResult.error || `HTTP ${remoteConfigResponse.status}`); } populateRemoteConfig(remoteConfigResult); // Save device config (always send — backend validates locked fields when sync is on) 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(); loadRemoteConfig(); loadUploadHealth(); loadUplinkStatus(); loadApConfig(); setInterval(loadUplinkStatus, 15000);