From e30b85d218a0777c1650c66e8606c607d56924a4 Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Wed, 12 Aug 2026 01:40:51 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20portal-wide=20device=20selector=20?= =?UTF-8?q?=E2=80=94=20session-based,=20all=20pages=20now=20use=20selected?= =?UTF-8?q?=20device?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _portal_device_id() reads from session, falls back to DEVICE_NAME env var - GET/POST /api/v1/portal/select-device for selector data + set - Dropdown in server base.html topbar, JS populates from API, page reloads on change - All portal pages (overview, trips, dashcam, DTC, device settings) now use selected device --- FEATURES.md | 70 +++---------------- PLAN.md | 34 ++++----- .../java/com/yarisobd/app/DashcamScreen.kt | 19 +++++ .../java/com/yarisobd/app/MainActivity.kt | 14 ++++ server/app.py | 47 ++++++++++++- server/templates/base.html | 36 +++++++++- 6 files changed, 133 insertions(+), 87 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index 5d77fcf..0eaadb4 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -82,69 +82,15 @@ dedicated PLAN.md phase (or fold it into the relevant existing work) and mark it run the Pi's hotspot/SSID the same way `app_network.py` currently does. ### FEAT-05 — DTC history synced to remote server with code descriptions -- **Status**: `IDEA` -- **Area**: `modules/app_upload.py`, `modules/app_sql.py`, `server/sql/`, `server/modules/db.py`, - `server/app.py`, server portal templates +- **Status**: `SHIPPED` 2026-08-12 +- **Area**: `modules/app_upload.py`, `server/sql/migrate_phase27_dtc.sql`, `server/modules/db.py`, `server/app.py`, `server/templates/dtc.html` - **Phase**: Phase 27 -- **Requested**: 2026-08-11 — owner wants DTC codes persisted to the server (not just local SQLite) - with code + description, surviving until user deletes them or OBD reports the code cleared. -- **Current state (2026-08-11)**: - - Pi: `app_obd.py` polls DTCs from ECU → `app_sql.save_dtc_state()` persists MIL + codes - (code, description, IS_SET, FIRST_SEEN_AT, LAST_SEEN_AT) to local SQLite `dtc` table. - - Pi: `app_upload.py` uploads tracking rows and dashcam files — NO DTC upload path exists. - - Server: NO `dtc_events` table, NO DTC API, NO portal DTC page. -- **Design**: - - **Server schema** (`server/sql/migrate_phase26_dtc.sql`): - ```sql - CREATE TABLE IF NOT EXISTS dtc_events ( - id SERIAL PRIMARY KEY, - device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE, - code VARCHAR(10) NOT NULL, - description TEXT NOT NULL DEFAULT '', - mil_on BOOLEAN NOT NULL DEFAULT FALSE, - is_active BOOLEAN NOT NULL DEFAULT TRUE, - first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), - last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), - cleared_at TIMESTAMPTZ, - UNIQUE (device_id, code) - ); - ``` - - **Server API** (`server/app.py`): - - `POST /api/v1/dtc/batch` (device auth): receives current DTC snapshot from Pi. - Body: `{"mil_on": true/false, "codes": [{"code": "P0301", "description": "Cylinder 1 Misfire"}, ...]}` - - Insert new codes, update `last_seen_at` for existing active codes. - - Mark any previously-active codes NOT in this batch as `is_active=false, cleared_at=now()`. - - Return `{received: N, cleared: M, unchanged: K}`. - - `GET /api/v1/portal/dtc` (user auth): list all DTC events for configured device. - - `DELETE /api/v1/portal/dtc/` (user auth): manual deletion. - - **Server portal**: new DTC page or section showing code, description, MIL state, first/last seen, - active/cleared status, cleared timestamp. - - **Pi upload** (`modules/app_upload.py`): - - New `_upload_dtc_snapshot()` — called after tracking batch in `_upload_loop()`. - - Only uploads when the local DTC snapshot differs from last-uploaded (hash or version compare). - - Reuses same `_credentials()`/`_headers()` as tracking uploads. - - Same WiFi-first, cellular-fallback (if configured) path as tracking batches. - -- **Deduplication logic**: server-side per `(device_id, code)` unique key. Pi sends the full - current snapshot each time. Server diffs: new codes = insert, existing = touch last_seen_at, - missing from snapshot = mark cleared. This means if a code disappears from the ECU, it - auto-clears on the server on the next upload — no manual "clear" step needed. -- **Manual deletion**: only via the server portal delete button. Cleared codes stay visible as - history. The Pi never deletes from the server automatically. -- **Outstanding considerations**: - - The Pi's local `save_dtc_state()` already handles IS_SET/cleared tracking — the upload path - just needs to read the current active snapshot from SQLite (reuse `get_persisted_dtc_state()`). - - DTC descriptions come directly from the ECU (python-obd's `GET_DTC` response) — they're the - standard SAE J2012 descriptions, no external lookup needed. - - Should we upload on every poll (every ~2s OBD cycle) or batch? Answer: batch. Upload alongside - tracking batches (every 30s idle, 1s catchup). DTCs don't change mid-drive that often. - - First upload after Pi reboot with no OBD connected: snapshot is empty → server marks all - previously-active codes as cleared. Is that desired? Probably not — should skip upload when - OBD is disconnected/unavailable (the Pi already knows this from `connection` state). +- **What**: Pi pushes current DTC snapshot to server via `POST /api/v1/dtc/batch`. + Server diffs against stored state — new codes inserted, existing updated, missing + codes auto-cleared. Portal has a new `/dtc` page showing code, description, MIL + state, active/cleared status, first/last seen timestamps, and manual delete. + Upload skips when OBD disconnected (avoids clearing server DTCs on Pi reboot). + Snapshot is hash-compared to avoid redundant uploads. ### FEAT-08 — Predictable IP on phone hotspot (DHCP client-id) - **Status**: `SHIPPED` 2026-08-11 (Phase 22) diff --git a/PLAN.md b/PLAN.md index 39139ce..e031ff8 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1945,35 +1945,25 @@ agree on OBD and GPS state. BUGS.md Open section is empty. readme.md project sta --- -### Phase 27: Feature additions — ✅ DONE (3 shipped, 4 candidates remain) -**Completed 2026-08-12.** Three features shipped; remaining 4 are low-priority ideas +### Phase 27: Feature additions — ✅ DONE (4 shipped, 3 candidates remain) +**Completed 2026-08-12.** Four features shipped; remaining 3 are low-priority ideas tracked in FEATURES.md. **Shipped:** -- **FEAT-03 — Manual odometer baseline** (2026-08-11): Pi settings has "Odometer baseline - (km)". Dashboard shows `odometer_km = baseline + trip distance`. Pushes to server on save - via `POST /api/device-config/push`. Portal shows read-only synced value. Server overview - stat card. Four sync bugs fixed during implementation: - 1. Sync overwrote Pi odometer with stale server value (`_store_synced_config` no longer touches it). - 2. JS skipped `POST /api/device-config` when enabling sync (always-send fix). - 3. Backend returned error when toggling sync with locked fields present (toggle-first fix). - 4. `get_device_by_id()` missing `odometer_baseline` in SQL SELECT (added column, shifted indices). - Changed: `modules/app_settings.py`, `modules/app_sql.py`, `modules/app_device_sync.py`, - `modules/app_device_config.py`, `templates/settings.html`, `static/js/settings.js`, - `server/modules/db.py`, `server/modules/device_config.py`, - `server/templates/device_settings.html`, `static/js/portal-device-settings.js`, - `server/app.py`, `FEATURES.md`. -- **FEAT-06 — Trip ID column in history** (2026-08-11): Narrow `#ID` column on server - history (`data-show-id="1"`). Checkbox + batch-delete server-only (`data-batch-delete-url`). - Changed: `static/js/history.js`, `server/templates/history.html`. -- **FEAT-07 — Phone-hotspot auto-connect docs** (2026-08-11): Already implemented in - Phase 22; documented and verified with real Pixel hardware. +- **FEAT-03 — Manual odometer baseline** (2026-08-11) +- **FEAT-06 — Trip ID column in history** (2026-08-11) +- **FEAT-07 — Phone-hotspot auto-connect docs** (2026-08-11) +- **FEAT-05 — DTC history sync to server** (2026-08-12): Pi pushes DTC snapshots + via `POST /api/v1/dtc/batch`. Server diffs: insert/update/auto-clear. Portal + `/dtc` page with code, description, MIL, status, timestamps, delete. Skips + upload when OBD disconnected. Changed: `modules/app_upload.py`, + `server/sql/migrate_phase27_dtc.sql`, `server/modules/db.py`, `server/app.py`, + `server/templates/dtc.html`, `server/templates/base.html`. -**Remaining candidates (4):** +**Remaining candidates (3):** 1. **FEAT-01** — Parallel/concurrent chunked dashcam uploads (medium) 2. **FEAT-02** — QR / claim-code device pairing flow (medium) 3. **FEAT-04** — Replace SIM868 2G GSM with 4G LTE modem (large) -4. **FEAT-05** — DTC history synced to remote server (medium) ### Phase 28: Native Android companion app — 🚧 IN PROGRESS (low priority, always last) **Renumbered from former Phase 26 on 2026-08-10 to make room for the road-test bug-fix phase. diff --git a/android/app/src/main/java/com/yarisobd/app/DashcamScreen.kt b/android/app/src/main/java/com/yarisobd/app/DashcamScreen.kt index 5353461..794cedd 100644 --- a/android/app/src/main/java/com/yarisobd/app/DashcamScreen.kt +++ b/android/app/src/main/java/com/yarisobd/app/DashcamScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -43,6 +44,7 @@ fun DashcamScreen( retentionDays: Int, isLoading: Boolean, error: String?, + downloadProgress: Map, onRefresh: () -> Unit, onDownload: (VideoFile) -> Unit, onDelete: (VideoFile) -> Unit, @@ -146,8 +148,10 @@ fun DashcamScreen( verticalArrangement = Arrangement.spacedBy(8.dp) ) { items(videos, key = { it.path }) { video -> + val progress = downloadProgress[video.path] VideoRow( video = video, + downloadProgress = progress, onDownload = { onDownload(video) }, onDelete = { onDelete(video) }, ) @@ -161,6 +165,7 @@ fun DashcamScreen( @Composable private fun VideoRow( video: VideoFile, + downloadProgress: DownloadProgress?, onDownload: () -> Unit, onDelete: () -> Unit, ) { @@ -224,6 +229,20 @@ private fun VideoRow( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.tertiary ) + } else if (downloadProgress != null) { + LinearProgressIndicator( + progress = { + if (downloadProgress.totalBytes > 0) + downloadProgress.bytesDownloaded.toFloat() / downloadProgress.totalBytes + else 0f + }, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + ) + Text( + text = "Downloading ${downloadProgress.bytesDownloaded / 1_000_000}MB / ${downloadProgress.totalBytes / 1_000_000}MB", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary + ) } else { Text( text = if (video.uploaded) "Uploaded" else "Pending", diff --git a/android/app/src/main/java/com/yarisobd/app/MainActivity.kt b/android/app/src/main/java/com/yarisobd/app/MainActivity.kt index 14867a1..492ccfe 100644 --- a/android/app/src/main/java/com/yarisobd/app/MainActivity.kt +++ b/android/app/src/main/java/com/yarisobd/app/MainActivity.kt @@ -12,8 +12,11 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -49,10 +52,20 @@ fun YarisMainScreen(client: OkHttpClient) { val retentionDays by dashcamVm.retentionDays.collectAsState() val isLoading by dashcamVm.isLoading.collectAsState() val error by dashcamVm.error.collectAsState() + val downloadProgress by dashcamVm.downloadProgress.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } var selectedTab by remember { mutableIntStateOf(0) } + // Show download/delete events as snackbars + LaunchedEffect(Unit) { + dashcamVm.downloadEvents.collect { event -> + snackbarHostState.showSnackbar(event) + } + } + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, bottomBar = { NavigationBar { NavigationBarItem( @@ -78,6 +91,7 @@ fun YarisMainScreen(client: OkHttpClient) { retentionDays = retentionDays, isLoading = isLoading, error = error, + downloadProgress = downloadProgress, onRefresh = { dashcamVm.loadVideos() }, onDownload = { dashcamVm.downloadVideo(it) }, onDelete = { dashcamVm.deleteVideo(it) }, diff --git a/server/app.py b/server/app.py index 764c5d5..08ce58f 100644 --- a/server/app.py +++ b/server/app.py @@ -74,12 +74,57 @@ login_manager.init_app(app) def _portal_device_id(): - """Return the device id for the configured device name (single Pi setup).""" + """Return the device id for the user's selected device, falling back to + the DEVICE_NAME env var default. Selection is stored in the Flask session + so it persists across pages without a URL parameter on every link.""" + # Check session first (user explicitly picked a device) + session_id = session.get("selected_device_id") + if session_id is not None: + dev = db.get_device_by_id(session_id) + if dev is not None: + return dev["id"] + # Selected device was deleted — clear stale session entry + session.pop("selected_device_id", None) + # Fall back to configured default name = os.environ.get("DEVICE_NAME", "yaris-2013") dev = db.get_device_by_name(name) return dev["id"] if dev else None +@app.get("/api/v1/portal/select-device") +@require_login +def portal_select_device_state(): + """Return current selection and available devices for the dropdown.""" + devices = device_registry.list_devices() + configured_name = os.environ.get("DEVICE_NAME", "yaris-2013") + for device in devices: + device["portal_default"] = device["name"] == configured_name + current_id = session.get("selected_device_id") + return jsonify( + devices=devices, + selected_device_id=current_id, + configured_device_name=configured_name, + ) + + +@app.post("/api/v1/portal/select-device") +@require_login +def portal_select_device(): + """Set the active device for the current session.""" + body = request.get_json(silent=True) or {} + device_id = body.get("device_id") + if device_id is None: + session.pop("selected_device_id", None) + session.pop("selected_device_name", None) + return jsonify(status="ok", message="Device selection cleared.") + dev = db.get_device_by_id(int(device_id)) + if dev is None: + return jsonify(error="Device not found"), 404 + session["selected_device_id"] = dev["id"] + session["selected_device_name"] = dev["device_name"] or dev["name"] + return jsonify(status="ok", device_id=dev["id"], device_name=dev["device_name"] or dev["name"]) + + @app.get("/health") def health(): """Unauthenticated liveness check - used by docker-compose healthcheck diff --git a/server/templates/base.html b/server/templates/base.html index d8778e0..55327b5 100644 --- a/server/templates/base.html +++ b/server/templates/base.html @@ -86,7 +86,12 @@ Yaris OBD Remote -