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
+45
View File
@@ -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/<int:dtc_id>")
@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)
+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):
+18
View File
@@ -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);
+9
View File
@@ -49,6 +49,11 @@
<i class="fas fa-video fa-fw"></i><span>Dashcam archive</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link{% if request.endpoint == 'portal_dtc_page' %} active{% endif %}" href="{{ url_for('portal_dtc_page') }}">
<i class="fas fa-exclamation-triangle fa-fw"></i><span>DTC history</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link{% if request.endpoint == 'portal_device_settings_page' %} active{% endif %}" href="{{ url_for('portal_device_settings_page') }}">
<i class="fas fa-sliders-h fa-fw"></i><span>Device settings</span>
@@ -117,6 +122,10 @@
<i class="fas fa-sliders-h"></i>
<span>Device</span>
</a>
<a class="mobile-nav-item{% if request.endpoint == 'portal_dtc_page' %} active{% endif %}" href="{{ url_for('portal_dtc_page') }}">
<i class="fas fa-exclamation-triangle"></i>
<span>DTC</span>
</a>
<a class="mobile-nav-item" href="{{ url_for('logout') }}">
<i class="fas fa-sign-out-alt"></i>
<span>Logout</span>
+94
View File
@@ -0,0 +1,94 @@
{% extends "base.html" %}
{% block title %}DTC history – Yaris OBD Remote{% endblock %}
{% block content %}
<h3 class="text-dark mb-3 fw-bold">DTC history</h3>
<div class="row g-3">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header py-2 d-flex justify-content-between align-items-center">
<h6 class="text-primary fw-bold m-0">Diagnostic trouble codes</h6>
<span id="dtc-count" class="badge bg-secondary">0</span>
</div>
<div class="card-body p-0">
<div id="dtc-loading" class="text-center text-muted py-4">
<span class="spinner-border spinner-border-sm me-2" aria-hidden="true"></span>
Loading…
</div>
<div id="dtc-error" class="alert alert-danger m-3" role="alert" hidden></div>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0" id="dtc-table" hidden>
<thead class="table-light">
<tr>
<th class="ps-3">Code</th>
<th>Description</th>
<th>MIL</th>
<th>Status</th>
<th>First seen</th>
<th>Last seen</th>
<th class="pe-3">Actions</th>
</tr>
</thead>
<tbody id="dtc-body"></tbody>
</table>
</div>
<div id="dtc-empty" class="text-muted text-center py-4 small" hidden>No DTC events recorded yet.</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
(function () {
const loading = document.getElementById("dtc-loading");
const error = document.getElementById("dtc-error");
const table = document.getElementById("dtc-table");
const tbody = document.getElementById("dtc-body");
const empty = document.getElementById("dtc-empty");
const count = document.getElementById("dtc-count");
function fmt(iso) { return iso ? new Date(iso).toLocaleString() : "—"; }
async function load() {
try {
const r = await fetch("/api/v1/portal/dtc");
if (!r.ok) throw new Error("Cannot load DTC events.");
const events = await r.json();
loading.hidden = true;
count.textContent = events.length;
if (!events.length) { empty.hidden = false; return; }
tbody.innerHTML = events.map(e => `
<tr>
<td class="ps-3 fw-bold font-monospace ${e.is_active ? 'text-danger' : 'text-muted'}">${e.code}</td>
<td>${e.description || "—"}</td>
<td>${e.mil_on ? '<span class="badge text-bg-warning">ON</span>' : '<span class="badge text-bg-secondary">OFF</span>'}</td>
<td>${e.is_active ? '<span class="badge text-bg-danger">Active</span>' : '<span class="badge text-bg-success">Cleared</span>'}</td>
<td class="small text-muted">${fmt(e.first_seen_at)}</td>
<td class="small text-muted">${fmt(e.last_seen_at)}</td>
<td class="pe-3"><button class="btn btn-sm btn-outline-danger" data-dtc-id="${e.id}" title="Delete">🗑</button></td>
</tr>
`).join("");
table.hidden = false;
tbody.querySelectorAll("[data-dtc-id]").forEach(btn => {
btn.addEventListener("click", async () => {
if (!confirm("Delete this DTC event?")) return;
const r = await fetch("/api/v1/portal/dtc/" + btn.dataset.dtcId, {method: "DELETE"});
if (!r.ok) { error.textContent = "Could not delete."; error.hidden = false; return; }
load();
});
});
} catch (e) {
loading.hidden = true;
error.textContent = e.message;
error.hidden = false;
}
}
load();
})();
</script>
{% endblock %}