feat: portal-wide device selector — session-based, all pages now use selected device

- _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
This commit is contained in:
Mathieu Moran
2026-08-12 01:40:51 -04:00
parent 1556c65131
commit e30b85d218
6 changed files with 133 additions and 87 deletions
+8 -62
View File
@@ -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. 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 ### FEAT-05 — DTC history synced to remote server with code descriptions
- **Status**: `IDEA` - **Status**: `SHIPPED` 2026-08-12
- **Area**: `modules/app_upload.py`, `modules/app_sql.py`, `server/sql/`, `server/modules/db.py`, - **Area**: `modules/app_upload.py`, `server/sql/migrate_phase27_dtc.sql`, `server/modules/db.py`, `server/app.py`, `server/templates/dtc.html`
`server/app.py`, server portal templates
- **Phase**: Phase 27 - **Phase**: Phase 27
- **Requested**: 2026-08-11 — owner wants DTC codes persisted to the server (not just local SQLite) - **What**: Pi pushes current DTC snapshot to server via `POST /api/v1/dtc/batch`.
with code + description, surviving until user deletes them or OBD reports the code cleared. Server diffs against stored state — new codes inserted, existing updated, missing
- **Current state (2026-08-11)**: codes auto-cleared. Portal has a new `/dtc` page showing code, description, MIL
- Pi: `app_obd.py` polls DTCs from ECU → `app_sql.save_dtc_state()` persists MIL + codes state, active/cleared status, first/last seen timestamps, and manual delete.
(code, description, IS_SET, FIRST_SEEN_AT, LAST_SEEN_AT) to local SQLite `dtc` table. Upload skips when OBD disconnected (avoids clearing server DTCs on Pi reboot).
- Pi: `app_upload.py` uploads tracking rows and dashcam files — NO DTC upload path exists. Snapshot is hash-compared to avoid redundant uploads.
- 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/<id>` (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).
### FEAT-08 — Predictable IP on phone hotspot (DHCP client-id) ### FEAT-08 — Predictable IP on phone hotspot (DHCP client-id)
- **Status**: `SHIPPED` 2026-08-11 (Phase 22) - **Status**: `SHIPPED` 2026-08-11 (Phase 22)
+12 -22
View File
@@ -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) ### Phase 27: Feature additions — ✅ DONE (4 shipped, 3 candidates remain)
**Completed 2026-08-12.** Three features shipped; remaining 4 are low-priority ideas **Completed 2026-08-12.** Four features shipped; remaining 3 are low-priority ideas
tracked in FEATURES.md. tracked in FEATURES.md.
**Shipped:** **Shipped:**
- **FEAT-03 — Manual odometer baseline** (2026-08-11): Pi settings has "Odometer baseline - **FEAT-03 — Manual odometer baseline** (2026-08-11)
(km)". Dashboard shows `odometer_km = baseline + trip distance`. Pushes to server on save - **FEAT-06 — Trip ID column in history** (2026-08-11)
via `POST /api/device-config/push`. Portal shows read-only synced value. Server overview - **FEAT-07 — Phone-hotspot auto-connect docs** (2026-08-11)
stat card. Four sync bugs fixed during implementation: - **FEAT-05 — DTC history sync to server** (2026-08-12): Pi pushes DTC snapshots
1. Sync overwrote Pi odometer with stale server value (`_store_synced_config` no longer touches it). via `POST /api/v1/dtc/batch`. Server diffs: insert/update/auto-clear. Portal
2. JS skipped `POST /api/device-config` when enabling sync (always-send fix). `/dtc` page with code, description, MIL, status, timestamps, delete. Skips
3. Backend returned error when toggling sync with locked fields present (toggle-first fix). upload when OBD disconnected. Changed: `modules/app_upload.py`,
4. `get_device_by_id()` missing `odometer_baseline` in SQL SELECT (added column, shifted indices). `server/sql/migrate_phase27_dtc.sql`, `server/modules/db.py`, `server/app.py`,
Changed: `modules/app_settings.py`, `modules/app_sql.py`, `modules/app_device_sync.py`, `server/templates/dtc.html`, `server/templates/base.html`.
`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.
**Remaining candidates (4):** **Remaining candidates (3):**
1. **FEAT-01** — Parallel/concurrent chunked dashcam uploads (medium) 1. **FEAT-01** — Parallel/concurrent chunked dashcam uploads (medium)
2. **FEAT-02** — QR / claim-code device pairing flow (medium) 2. **FEAT-02** — QR / claim-code device pairing flow (medium)
3. **FEAT-04** — Replace SIM868 2G GSM with 4G LTE modem (large) 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) ### 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. **Renumbered from former Phase 26 on 2026-08-10 to make room for the road-test bug-fix phase.
@@ -21,6 +21,7 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -43,6 +44,7 @@ fun DashcamScreen(
retentionDays: Int, retentionDays: Int,
isLoading: Boolean, isLoading: Boolean,
error: String?, error: String?,
downloadProgress: Map<String, DownloadProgress>,
onRefresh: () -> Unit, onRefresh: () -> Unit,
onDownload: (VideoFile) -> Unit, onDownload: (VideoFile) -> Unit,
onDelete: (VideoFile) -> Unit, onDelete: (VideoFile) -> Unit,
@@ -146,8 +148,10 @@ fun DashcamScreen(
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
items(videos, key = { it.path }) { video -> items(videos, key = { it.path }) { video ->
val progress = downloadProgress[video.path]
VideoRow( VideoRow(
video = video, video = video,
downloadProgress = progress,
onDownload = { onDownload(video) }, onDownload = { onDownload(video) },
onDelete = { onDelete(video) }, onDelete = { onDelete(video) },
) )
@@ -161,6 +165,7 @@ fun DashcamScreen(
@Composable @Composable
private fun VideoRow( private fun VideoRow(
video: VideoFile, video: VideoFile,
downloadProgress: DownloadProgress?,
onDownload: () -> Unit, onDownload: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
) { ) {
@@ -224,6 +229,20 @@ private fun VideoRow(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary 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 { } else {
Text( Text(
text = if (video.uploaded) "Uploaded" else "Pending", text = if (video.uploaded) "Uploaded" else "Pending",
@@ -12,8 +12,11 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
@@ -49,10 +52,20 @@ fun YarisMainScreen(client: OkHttpClient) {
val retentionDays by dashcamVm.retentionDays.collectAsState() val retentionDays by dashcamVm.retentionDays.collectAsState()
val isLoading by dashcamVm.isLoading.collectAsState() val isLoading by dashcamVm.isLoading.collectAsState()
val error by dashcamVm.error.collectAsState() val error by dashcamVm.error.collectAsState()
val downloadProgress by dashcamVm.downloadProgress.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
var selectedTab by remember { mutableIntStateOf(0) } var selectedTab by remember { mutableIntStateOf(0) }
// Show download/delete events as snackbars
LaunchedEffect(Unit) {
dashcamVm.downloadEvents.collect { event ->
snackbarHostState.showSnackbar(event)
}
}
Scaffold( Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = { bottomBar = {
NavigationBar { NavigationBar {
NavigationBarItem( NavigationBarItem(
@@ -78,6 +91,7 @@ fun YarisMainScreen(client: OkHttpClient) {
retentionDays = retentionDays, retentionDays = retentionDays,
isLoading = isLoading, isLoading = isLoading,
error = error, error = error,
downloadProgress = downloadProgress,
onRefresh = { dashcamVm.loadVideos() }, onRefresh = { dashcamVm.loadVideos() },
onDownload = { dashcamVm.downloadVideo(it) }, onDownload = { dashcamVm.downloadVideo(it) },
onDelete = { dashcamVm.deleteVideo(it) }, onDelete = { dashcamVm.deleteVideo(it) },
+46 -1
View File
@@ -74,12 +74,57 @@ login_manager.init_app(app)
def _portal_device_id(): 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") name = os.environ.get("DEVICE_NAME", "yaris-2013")
dev = db.get_device_by_name(name) dev = db.get_device_by_name(name)
return dev["id"] if dev else None 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") @app.get("/health")
def health(): def health():
"""Unauthenticated liveness check - used by docker-compose healthcheck """Unauthenticated liveness check - used by docker-compose healthcheck
+34 -2
View File
@@ -86,7 +86,12 @@
<span class="fw-bold text-dark">Yaris OBD</span> <span class="fw-bold text-dark">Yaris OBD</span>
<span class="badge bg-warning text-dark ms-1" style="font-size:.6rem;">Remote</span> <span class="badge bg-warning text-dark ms-1" style="font-size:.6rem;">Remote</span>
</a> </a>
<div class="ms-auto d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
<select class="form-select form-select-sm" id="portal-device-selector" style="width:auto;min-width:10rem;" aria-label="Select device">
<option value="">Loading…</option>
</select>
</div>
<div class="d-flex align-items-center gap-2">
<span class="text-muted small d-none d-sm-inline">{{ current_user.username }}</span> <span class="text-muted small d-none d-sm-inline">{{ current_user.username }}</span>
<a href="{{ url_for('logout') }}" class="btn btn-outline-secondary btn-sm" title="Logout"> <a href="{{ url_for('logout') }}" class="btn btn-outline-secondary btn-sm" title="Logout">
<i class="fas fa-sign-out-alt fa-fw"></i> <i class="fas fa-sign-out-alt fa-fw"></i>
@@ -136,7 +141,34 @@
<script src="{{ url_for('static', filename='js/bs-init.js') }}"></script> <script src="{{ url_for('static', filename='js/bs-init.js') }}"></script>
<script src="{{ url_for('static', filename='js/theme.js') }}"></script> <script src="{{ url_for('static', filename='js/theme.js') }}"></script>
{# ── Shared confirmation helper ────────────────────────────────────────── #} {# ── Device selector ─────────────────────────────────────────────────── #}
<script>
(function () {
const sel = document.getElementById("portal-device-selector");
if (!sel) return;
async function load() {
const r = await fetch("/api/v1/portal/select-device");
if (!r.ok) return;
const d = await r.json();
sel.innerHTML = d.devices.map(dev =>
'<option value="' + dev.id + '"' + (dev.id === d.selected_device_id ? " selected" : "") + ">" +
(dev.device_name || dev.name) + (dev.portal_default ? " (default)" : "") +
"</option>"
).join("");
if (!d.devices.length) sel.innerHTML = '<option value="">No devices</option>';
}
load();
sel.addEventListener("change", async function () {
const id = sel.value ? parseInt(sel.value) : null;
await fetch("/api/v1/portal/select-device", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({device_id: id}),
});
location.reload();
});
})();
</script>
<script> <script>
window.confirmDelete = function(message) { window.confirmDelete = function(message) {
return Promise.resolve(window.confirm(message)); return Promise.resolve(window.confirm(message));