feat: FEAT-05 — DTC history sync to remote server

- Server: dtc_events table (migrate_phase27_dtc.sql), db.py helpers
  (upsert_dtc_snapshot, list_dtc_events, delete_dtc_event)
- Server API: POST /api/v1/dtc/batch (device auth), GET/DELETE /api/v1/portal/dtc
- Server portal: /dtc page with code, description, MIL, status, timestamps, delete
- Server nav: DTC history in sidebar + mobile bottom nav
- Pi: _upload_dtc_snapshot() in app_upload.py — uploads when snapshot changes,
  skips when OBD disconnected or unchanged
This commit is contained in:
Mathieu Moran
2026-08-12 01:23:23 -04:00
parent 13cb8b4361
commit 3916f9879e
8 changed files with 302 additions and 14 deletions
+77
View File
@@ -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):