Add clear DTC codes button to Pi DTC page (FEAT-15)

This commit is contained in:
Mathieu Moran
2026-08-13 16:40:15 -04:00
parent b39faadf58
commit e873db918d
8 changed files with 187 additions and 2 deletions
+15
View File
@@ -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`.
+10
View File
@@ -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())
+43
View File
@@ -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():
+27
View File
@@ -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()
+2 -1
View File
@@ -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 +
+27
View File
@@ -14,6 +14,9 @@
<button id="dtc-refresh-btn" class="btn btn-sm btn-secondary">
<i class="fas fa-sync-alt me-1"></i>Refresh
</button>
<button id="dtc-clear-btn" class="btn btn-sm btn-outline-danger">
<i class="fas fa-eraser me-1"></i>Clear codes
</button>
</div>
</div>
@@ -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();
})();
</script>
+47 -1
View File
@@ -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()
+16
View File
@@ -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)