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)