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
|
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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
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."""
|
||||||
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
-2
@@ -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")
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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():
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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