diff --git a/android/app/src/main/java/com/yarisobd/app/DashcamViewModel.kt b/android/app/src/main/java/com/yarisobd/app/DashcamViewModel.kt index 4722cbb..dc142a3 100644 --- a/android/app/src/main/java/com/yarisobd/app/DashcamViewModel.kt +++ b/android/app/src/main/java/com/yarisobd/app/DashcamViewModel.kt @@ -34,12 +34,12 @@ data class DownloadProgress( class DashcamViewModel(application: Application) : AndroidViewModel(application) { - private val baseUrl = "http://10.42.0.1/" + private val baseUrl = "http://10.42.0.1" // no trailing slash private val okHttp = OkHttpClient.Builder().build() private val json = Json { ignoreUnknownKeys = true; coerceInputValues = true } private val api: PiApi = Retrofit.Builder() - .baseUrl(baseUrl) + .baseUrl("$baseUrl/") // Retrofit requires trailing slash .client(okHttp) .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) .build() @@ -93,14 +93,14 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application) val dir = app.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: return@launch val localFile = File(dir, video.path.substringAfterLast("/")) - // Skip if already downloaded + // Skip if already downloaded and size matches if (localFile.exists() && localFile.length() == video.size) { - _downloadEvents.emit("Already downloaded: ${localFile.name}") + _downloadEvents.emit("✓ Already downloaded: ${localFile.name}") return@launch } try { - withContext(Dispatchers.IO) { + val result = withContext(Dispatchers.IO) { val request = Request.Builder() .url("$baseUrl/api/local-video/file?path=${video.path}") .get() @@ -108,10 +108,10 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application) val response = okHttp.newCall(request).execute() if (!response.isSuccessful) { - throw Exception("HTTP ${response.code}") + return@withContext "Download failed: HTTP ${response.code}" } - val body = response.body ?: throw Exception("Empty body") + val body = response.body ?: return@withContext "Download failed: Empty body" val total = body.contentLength() localFile.parentFile?.mkdirs() @@ -124,19 +124,19 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application) if (read == -1L) break sink.write(buffer, read) downloaded += read - _downloadProgress.value = _downloadProgress.value + ( - video.path to DownloadProgress(video.path, downloaded, total) - ) + val progress = DownloadProgress(video.path, downloaded, total) + _downloadProgress.value = _downloadProgress.value + (video.path to progress) } } } _downloadProgress.value = _downloadProgress.value - video.path - _downloadEvents.emit("Downloaded: ${localFile.name}") + "✓ Downloaded: ${localFile.name} (${downloaded} bytes)" } + _downloadEvents.emit(result) } catch (e: Exception) { _downloadProgress.value = _downloadProgress.value - video.path - _downloadEvents.emit("Download failed: ${e.message}") + _downloadEvents.emit("✗ Download failed: ${e.message}") } } } diff --git a/android/app/src/main/java/com/yarisobd/app/data/remote/PiApi.kt b/android/app/src/main/java/com/yarisobd/app/data/remote/PiApi.kt index 3f4749d..74e4e51 100644 --- a/android/app/src/main/java/com/yarisobd/app/data/remote/PiApi.kt +++ b/android/app/src/main/java/com/yarisobd/app/data/remote/PiApi.kt @@ -4,8 +4,8 @@ import com.yarisobd.app.data.remote.dto.GpsStatus import com.yarisobd.app.data.remote.dto.VideoListResponse import okhttp3.ResponseBody import retrofit2.http.Body -import retrofit2.http.DELETE import retrofit2.http.GET +import retrofit2.http.HTTP import retrofit2.http.Query import retrofit2.http.Streaming @@ -32,6 +32,6 @@ interface PiApi { suspend fun getThumbnail(@Query("path") path: String): ResponseBody /** Delete local copy only */ - @DELETE("/api/local-video") + @HTTP(method = "DELETE", path = "/api/local-video", hasBody = true) suspend fun deleteVideo(@Body body: Map): Map } diff --git a/modules/app_upload.py b/modules/app_upload.py index 447f20f..605c88e 100644 --- a/modules/app_upload.py +++ b/modules/app_upload.py @@ -310,6 +310,50 @@ def _upload_dashcam_files(): return uploaded_count +_last_dtc_hash = None # tracks last-uploaded DTC snapshot to avoid redundant pushes + + +def _upload_dtc_snapshot(): + """Upload current DTC snapshot to server if it differs from last upload. + Skips when OBD is disconnected (no point sending empty snapshots that + would clear all server-side DTC events).""" + global _last_dtc_hash + import hashlib as _hl + from . import app_obd, app_sql + + # Only upload when OBD is actually connected — don't clear server DTCs + # just because the Pi rebooted or the adapter is unplugged. + if app_obd.connection is None: + return + + mil_on = app_obd.mil_on + codes = app_obd.dtc_codes or [] + if not codes and mil_on is None: + return # No data yet + + # Hash the current snapshot to detect changes + payload = {"mil_on": bool(mil_on), "codes": codes} + h = _hl.sha256(repr(payload).encode()).hexdigest() + if h == _last_dtc_hash: + return + _last_dtc_hash = h + + try: + creds = _credentials() + if not creds["server_url"] or not creds["device_name"] or not creds["api_key"]: + return + resp = requests.post( + f"{creds['server_url']}/api/v1/dtc/batch", + json=payload, + headers=_headers(creds), + timeout=5, + ) + resp.raise_for_status() + except requests.RequestException: + _last_dtc_hash = None # retry next cycle + raise + + def _upload_loop(): global _last_error, _last_success_at import datetime as _dt @@ -323,6 +367,7 @@ def _upload_loop(): sent = _upload_tracking_batch() uploaded = _upload_dashcam_files() cleanup = app_video.cleanup_uploaded_videos() + _upload_dtc_snapshot() # silently skips when unchanged or OBD offline if sent or uploaded: print(f"Upload: sent {sent} tracking rows, {uploaded} dashcam files") if cleanup["deleted"]: diff --git a/server/app.py b/server/app.py index 489e799..764c5d5 100644 --- a/server/app.py +++ b/server/app.py @@ -423,6 +423,12 @@ def portal_device_settings_page(): ) +@app.get("/dtc") +@require_login +def portal_dtc_page(): + return render_template("dtc.html") + + # ── Portal JSON API ──────────────────────────────────────────────────────── @app.get("/api/v1/portal/stats") @@ -850,6 +856,45 @@ def api_device_config_update(): return jsonify(config) +# ── DTC sync (FEAT-05) ──────────────────────────────────────────────────────── + +@app.post("/api/v1/dtc/batch") +@require_device_auth +def upload_dtc_batch(): + """Receive a DTC snapshot from the Pi. Body: {mil_on: bool, codes: [{code, description}, ...]}.""" + body = request.get_json(silent=True) or {} + mil_on = bool(body.get("mil_on", False)) + codes = body.get("codes") + if not isinstance(codes, list): + return jsonify(error="expected 'codes' as a list"), 400 + for c in codes: + if not isinstance(c.get("code"), str) or not c["code"].strip(): + return jsonify(error="each code must have a non-empty 'code' string"), 400 + result = db.upsert_dtc_snapshot(g.device_id, mil_on, codes) + return jsonify(result), 200 + + +@app.get("/api/v1/portal/dtc") +@require_login +def portal_dtc(): + device_id = _portal_device_id() + if device_id is None: + return jsonify(error="device not configured"), 404 + return jsonify(db.list_dtc_events(device_id)) + + +@app.delete("/api/v1/portal/dtc/") +@require_login +def portal_dtc_delete(dtc_id): + device_id = _portal_device_id() + if device_id is None: + return jsonify(error="device not configured"), 404 + deleted = db.delete_dtc_event(device_id, dtc_id) + if not deleted: + return jsonify(error="DTC event not found"), 404 + return jsonify(status="ok") + + def main(): app.run(host="0.0.0.0", port=8098) diff --git a/server/modules/db.py b/server/modules/db.py index 057b68a..8bc10dc 100644 --- a/server/modules/db.py +++ b/server/modules/db.py @@ -249,6 +249,83 @@ def insert_dashcam_file(device_id, relative_path, size_bytes, sha256, storage_pa ) +# ── DTC events (FEAT-05) ──────────────────────────────────────────────────── + +def upsert_dtc_snapshot(device_id, mil_on, codes): + """Receive a DTC snapshot from the Pi. Inserts new codes, updates existing, + marks codes absent from this snapshot as cleared. Returns stats dict.""" + with get_cursor(commit=True) as cur: + cur.execute( + "SELECT code FROM dtc_events WHERE device_id = %s AND is_active = TRUE", + (device_id,), + ) + previously_active = {row[0] for row in cur.fetchall()} + current_codes = {c["code"] for c in codes} + + new_codes = current_codes - previously_active + cleared_codes = previously_active - current_codes + unchanged = previously_active & current_codes + + with get_cursor(commit=True) as cur: + for c in codes: + if c["code"] in new_codes: + cur.execute( + """INSERT INTO dtc_events (device_id, code, description, mil_on, is_active) + VALUES (%s, %s, %s, %s, TRUE) + ON CONFLICT (device_id, code) DO UPDATE SET + description = EXCLUDED.description, + mil_on = EXCLUDED.mil_on, + is_active = TRUE, + last_seen_at = now()""", + (device_id, c["code"], c.get("description", ""), mil_on), + ) + elif c["code"] in unchanged: + cur.execute( + """UPDATE dtc_events SET mil_on = %s, last_seen_at = now(), is_active = TRUE + WHERE device_id = %s AND code = %s""", + (mil_on, device_id, c["code"]), + ) + for code in cleared_codes: + cur.execute( + """UPDATE dtc_events SET is_active = FALSE, cleared_at = now() + WHERE device_id = %s AND code = %s""", + (device_id, code), + ) + return {"received": len(codes), "cleared": len(cleared_codes), "unchanged": len(unchanged)} + + +def list_dtc_events(device_id): + with get_cursor() as cur: + cur.execute( + """SELECT id, code, description, mil_on, is_active, first_seen_at, last_seen_at, cleared_at + FROM dtc_events WHERE device_id = %s ORDER BY last_seen_at DESC""", + (device_id,), + ) + rows = cur.fetchall() + return [ + { + "id": r[0], + "code": r[1], + "description": r[2], + "mil_on": r[3], + "is_active": r[4], + "first_seen_at": r[5].isoformat() if r[5] else None, + "last_seen_at": r[6].isoformat() if r[6] else None, + "cleared_at": r[7].isoformat() if r[7] else None, + } + for r in rows + ] + + +def delete_dtc_event(device_id, dtc_id): + with get_cursor(commit=True) as cur: + cur.execute( + "DELETE FROM dtc_events WHERE id = %s AND device_id = %s RETURNING id", + (dtc_id, device_id), + ) + return cur.fetchone() is not None + + # ── User auth ────────────────────────────────────────────────────────────── def get_user_by_id(user_id): diff --git a/server/sql/migrate_phase27_dtc.sql b/server/sql/migrate_phase27_dtc.sql new file mode 100644 index 0000000..3396f2f --- /dev/null +++ b/server/sql/migrate_phase27_dtc.sql @@ -0,0 +1,18 @@ +-- Phase 27 / FEAT-05: DTC history synced to remote server. +-- Pi pushes current DTC snapshot; server diffs against stored state. + +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) +); + +CREATE INDEX IF NOT EXISTS idx_dtc_events_device_active + ON dtc_events (device_id, is_active); diff --git a/server/templates/base.html b/server/templates/base.html index 8de1a02..d8778e0 100644 --- a/server/templates/base.html +++ b/server/templates/base.html @@ -49,6 +49,11 @@ Dashcam archive +