Add clear DTC codes button to Pi DTC page (FEAT-15)
This commit is contained in:
+15
@@ -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`.
|
||||
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 +
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user