feat: add remote device settings portal and Pi hot reload

- add /settings/device portal page and portal device-config GET endpoint
- add portal navigation and client-side device settings editor UI
- hot-reload Account/Car display config after local saves and server syncs
- document portal workflow and Phase 25.5b completion
- add runtime reload tests for account, car, and sync callback

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Mathieu Moran
2026-08-10 17:34:22 -04:00
co-authored by Copilot
parent c4ccc3efb9
commit 585da18225
12 changed files with 383 additions and 22 deletions
+26 -5
View File
@@ -1532,11 +1532,32 @@ phase roadmap.
service restarted cleanly, and unraid rebuilt the API stack after manually applying
`migrate_phase25_5a.sql`.
#### Next phase (Phase 25.5b)
- Hot-reload handler to apply config changes without restart
- Server portal UI for editing device config (admin page)
- Device settings page on portal to test config updates
- Mobile-friendly device management UI
### Phase 25.5b: Remote portal device settings + hot-reload — ✅ COMPLETE
**The remote editor and Pi-side hot-reload are now wired.** The server portal can
edit the device identity that the Pi sync thread consumes, and the running Pi UI
updates its display-facing values without a manual restart once sync lands.
#### Implemented (Phase 25.5b so far)
- **Remote portal page**: `/settings/device` renders a login-protected editor for
`owner_name`, `car_model`, `device_name`, and `avatar_path`
- **Portal JSON endpoint**: `GET /api/v1/portal/device-config` returns the current
config for the configured single-device deployment
- **Navigation**: desktop sidebar + mobile bottom nav now expose **Device settings**
- **Portal UX**: live avatar preview, config-version display, dirty-state save
button, and inline sync/explanation text
- **Pi hot-reload**:
- `Account.reload_runtime_config()` and `Car.reload_runtime_config()` refresh
display fields from the live SQLite-backed device config
- local `/api/device-config` saves now reload the in-process `account`/`car`
objects immediately
- server sync now invokes a registered reload callback after writing new config
locally, so next-page renders reflect synced values without restarting Flask
#### Validation
- Added runtime reload tests for `Account`, `Car`, and the sync callback path
- Existing suite still passes (`66 passed`)
- Remote deployment / live portal-path verification still recommended whenever the
server portal is updated
---
+9
View File
@@ -14,6 +14,14 @@ gps = app_gps.start_gps()
app_sql.start_logging()
app_upload.start_uploading()
app_alerts.start_alerting()
def _reload_runtime_device_config():
account.reload_runtime_config()
car.reload_runtime_config()
app_device_sync.register_reload_callback(_reload_runtime_device_config)
app_device_sync.start_sync()
@app.route('/')
@@ -53,6 +61,7 @@ def api_device_config():
app_device_config.update_device_config(request.get_json(silent=True), errors)
if errors:
return jsonify(error="Invalid device config.", fields=errors), 400
_reload_runtime_device_config()
return jsonify(app_device_config.get_device_config())
@app.route('/api/uplink/status')
+9 -2
View File
@@ -1,15 +1,22 @@
import json
import os
class Account:
def __init__(self):
# Load from config/device.json at startup
# Database values are updated via Settings UI, but Account object reflects startup state
config = self._load_config()
self.name = config.get("owner_name", "quoije")
self.avatar_path = config.get("avatar_path", "img/yaris.png")
self.pp_image = None
self.car = None
def reload_runtime_config(self):
"""Refresh display-facing fields from the live device config store."""
from . import app_device_config
config = app_device_config.get_device_config()
self.name = config.get("owner_name", "quoije")
self.avatar_path = config.get("avatar_path", "img/yaris.png")
def _load_config(self):
"""Load device config from JSON file, return dict with defaults."""
+10 -3
View File
@@ -1,15 +1,22 @@
import json
import os
class Car:
def __init__(self):
# Load from config/device.json at startup
# Database values are updated via Settings UI, but Car object reflects startup state
config = self._load_config()
self.model = config.get("car_model", "Yaris 2013")
self.device_name = config.get("device_name", "Yaris OBD")
self.obd = None
def reload_runtime_config(self):
"""Refresh display-facing fields from the live device config store."""
from . import app_device_config
config = app_device_config.get_device_config()
self.model = config.get("car_model", "Yaris 2013")
self.device_name = config.get("device_name", "Yaris OBD")
def _load_config(self):
"""Load device config from JSON file, return dict with defaults."""
config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "config", "device.json")
+9 -4
View File
@@ -17,6 +17,7 @@ from . import app_network, app_sql
_SYNC_THREAD = None
_SYNC_STOP = False
_SYNC_CONFIG_VERSION = 0 # Track last-synced version to detect changes
_RELOAD_CALLBACK = None
def _has_internet():
@@ -44,6 +45,12 @@ def start_sync():
_SYNC_THREAD.start()
def register_reload_callback(callback):
"""Register a callback invoked after synced config is written locally."""
global _RELOAD_CALLBACK
_RELOAD_CALLBACK = callback
def stop_sync():
"""Stop background device config sync thread."""
global _SYNC_STOP
@@ -112,10 +119,8 @@ def _store_synced_config(config):
def _on_config_changed():
"""Called when config changes and needs to be reloaded by app."""
# This is a signal for future hot-reload implementation
# For now, config is automatically read from database on each request
# A full reload would require restarting the app or using a signal handler
pass
if _RELOAD_CALLBACK is not None:
_RELOAD_CALLBACK()
def is_sync_enabled():
+9 -6
View File
@@ -55,12 +55,15 @@ Dashboard — mobile (390 px) and desktop (1280 px):
Dashcam videos upload in resumable 40MB chunks (works around tunnel body-size limits and keeps
the Pi's memory footprint flat regardless of file size); a UI toggle can pause dashcam uploads
independently while trip logs keep syncing
- 🌐 **Remote web portal** — a full read-only browser portal at the same server URL (Phase 24):
login-protected (Flask-Login + bcrypt), trip history with OSM/CARTO live map and route replay,
dashcam archive browsing with inline video player and Range-request seeking, overview stats
dashboard, and delete buttons for trips and clips. Shared `history.js` code runs on both the
Pi and the server — tile source and zoom limits are configured per-deployment via HTML data
attributes. Accessible at `https://your-cloudflare-tunnel-url/`
- 🌐 **Remote web portal** — a login-protected browser portal at the same server URL (Phase 24+):
trip history with OSM/CARTO live map and route replay, dashcam archive browsing with inline
video player and Range-request seeking, overview stats dashboard, delete buttons for trips and
clips, and a device-settings page for editing the synced owner/car/avatar values that the Pi
pulls from the server. When the Pi has **Sync with server** enabled, those identity changes hot-
reload into the running local UI on the next successful sync instead of waiting for a manual
restart. Shared `history.js` code runs on both the Pi and the server — tile
source and zoom limits are configured per-deployment via HTML data attributes. Accessible at
`https://your-cloudflare-tunnel-url/`
- 📡 **Optional cellular fallback** through the SIM868 for small trip-log uploads when normal
WiFi/Ethernet is unavailable (dashcam video stays off cellular)
- 🗄️ **Motion-aware local-first SQLite logging** — five-second motion checks without five-second
+8 -1
View File
@@ -65,6 +65,10 @@ so a permissive origin can't be used to steal a logged-in session.
docker compose ps
curl http://localhost:8098/health
```
5. Sign in to the remote portal and open `/settings/device` to edit the synced
owner/car/device/avatar values for the configured `DEVICE_NAME`. If the Pi
has local **Sync with server** enabled, those values hot-reload into the
running Pi UI after the next successful sync.
### Upgrading an existing unraid deployment
@@ -80,7 +84,10 @@ docker compose up -d --build
Phase 25.5a adds the device identity sync columns (`owner_name`, `car_model`,
`device_name`, `avatar_path`, `config_version`, `config_updated_at`) used by the
Pi/server config sync flow.
Pi/server config sync flow. The browser portal page at `/settings/device` edits
those values; the Pi applies them on its next successful sync when local
**Sync with server** is enabled, and Phase 25.5b now hot-reloads the in-process
UI fields instead of requiring an app restart.
## Registering a device and getting an API key
+26 -1
View File
@@ -1,6 +1,6 @@
"""yaris-obd remote server: receives trip/GPS log batches and dashcam files
uploaded by the Pi, storing them in Postgres + on-disk file storage.
Also serves a read-only web portal for browsing trip history and dashcam archive.
Also serves the remote web portal for browsing and managing the configured car.
"""
import functools
import hashlib
@@ -414,6 +414,15 @@ def portal_dashcam():
return render_template("dashcam.html")
@app.get("/settings/device")
@require_login
def portal_device_settings_page():
return render_template(
"device_settings.html",
configured_device_name=os.environ.get("DEVICE_NAME", "yaris-2013"),
)
# ── Portal JSON API ────────────────────────────────────────────────────────
@app.get("/api/v1/portal/stats")
@@ -621,6 +630,22 @@ def portal_trip_compat(trip_id):
# ── Phase 25.5a: Device configuration sync ──────────────────────────────────
@app.get("/api/v1/portal/device-config")
@require_login
def portal_device_config_get():
"""Get the configured device identity for the remote portal."""
device_id = _portal_device_id()
if device_id is None:
return jsonify(error="device not configured"), 404
config = device_config.get_device_config(device_id)
if not config:
return jsonify(error="device not found"), 404
config["configured_device_name"] = os.environ.get("DEVICE_NAME", "yaris-2013")
return jsonify(config)
@app.get("/api/device-config")
@require_device_auth
def api_device_config_get():
+9
View File
@@ -49,6 +49,11 @@
<i class="fas fa-video fa-fw"></i><span>Dashcam archive</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link{% if request.endpoint == 'portal_device_settings_page' %} active{% endif %}" href="{{ url_for('portal_device_settings_page') }}">
<i class="fas fa-sliders-h fa-fw"></i><span>Device settings</span>
</a>
</li>
</ul>
<div class="px-3 pb-2 mt-auto text-muted d-flex align-items-center gap-1 sidebar-user-footer" style="font-size:.75rem;">
<i class="fas fa-user fa-fw flex-shrink-0"></i>
@@ -108,6 +113,10 @@
<i class="fas fa-video"></i>
<span>Dashcam</span>
</a>
<a class="mobile-nav-item{% if request.endpoint == 'portal_device_settings_page' %} active{% endif %}" href="{{ url_for('portal_device_settings_page') }}">
<i class="fas fa-sliders-h"></i>
<span>Device</span>
</a>
<a class="mobile-nav-item" href="{{ url_for('logout') }}">
<i class="fas fa-sign-out-alt"></i>
<span>Logout</span>
+87
View File
@@ -0,0 +1,87 @@
{% extends "base.html" %}
{% block title %}Device settings – Yaris OBD Remote{% endblock %}
{% block content %}
<div class="d-flex flex-column flex-md-row align-items-md-center justify-content-between gap-2 mb-3">
<div>
<h3 class="text-dark mb-1 fw-bold">Device settings</h3>
<p class="text-muted mb-0">
Edit the identity synced to the Pi for <code>{{ configured_device_name }}</code>.
</p>
</div>
<button class="btn btn-primary" id="device-settings-save" type="submit" form="device-settings-form" disabled>
<i class="fas fa-save me-1"></i>Save device settings
</button>
</div>
<div id="device-settings-message" class="alert" role="status" hidden></div>
<div class="row g-3">
<div class="col-12 col-xl-8">
<div class="card shadow-sm">
<div class="card-header py-2">
<h6 class="text-primary fw-bold m-0">Synced identity</h6>
</div>
<div class="card-body">
<form id="device-settings-form" novalidate>
<div class="row g-3">
<div class="col-12 col-md-6">
<label class="form-label" for="owner_name">Owner name</label>
<input class="form-control" id="owner_name" name="owner_name" maxlength="100" required>
</div>
<div class="col-12 col-md-6">
<label class="form-label" for="car_model">Car model</label>
<input class="form-control" id="car_model" name="car_model" maxlength="100" required>
</div>
<div class="col-12 col-md-6">
<label class="form-label" for="device_name">Device name</label>
<input class="form-control" id="device_name" name="device_name" maxlength="100">
<div class="form-text">Used by the remote portal and future multi-device views.</div>
</div>
<div class="col-12 col-md-6">
<label class="form-label" for="avatar_path">Avatar path</label>
<input class="form-control" id="avatar_path" name="avatar_path" maxlength="255" placeholder="img/yaris.png">
<div class="form-text">Relative to the shared <code>static/</code> folder.</div>
</div>
</div>
</form>
</div>
</div>
</div>
<div class="col-12 col-xl-4">
<div class="card shadow-sm mb-3">
<div class="card-header py-2">
<h6 class="text-primary fw-bold m-0">Sync behavior</h6>
</div>
<div class="card-body small text-muted">
<p class="mb-2">
The Pi picks these values up on its next successful internet-backed sync.
</p>
<ul class="mb-0 ps-3">
<li>Expected propagation delay: up to ~5 minutes while online.</li>
<li>The Pi must have <strong>Sync with server</strong> enabled locally.</li>
<li>Offline cars keep the last synced config until connectivity returns.</li>
</ul>
</div>
</div>
<div class="card shadow-sm">
<div class="card-header py-2">
<h6 class="text-primary fw-bold m-0">Preview</h6>
</div>
<div class="card-body text-center">
<img id="avatar-preview" alt="Avatar preview" class="rounded-circle border mb-3" src="{{ url_for('static', filename='img/yaris.png') }}" style="width:96px;height:96px;object-fit:cover;">
<div class="small text-muted mb-1">Config version</div>
<div class="fw-bold fs-5" id="config-version">—</div>
<div class="small text-muted mt-3" id="preview-caption">Preview updates as you type.</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/portal-device-settings.js') }}"></script>
{% endblock %}
+121
View File
@@ -0,0 +1,121 @@
const form = document.getElementById('device-settings-form');
const saveButton = document.getElementById('device-settings-save');
const message = document.getElementById('device-settings-message');
const configVersion = document.getElementById('config-version');
const avatarPreview = document.getElementById('avatar-preview');
const previewCaption = document.getElementById('preview-caption');
const fields = {
owner_name: document.getElementById('owner_name'),
car_model: document.getElementById('car_model'),
device_name: document.getElementById('device_name'),
avatar_path: document.getElementById('avatar_path'),
};
let initialState = null;
function showMessage(text, kind) {
message.className = `alert alert-${kind}`;
message.textContent = text;
message.hidden = false;
}
function hideMessage() {
message.hidden = true;
}
function currentState() {
return {
owner_name: fields.owner_name.value.trim(),
car_model: fields.car_model.value.trim(),
device_name: fields.device_name.value.trim(),
avatar_path: fields.avatar_path.value.trim(),
};
}
function avatarUrl(path) {
if (!path) return '/static/img/yaris.png';
if (/^https?:\/\//.test(path)) return path;
return `/static/${path.replace(/^\/+/, '')}`;
}
function updatePreview() {
const state = currentState();
avatarPreview.src = avatarUrl(state.avatar_path);
previewCaption.textContent = state.owner_name && state.car_model
? `${state.owner_name} • ${state.car_model}`
: 'Preview updates as you type.';
}
function updateDirtyState() {
if (!initialState) {
saveButton.disabled = true;
return;
}
saveButton.disabled = JSON.stringify(currentState()) === JSON.stringify(initialState);
}
function populate(config) {
fields.owner_name.value = config.owner_name || '';
fields.car_model.value = config.car_model || '';
fields.device_name.value = config.device_name || '';
fields.avatar_path.value = config.avatar_path || '';
configVersion.textContent = config.config_version ?? '—';
initialState = currentState();
updatePreview();
updateDirtyState();
}
async function loadConfig() {
try {
const response = await fetch('/api/v1/portal/device-config');
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || 'Could not load device config.');
}
populate(payload);
} catch (error) {
showMessage(error.message, 'danger');
}
}
async function saveConfig(event) {
event.preventDefault();
hideMessage();
saveButton.disabled = true;
try {
const response = await fetch('/api/device-config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(currentState()),
});
const payload = await response.json();
if (!response.ok) {
const detail = payload.fields
? Object.values(payload.fields).join(' ')
: (payload.error || 'Could not save device config.');
throw new Error(detail);
}
populate(payload);
showMessage('Device settings saved. The Pi will pick them up on its next sync.', 'success');
} catch (error) {
showMessage(error.message, 'danger');
updateDirtyState();
}
}
for (const field of Object.values(fields)) {
field.addEventListener('input', () => {
hideMessage();
updatePreview();
updateDirtyState();
});
}
avatarPreview.addEventListener('error', () => {
previewCaption.textContent = 'Preview image not found on the server static path.';
});
form.addEventListener('submit', saveConfig);
loadConfig();
+60
View File
@@ -0,0 +1,60 @@
import unittest
from unittest.mock import Mock, patch
from modules import app_account, app_car, app_device_sync
class RuntimeDeviceConfigReloadTest(unittest.TestCase):
@patch.object(app_account.Account, "_load_config", return_value={})
@patch("modules.app_device_config.get_device_config")
def test_account_reload_runtime_config_uses_live_settings(
self,
get_device_config,
_load_config,
):
account = app_account.Account()
get_device_config.return_value = {
"owner_name": "alice",
"avatar_path": "img/alice.png",
}
account.reload_runtime_config()
self.assertEqual(account.name, "alice")
self.assertEqual(account.avatar_path, "img/alice.png")
@patch.object(app_car.Car, "_load_config", return_value={})
@patch("modules.app_device_config.get_device_config")
def test_car_reload_runtime_config_uses_live_settings(
self,
get_device_config,
_load_config,
):
car = app_car.Car()
get_device_config.return_value = {
"car_model": "Corolla",
"device_name": "Garage Pi",
}
car.reload_runtime_config()
self.assertEqual(car.model, "Corolla")
self.assertEqual(car.device_name, "Garage Pi")
class DeviceSyncReloadCallbackTest(unittest.TestCase):
def test_on_config_changed_calls_registered_callback(self):
callback = Mock()
original = app_device_sync._RELOAD_CALLBACK
try:
app_device_sync.register_reload_callback(callback)
app_device_sync._on_config_changed()
callback.assert_called_once_with()
finally:
app_device_sync._RELOAD_CALLBACK = original
if __name__ == "__main__":
unittest.main()