- 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]>
270 lines
16 KiB
HTML
270 lines
16 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Settings - Yaris OBD{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
|
|
<div>
|
|
<h3 class="text-dark mb-0 fw-bold">Settings</h3>
|
|
<p class="text-muted small mb-0">Stored locally on this Pi.</p>
|
|
</div>
|
|
<button class="btn btn-primary btn-sm d-none d-md-inline-flex align-items-center gap-1 settings-save"
|
|
type="submit" form="settings-form" disabled>
|
|
<i class="fas fa-save"></i> Save settings
|
|
</button>
|
|
</div>
|
|
|
|
<div id="settings-message" class="alert" role="status" hidden></div>
|
|
|
|
<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 ──────────────────────────────────────────── #}
|
|
<div class="card shadow-sm mb-3">
|
|
<div class="card-header py-2">
|
|
<p class="text-primary m-0 fw-bold small">Uploads & local video</p>
|
|
</div>
|
|
<div class="card-body py-3">
|
|
<div class="form-check form-switch mb-2">
|
|
<input class="form-check-input" type="checkbox" role="switch" id="trips-upload-enabled">
|
|
<label class="form-check-label" for="trips-upload-enabled">Upload trips & GPS logs to server</label>
|
|
</div>
|
|
<div class="form-check form-switch mb-2">
|
|
<input class="form-check-input" type="checkbox" role="switch" id="dashcam-upload-enabled">
|
|
<label class="form-check-label" for="dashcam-upload-enabled">Upload dashcam videos to server (WiFi only)</label>
|
|
</div>
|
|
<p id="upload-health" class="small text-muted mb-3">Checking…</p>
|
|
<div class="d-flex align-items-center gap-2">
|
|
<label class="form-label mb-0 text-nowrap" for="retention-days">Delete after</label>
|
|
<div class="input-group input-group-sm" style="max-width:10rem;">
|
|
<input class="form-control" id="retention-days" type="number" min="0" max="3650" step="1" required>
|
|
<span class="input-group-text">days</span>
|
|
</div>
|
|
<span class="small text-muted">(0 = keep forever)</span>
|
|
</div>
|
|
<div class="invalid-feedback" data-error-for="local_video_retention_days"></div>
|
|
<div class="form-check form-switch mt-3">
|
|
<input class="form-check-input" type="checkbox" role="switch" id="video-convert-enabled">
|
|
<label class="form-check-label" for="video-convert-enabled">
|
|
Convert imported videos for web playback
|
|
<span class="text-muted small d-block">Re-encodes MOV→MP4 using hardware encoder in background after import. Original deleted when done.</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{# ── Phone hotspot ───────────────────────────────────────────────── #}
|
|
<div class="card shadow-sm mb-3">
|
|
<div class="card-header py-2">
|
|
<p class="text-primary m-0 fw-bold small">Phone hotspot internet</p>
|
|
</div>
|
|
<div class="card-body py-3">
|
|
<p id="uplink-status" class="mb-2 small fw-semibold">Checking network mode…</p>
|
|
<p id="last-phone-address" class="small mb-3" hidden>
|
|
Last phone IP: <a id="last-phone-address-link" href="#"></a>
|
|
<span class="text-muted">(may change after reconnecting)</span>
|
|
</p>
|
|
<div class="row g-2 mb-3">
|
|
<div class="col-sm-6">
|
|
<label class="form-label form-label-sm" for="phone-hotspot-ssid">Hotspot name</label>
|
|
<input class="form-control form-control-sm" id="phone-hotspot-ssid" maxlength="32" autocomplete="off">
|
|
</div>
|
|
<div class="col-sm-6">
|
|
<label class="form-label form-label-sm" for="phone-hotspot-password">Password</label>
|
|
<input class="form-control form-control-sm" id="phone-hotspot-password" type="password"
|
|
minlength="8" maxlength="63" autocomplete="new-password">
|
|
</div>
|
|
</div>
|
|
<div id="uplink-message" class="alert py-2 mt-2 mb-0" role="status" hidden></div>
|
|
<div class="d-flex flex-wrap gap-2 mb-2">
|
|
<button class="btn btn-sm btn-primary" id="phone-hotspot-save" type="button">Save</button>
|
|
<button class="btn btn-sm btn-outline-primary" id="phone-hotspot-connect" type="button" disabled>Wait for hotspot</button>
|
|
<button class="btn btn-sm btn-outline-danger" id="phone-hotspot-remove" type="button" disabled>Remove</button>
|
|
</div>
|
|
<p class="form-text small mb-0">
|
|
Save while connected to home WiFi, then tap <strong>Wait for hotspot</strong> and enable the phone hotspot within 2 min.
|
|
Password is write-only. If the Pi hotspot doesn't appear via <strong>yaris.local</strong>, use the last phone IP above.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{# ── Cellular + ntfy (collapsible together) ─────────────────────── #}
|
|
<div class="card shadow-sm mb-3">
|
|
<div class="card-header py-2 d-flex align-items-center justify-content-between"
|
|
style="cursor:pointer;" data-bs-toggle="collapse" data-bs-target="#connectivity-body"
|
|
aria-expanded="false" aria-controls="connectivity-body">
|
|
<p class="text-primary m-0 fw-bold small">Cellular & notifications</p>
|
|
<i class="fas fa-chevron-down text-muted small collapse-chevron"></i>
|
|
</div>
|
|
<div class="collapse" id="connectivity-body">
|
|
<div class="card-body py-3">
|
|
{# Cellular #}
|
|
<p class="fw-semibold small text-secondary text-uppercase mb-2">SIM868 Cellular</p>
|
|
<div class="form-check form-switch mb-2">
|
|
<input class="form-check-input" type="checkbox" role="switch" id="cellular-enabled">
|
|
<label class="form-check-label" for="cellular-enabled">Enable SIM868 cellular fallback</label>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label form-label-sm" for="cellular-apn">Carrier APN</label>
|
|
<input class="form-control form-control-sm" id="cellular-apn" maxlength="100"
|
|
autocomplete="off" placeholder="example.apn">
|
|
<div class="invalid-feedback" data-error-for="cellular_apn"></div>
|
|
<div class="form-text">Small trip-log batches only. Dashcam video never uses cellular.</div>
|
|
</div>
|
|
<hr class="my-3">
|
|
{# ntfy #}
|
|
<p class="fw-semibold small text-secondary text-uppercase mb-2">ntfy push notifications</p>
|
|
<div class="form-check form-switch mb-2">
|
|
<input class="form-check-input" type="checkbox" role="switch" id="ntfy-enabled">
|
|
<label class="form-check-label" for="ntfy-enabled">Send alerts via ntfy</label>
|
|
</div>
|
|
<div class="mb-2">
|
|
<label class="form-label form-label-sm" for="ntfy-url">Topic URL</label>
|
|
<input class="form-control form-control-sm" id="ntfy-url" type="url" maxlength="2048"
|
|
autocomplete="off" placeholder="https://ntfy.sh/my-topic">
|
|
<div class="invalid-feedback" data-error-for="ntfy_url"></div>
|
|
<div class="form-text" id="ntfy-url-state">Leave blank to keep saved URL.</div>
|
|
<div class="form-check mt-1">
|
|
<input class="form-check-input" type="checkbox" id="clear-ntfy-url">
|
|
<label class="form-check-label small" for="clear-ntfy-url">Remove saved URL</label>
|
|
</div>
|
|
</div>
|
|
<div class="mb-0">
|
|
<label class="form-label form-label-sm" for="ntfy-token">Access token</label>
|
|
<input class="form-control form-control-sm" id="ntfy-token" type="password"
|
|
maxlength="512" autocomplete="new-password" placeholder="Optional">
|
|
<div class="invalid-feedback" data-error-for="ntfy_token"></div>
|
|
<div class="form-text" id="ntfy-token-state">Leave blank to keep saved token.</div>
|
|
<div class="form-check mt-1">
|
|
<input class="form-check-input" type="checkbox" id="clear-ntfy-token">
|
|
<label class="form-check-label small" for="clear-ntfy-token">Remove saved token</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{# ── Alert thresholds (collapsible) ──────────────────────────────── #}
|
|
<div class="card shadow-sm mb-3">
|
|
<div class="card-header py-2 d-flex align-items-center justify-content-between"
|
|
style="cursor:pointer;" data-bs-toggle="collapse" data-bs-target="#thresholds-body"
|
|
aria-expanded="false" aria-controls="thresholds-body">
|
|
<p class="text-primary m-0 fw-bold small">Alert thresholds</p>
|
|
<i class="fas fa-chevron-down text-muted small collapse-chevron"></i>
|
|
</div>
|
|
<div class="collapse" id="thresholds-body">
|
|
<div class="card-body py-3">
|
|
<div class="row g-3">
|
|
<div class="col-6">
|
|
<label class="form-label form-label-sm" for="temperature-c">Pi temp</label>
|
|
<div class="input-group input-group-sm">
|
|
<input class="form-control" id="temperature-c" type="number" min="40" max="100" step="0.1" required>
|
|
<span class="input-group-text">°C</span>
|
|
</div>
|
|
<div class="invalid-feedback" data-error-for="temperature_c"></div>
|
|
</div>
|
|
<div class="col-6">
|
|
<label class="form-label form-label-sm" for="disk-percent">Disk usage</label>
|
|
<div class="input-group input-group-sm">
|
|
<input class="form-control" id="disk-percent" type="number" min="50" max="100" step="0.1" required>
|
|
<span class="input-group-text">%</span>
|
|
</div>
|
|
<div class="invalid-feedback" data-error-for="disk_percent"></div>
|
|
</div>
|
|
<div class="col-6">
|
|
<label class="form-label form-label-sm" for="tracking-backlog">Tracking backlog</label>
|
|
<input class="form-control form-control-sm" id="tracking-backlog" type="number" min="1" max="1000000" step="1" required>
|
|
<div class="invalid-feedback" data-error-for="tracking_backlog"></div>
|
|
</div>
|
|
<div class="col-6">
|
|
<label class="form-label form-label-sm" for="video-backlog">Video backlog</label>
|
|
<input class="form-control form-control-sm" id="video-backlog" type="number" min="1" max="100000" step="1" required>
|
|
<div class="invalid-feedback" data-error-for="video_backlog"></div>
|
|
</div>
|
|
<div class="col-6">
|
|
<label class="form-label form-label-sm" for="gps-loss-seconds">GPS loss</label>
|
|
<div class="input-group input-group-sm">
|
|
<input class="form-control" id="gps-loss-seconds" type="number" min="10" max="3600" step="1" required>
|
|
<span class="input-group-text">s</span>
|
|
</div>
|
|
<div class="invalid-feedback" data-error-for="gps_loss_seconds"></div>
|
|
</div>
|
|
<div class="col-6">
|
|
<label class="form-label form-label-sm" for="repeat-seconds">Repeat interval</label>
|
|
<div class="input-group input-group-sm">
|
|
<input class="form-control" id="repeat-seconds" type="number" min="60" max="604800" step="1" required>
|
|
<span class="input-group-text">s</span>
|
|
</div>
|
|
<div class="invalid-feedback" data-error-for="repeat_seconds"></div>
|
|
</div>
|
|
</div>
|
|
<p class="text-muted small mt-3 mb-0">Web UI alerts are always shown regardless of ntfy.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button class="btn btn-primary w-100 d-md-none settings-save mb-4" type="submit" disabled>
|
|
<i class="fas fa-save me-1"></i>Save settings
|
|
</button>
|
|
</form>
|
|
|
|
<script>
|
|
// Rotate chevrons on collapsible card headers
|
|
document.querySelectorAll('[data-bs-toggle="collapse"]').forEach(function (trigger) {
|
|
const targetId = trigger.getAttribute('data-bs-target');
|
|
const target = document.querySelector(targetId);
|
|
if (!target) return;
|
|
const chevron = trigger.querySelector('.collapse-chevron');
|
|
if (!chevron) return;
|
|
target.addEventListener('show.bs.collapse', function () { chevron.style.transform = 'rotate(180deg)'; });
|
|
target.addEventListener('hide.bs.collapse', function () { chevron.style.transform = ''; });
|
|
});
|
|
</script>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script src="{{ url_for('static', filename='js/settings.js') }}"></script>
|
|
{% endblock %}
|