diff --git a/PLAN.md b/PLAN.md index 72e4c42..a08b0cb 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 --- diff --git a/app.py b/app.py index a1d8295..f776c43 100644 --- a/app.py +++ b/app.py @@ -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') diff --git a/modules/app_account.py b/modules/app_account.py index afe970c..4c40331 100644 --- a/modules/app_account.py +++ b/modules/app_account.py @@ -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.""" diff --git a/modules/app_car.py b/modules/app_car.py index b679add..6bf7574 100644 --- a/modules/app_car.py +++ b/modules/app_car.py @@ -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") diff --git a/modules/app_device_sync.py b/modules/app_device_sync.py index 9de27e1..3598270 100644 --- a/modules/app_device_sync.py +++ b/modules/app_device_sync.py @@ -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(): diff --git a/readme.md b/readme.md index c4b0608..d4f718f 100644 --- a/readme.md +++ b/readme.md @@ -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 diff --git a/server/README.md b/server/README.md index 6d4b58e..0f14736 100644 --- a/server/README.md +++ b/server/README.md @@ -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 diff --git a/server/app.py b/server/app.py index 80229d8..b0d771c 100644 --- a/server/app.py +++ b/server/app.py @@ -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(): diff --git a/server/templates/base.html b/server/templates/base.html index 7ca23e3..8de1a02 100644 --- a/server/templates/base.html +++ b/server/templates/base.html @@ -49,6 +49,11 @@ Dashcam archive +