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 service restarted cleanly, and unraid rebuilt the API stack after manually applying
`migrate_phase25_5a.sql`. `migrate_phase25_5a.sql`.
#### Next phase (Phase 25.5b) ### Phase 25.5b: Remote portal device settings + hot-reload — ✅ COMPLETE
- Hot-reload handler to apply config changes without restart **The remote editor and Pi-side hot-reload are now wired.** The server portal can
- Server portal UI for editing device config (admin page) edit the device identity that the Pi sync thread consumes, and the running Pi UI
- Device settings page on portal to test config updates updates its display-facing values without a manual restart once sync lands.
- Mobile-friendly device management UI
#### 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_sql.start_logging()
app_upload.start_uploading() app_upload.start_uploading()
app_alerts.start_alerting() 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_device_sync.start_sync()
@app.route('/') @app.route('/')
@@ -53,6 +61,7 @@ def api_device_config():
app_device_config.update_device_config(request.get_json(silent=True), errors) app_device_config.update_device_config(request.get_json(silent=True), errors)
if errors: if errors:
return jsonify(error="Invalid device config.", fields=errors), 400 return jsonify(error="Invalid device config.", fields=errors), 400
_reload_runtime_device_config()
return jsonify(app_device_config.get_device_config()) return jsonify(app_device_config.get_device_config())
@app.route('/api/uplink/status') @app.route('/api/uplink/status')
+9 -2
View File
@@ -1,15 +1,22 @@
import json import json
import os import os
class Account: class Account:
def __init__(self): 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() config = self._load_config()
self.name = config.get("owner_name", "quoije") self.name = config.get("owner_name", "quoije")
self.avatar_path = config.get("avatar_path", "img/yaris.png") self.avatar_path = config.get("avatar_path", "img/yaris.png")
self.pp_image = None self.pp_image = None
self.car = 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): def _load_config(self):
"""Load device config from JSON file, return dict with defaults.""" """Load device config from JSON file, return dict with defaults."""
+10 -3
View File
@@ -1,15 +1,22 @@
import json import json
import os import os
class Car: class Car:
def __init__(self): 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() config = self._load_config()
self.model = config.get("car_model", "Yaris 2013") self.model = config.get("car_model", "Yaris 2013")
self.device_name = config.get("device_name", "Yaris OBD") self.device_name = config.get("device_name", "Yaris OBD")
self.obd = None 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): def _load_config(self):
"""Load device config from JSON file, return dict with defaults.""" """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") 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_THREAD = None
_SYNC_STOP = False _SYNC_STOP = False
_SYNC_CONFIG_VERSION = 0 # Track last-synced version to detect changes _SYNC_CONFIG_VERSION = 0 # Track last-synced version to detect changes
_RELOAD_CALLBACK = None
def _has_internet(): def _has_internet():
@@ -44,6 +45,12 @@ def start_sync():
_SYNC_THREAD.start() _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(): def stop_sync():
"""Stop background device config sync thread.""" """Stop background device config sync thread."""
global _SYNC_STOP global _SYNC_STOP
@@ -112,10 +119,8 @@ def _store_synced_config(config):
def _on_config_changed(): def _on_config_changed():
"""Called when config changes and needs to be reloaded by app.""" """Called when config changes and needs to be reloaded by app."""
# This is a signal for future hot-reload implementation if _RELOAD_CALLBACK is not None:
# For now, config is automatically read from database on each request _RELOAD_CALLBACK()
# A full reload would require restarting the app or using a signal handler
pass
def is_sync_enabled(): 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 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 the Pi's memory footprint flat regardless of file size); a UI toggle can pause dashcam uploads
independently while trip logs keep syncing independently while trip logs keep syncing
- 🌐 **Remote web portal** — a full read-only browser portal at the same server URL (Phase 24): - 🌐 **Remote web portal** — a login-protected browser portal at the same server URL (Phase 24+):
login-protected (Flask-Login + bcrypt), trip history with OSM/CARTO live map and route replay, trip history with OSM/CARTO live map and route replay, dashcam archive browsing with inline
dashcam archive browsing with inline video player and Range-request seeking, overview stats video player and Range-request seeking, overview stats dashboard, delete buttons for trips and
dashboard, and delete buttons for trips and clips. Shared `history.js` code runs on both the clips, and a device-settings page for editing the synced owner/car/avatar values that the Pi
Pi and the server — tile source and zoom limits are configured per-deployment via HTML data pulls from the server. When the Pi has **Sync with server** enabled, those identity changes hot-
attributes. Accessible at `https://your-cloudflare-tunnel-url/` 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 - 📡 **Optional cellular fallback** through the SIM868 for small trip-log uploads when normal
WiFi/Ethernet is unavailable (dashcam video stays off cellular) WiFi/Ethernet is unavailable (dashcam video stays off cellular)
- 🗄️ **Motion-aware local-first SQLite logging** — five-second motion checks without five-second - 🗄️ **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 docker compose ps
curl http://localhost:8098/health 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 ### 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`, 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 `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 ## 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 """yaris-obd remote server: receives trip/GPS log batches and dashcam files
uploaded by the Pi, storing them in Postgres + on-disk file storage. 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 functools
import hashlib import hashlib
@@ -414,6 +414,15 @@ def portal_dashcam():
return render_template("dashcam.html") 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 ──────────────────────────────────────────────────────── # ── Portal JSON API ────────────────────────────────────────────────────────
@app.get("/api/v1/portal/stats") @app.get("/api/v1/portal/stats")
@@ -621,6 +630,22 @@ def portal_trip_compat(trip_id):
# ── Phase 25.5a: Device configuration sync ────────────────────────────────── # ── 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") @app.get("/api/device-config")
@require_device_auth @require_device_auth
def api_device_config_get(): def api_device_config_get():
+9
View File
@@ -49,6 +49,11 @@
<i class="fas fa-video fa-fw"></i><span>Dashcam archive</span> <i class="fas fa-video fa-fw"></i><span>Dashcam archive</span>
</a> </a>
</li> </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> </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;"> <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> <i class="fas fa-user fa-fw flex-shrink-0"></i>
@@ -108,6 +113,10 @@
<i class="fas fa-video"></i> <i class="fas fa-video"></i>
<span>Dashcam</span> <span>Dashcam</span>
</a> </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') }}"> <a class="mobile-nav-item" href="{{ url_for('logout') }}">
<i class="fas fa-sign-out-alt"></i> <i class="fas fa-sign-out-alt"></i>
<span>Logout</span> <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()