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:
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
@@ -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
@@ -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():
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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();
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user