From e873db918dd4d7c94535fa7bbe2f3a55f3c6b599 Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Thu, 13 Aug 2026 16:40:15 -0400 Subject: [PATCH] Add clear DTC codes button to Pi DTC page (FEAT-15) --- FEATURES.md | 15 +++++++++++ app.py | 10 ++++++++ modules/app_obd.py | 43 +++++++++++++++++++++++++++++++ modules/app_sql.py | 27 ++++++++++++++++++++ readme.md | 3 ++- templates/dtc.html | 27 ++++++++++++++++++++ tests/test_app_obd.py | 48 ++++++++++++++++++++++++++++++++++- tests/test_dtc_persistence.py | 16 ++++++++++++ 8 files changed, 187 insertions(+), 2 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index b94adf1..162ae1a 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -34,6 +34,7 @@ dedicated PLAN.md phase (or fold it into the relevant existing work) and mark it | FEAT-12 | `SHIPPED` | Log OBD speed before GPS fix (rolling-start capture) | Phase 27 | | FEAT-13 | `IDEA` | Flush uploaded tracking rows from local DB | unassigned | | FEAT-14 | `IDEA` | Second T3U to replace onboard wlan0 (dual AC radios) | unassigned | +| FEAT-15 | `SHIPPED` | Clear DTC codes from the DTC page | Phase 29 | --- @@ -275,3 +276,17 @@ dedicated PLAN.md phase (or fold it into the relevant existing work) and mark it - The rtl88x2bu driver is already built; a second identical dongle should be plug-and-play. +### FEAT-15 — Clear DTC codes from the DTC page +- **Status**: `SHIPPED` 2026-08-13 +- **Area**: `modules/app_obd.py`, `modules/app_sql.py`, `app.py`, `templates/dtc.html` +- **Phase**: Phase 29 +- **What**: The Pi `/dtc` page now has a **Clear codes** button that sends OBD mode 04 + (`CLEAR_DTC`) to the ECU and resets both the live and persisted DTC state, so codes can be + cleared right after a repair without a separate scanner. If the adapter isn't connected to + the car ECU it reports "not connected" (409), and if a fault is still physically present the + codes legitimately re-appear on the next sensor poll. +- **Files**: `clear_dtc_codes()` in `app_obd.py` (mode 04 + in-memory/socket reset), + `clear_dtc_state()` in `app_sql.py` (marks active rows `IS_SET=0`, resets `dtc_mil`), + `POST /api/dtc/clear` in `app.py`, and the button + confirm/status JS in `templates/dtc.html`. + + diff --git a/app.py b/app.py index 3bc7695..ddf1a40 100644 --- a/app.py +++ b/app.py @@ -440,6 +440,16 @@ def api_dtc(): "codes": fresh_codes, }) +@app.route('/api/dtc/clear', methods=['POST']) +def api_dtc_clear(): + result = app_obd.clear_dtc_codes() + status_codes = { + "cleared": 200, + "not_connected": 409, + "error": 502, + } + return jsonify(result), status_codes.get(result["status"], 500) + @app.route('/api/stats') def api_stats(): return jsonify(app_sql.get_overview_stats()) diff --git a/modules/app_obd.py b/modules/app_obd.py index 1301e2b..e304543 100644 --- a/modules/app_obd.py +++ b/modules/app_obd.py @@ -406,6 +406,49 @@ def _persist_dtc_state(): print("Failed to persist DTC state to SQLite:", error) +def clear_dtc_codes(): + """Clear stored DTCs on the ECU (OBD mode 04) and reset local state. + + Returns a dict with a "status" key ("cleared", "not_connected", or + "error") so the HTTP route can map it to a status code. Never raises — + the serial path can fail in normal use (e.g. adapter yanked mid-request). + """ + with _connection_lock: + candidate = connection + if candidate is None or not _is_car_connected(candidate): + return { + "status": "not_connected", + "error": "OBD is not connected to the car ECU.", + } + + global dtc_codes, mil_on, dtc_updated_at, mil_updated_at + try: + with _query_lock: + candidate.query(obd.commands.CLEAR_DTC) + except Exception as error: + _mark_connection_lost(candidate, "CLEAR_DTC", error) + return {"status": "error", "error": str(error)} + + # Reset the live snapshot so the UI reflects the cleared state immediately. + # If a fault is still physically present, the next sensor poll (within a + # few seconds) re-populates these — which is the correct behavior. + dtc_codes = [] + dtc_updated_at = time.monotonic() + mil_on = False + mil_updated_at = time.monotonic() + app_events.socketio.emit("obd_dtc", []) + app_events.socketio.emit("obd_mil", {"on": False, "dtc_count": 0}) + + # Also clear the persisted snapshot so it doesn't show stale active codes. + try: + from modules import app_sql + app_sql.clear_dtc_state() + except Exception as error: + print("Failed to clear persisted DTC state:", error) + + return {"status": "cleared"} + + def obd_sensors(): with obd_sensor_lock: while not stop_sensors.is_set() and _is_car_connected(): diff --git a/modules/app_sql.py b/modules/app_sql.py index 017600c..88f33cf 100644 --- a/modules/app_sql.py +++ b/modules/app_sql.py @@ -280,6 +280,33 @@ def save_dtc_state(mil_on, dtc_codes, recorded_at=None): print("Failed to persist DTC state:", e) +def clear_dtc_state(recorded_at=None): + """Mark every active DTC as cleared and reset the persisted MIL flag. + + Called after the user explicitly clears codes on the ECU (OBD mode 04) so + the persisted snapshot stops showing stale active codes. Cleared codes + keep their history rows (IS_SET = 0), like a normal "no longer detected" + poll would. + """ + conn = get_conn() + if conn is None: + return + now_str = (recorded_at or datetime.now()).isoformat() + with _conn_lock: + try: + conn.execute( + "INSERT INTO settings (key, value) VALUES ('dtc_mil', '0') " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value" + ) + conn.execute( + "UPDATE dtc SET IS_SET = 0, LAST_SEEN_AT = ? WHERE IS_SET = 1", + (now_str,), + ) + conn.commit() + except sqlite3.Error as e: + print("Failed to clear DTC state:", e) + + def get_persisted_dtc_state(): """Return the last-persisted MIL + DTC codes for display when OBD is offline.""" conn = get_conn() diff --git a/readme.md b/readme.md index bedf3ab..56e1f2c 100644 --- a/readme.md +++ b/readme.md @@ -33,7 +33,8 @@ Dashboard — mobile (390 px) and desktop (1280 px): ## Features - **Live OBD-II data** — speed, RPM, coolant temp, fuel level, throttle, MIL/DTC via USB ELM327. - Automatic reconnects with clear adapter/ECU diagnostics. DTC codes persist across reboots. + Automatic reconnects with clear adapter/ECU diagnostics. DTC codes persist across reboots and + can be cleared straight from the DTC page. - **Real GPS tracking** — Waveshare SIM868 GNSS HAT with GPIO PWRKEY auto-power-on. Live position plotted on an offline Leaflet map (pre-cached tiles, no internet needed). - **WiFi hotspot** — fast 5GHz in-car AP on the T3U (wlan1), internet uplink (home WiFi + diff --git a/templates/dtc.html b/templates/dtc.html index e55a924..cdc813a 100644 --- a/templates/dtc.html +++ b/templates/dtc.html @@ -14,6 +14,9 @@ + @@ -42,6 +45,7 @@ const milEl = document.getElementById('mil-indicator'); const updatedEl = document.getElementById('dtc-updated'); const refreshBtn = document.getElementById('dtc-refresh-btn'); + const clearBtn = document.getElementById('dtc-clear-btn'); const body = document.querySelector('#dtc-list [data-data-list-body]'); async function loadDtc() { @@ -83,6 +87,29 @@ } refreshBtn.addEventListener('click', loadDtc); + + clearBtn.addEventListener('click', async function () { + if (!window.confirm('Clear all DTC codes from the car ECU? This clears the check-engine light unless a fault is still present.')) { + return; + } + clearBtn.disabled = true; + updatedEl.textContent = 'Clearing…'; + try { + const r = await fetch('/api/dtc/clear', { method: 'POST' }); + const d = await r.json(); + if (!r.ok) { + updatedEl.textContent = d.error || 'Clear failed'; + return; + } + await loadDtc(); + updatedEl.textContent = 'Codes cleared'; + } catch (e) { + updatedEl.textContent = 'Clear failed'; + } finally { + clearBtn.disabled = false; + } + }); + loadDtc(); })(); diff --git a/tests/test_app_obd.py b/tests/test_app_obd.py index c9b3c59..a51099f 100644 --- a/tests/test_app_obd.py +++ b/tests/test_app_obd.py @@ -1,5 +1,7 @@ import unittest -from unittest.mock import patch +from unittest.mock import MagicMock, patch + +import obd from modules import app_obd @@ -26,5 +28,49 @@ class CarConnectedFlagTest(unittest.TestCase): mock_set.assert_called_once_with("car_ever_connected", "1") +class ClearDtcCodesTest(unittest.TestCase): + def setUp(self): + self.original_connection = app_obd.connection + self.original_dtc_codes = app_obd.dtc_codes + self.original_mil_on = app_obd.mil_on + + def tearDown(self): + app_obd.connection = self.original_connection + app_obd.dtc_codes = self.original_dtc_codes + app_obd.mil_on = self.original_mil_on + + def test_not_connected_returns_not_connected(self): + app_obd.connection = None + result = app_obd.clear_dtc_codes() + self.assertEqual(result["status"], "not_connected") + + def test_connected_clears_ecu_and_resets_state(self): + fake_conn = MagicMock() + app_obd.connection = fake_conn + app_obd.dtc_codes = [{"code": "P0300", "description": "Misfire"}] + app_obd.mil_on = True + with patch.object(app_obd, "_is_car_connected", return_value=True), \ + patch("modules.app_sql.clear_dtc_state") as mock_clear, \ + patch("modules.app_events.socketio.emit") as mock_emit: + result = app_obd.clear_dtc_codes() + self.assertEqual(result["status"], "cleared") + fake_conn.query.assert_called_once_with(obd.commands.CLEAR_DTC) + self.assertEqual(app_obd.dtc_codes, []) + self.assertFalse(app_obd.mil_on) + mock_clear.assert_called_once_with() + self.assertTrue(mock_emit.called) + + def test_query_error_returns_error(self): + fake_conn = MagicMock() + fake_conn.query.side_effect = RuntimeError("boom") + app_obd.connection = fake_conn + with patch.object(app_obd, "_is_car_connected", return_value=True), \ + patch.object(app_obd, "_mark_connection_lost") as mock_mark: + result = app_obd.clear_dtc_codes() + self.assertEqual(result["status"], "error") + self.assertIn("boom", result["error"]) + mock_mark.assert_called_once() + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_dtc_persistence.py b/tests/test_dtc_persistence.py index 801ec2c..0237488 100644 --- a/tests/test_dtc_persistence.py +++ b/tests/test_dtc_persistence.py @@ -62,6 +62,22 @@ class DtcPersistenceTest(unittest.TestCase): self.assertEqual(app_sql.count_active_dtcs(), 0) self.assertIs(app_sql.get_persisted_dtc_state()["mil"], False) + def test_clear_dtc_state_clears_active_and_mil(self): + app_sql.save_dtc_state(True, [ + {"code": "P0300", "description": "Random misfire"}, + {"code": "P0420", "description": "Catalyst efficiency"}, + ]) + app_sql.clear_dtc_state() + state = app_sql.get_persisted_dtc_state() + self.assertIs(state["mil"], False) + self.assertEqual(state["codes"], []) + self.assertEqual(app_sql.count_active_dtcs(), 0) + # History rows are kept but no longer set. + rows = self.conn.execute( + "SELECT code, IS_SET FROM dtc ORDER BY code" + ).fetchall() + self.assertEqual(rows, [("P0300", 0), ("P0420", 0)]) + def test_first_seen_preserved_while_last_seen_bumps(self): first = datetime(2026, 2, 1, 8, 0, 0) later = datetime(2026, 2, 1, 8, 5, 0)