Files
yaris-obd/static/js/settings.js
T
Mathieu MoranandCopilot afce229310 feat: odometer in trips/overview, trips upload toggle, fix video count
- OBD odometer now logged to tracking rows on every GPS snapshot
- trips table: START_ODOMETER/END_ODOMETER columns (migration)
- build_trip_summaries: picks odometer from first/last tracking row
- list_trips: uses real OBD odo, falls back to cumulative distance
- overview stats: odometer_km (max seen from OBD), video_count fixed
- history page: Odometer column shows start→end km per trip
- app_upload: TRIPS_UPLOAD_SETTING_KEY, is_trips_upload_enabled()
- _upload_tracking_batch: skips when trips upload is off
- settings: separate 'Upload trips & GPS' toggle from dashcam toggle

Co-authored-by: Copilot <[email protected]>
2026-08-10 02:07:31 -04:00

307 lines
12 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'),
};
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,
};
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 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),
},
};
}
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');
}
}
form.addEventListener('submit', async event => {
event.preventDefault();
clearErrors();
message.hidden = true;
setSaving(true);
try {
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);
showMessage('Settings saved.', 'success');
} catch (error) {
if (message.hidden) showMessage(`Could not save settings: ${error.message}`, 'danger');
} finally {
setSaving(false);
}
});
loadSettings();
loadUploadHealth();
loadUplinkStatus();
setInterval(loadUplinkStatus, 15000);