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-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-13 | `IDEA` | Flush uploaded tracking rows from local DB | unassigned |
|
||||||
| FEAT-14 | `IDEA` | Second T3U to replace onboard wlan0 (dual AC radios) | 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.
|
- 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,
|
"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')
|
@app.route('/api/stats')
|
||||||
def api_stats():
|
def api_stats():
|
||||||
return jsonify(app_sql.get_overview_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)
|
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():
|
def obd_sensors():
|
||||||
with obd_sensor_lock:
|
with obd_sensor_lock:
|
||||||
while not stop_sensors.is_set() and _is_car_connected():
|
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)
|
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():
|
def get_persisted_dtc_state():
|
||||||
"""Return the last-persisted MIL + DTC codes for display when OBD is offline."""
|
"""Return the last-persisted MIL + DTC codes for display when OBD is offline."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ Dashboard — mobile (390 px) and desktop (1280 px):
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Live OBD-II data** — speed, RPM, coolant temp, fuel level, throttle, MIL/DTC via USB ELM327.
|
- **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
|
- **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).
|
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 +
|
- **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">
|
<button id="dtc-refresh-btn" class="btn btn-sm btn-secondary">
|
||||||
<i class="fas fa-sync-alt me-1"></i>Refresh
|
<i class="fas fa-sync-alt me-1"></i>Refresh
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -42,6 +45,7 @@
|
|||||||
const milEl = document.getElementById('mil-indicator');
|
const milEl = document.getElementById('mil-indicator');
|
||||||
const updatedEl = document.getElementById('dtc-updated');
|
const updatedEl = document.getElementById('dtc-updated');
|
||||||
const refreshBtn = document.getElementById('dtc-refresh-btn');
|
const refreshBtn = document.getElementById('dtc-refresh-btn');
|
||||||
|
const clearBtn = document.getElementById('dtc-clear-btn');
|
||||||
const body = document.querySelector('#dtc-list [data-data-list-body]');
|
const body = document.querySelector('#dtc-list [data-data-list-body]');
|
||||||
|
|
||||||
async function loadDtc() {
|
async function loadDtc() {
|
||||||
@@ -83,6 +87,29 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
refreshBtn.addEventListener('click', loadDtc);
|
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();
|
loadDtc();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+47
-1
@@ -1,5 +1,7 @@
|
|||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import obd
|
||||||
|
|
||||||
from modules import app_obd
|
from modules import app_obd
|
||||||
|
|
||||||
@@ -26,5 +28,49 @@ class CarConnectedFlagTest(unittest.TestCase):
|
|||||||
mock_set.assert_called_once_with("car_ever_connected", "1")
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -62,6 +62,22 @@ class DtcPersistenceTest(unittest.TestCase):
|
|||||||
self.assertEqual(app_sql.count_active_dtcs(), 0)
|
self.assertEqual(app_sql.count_active_dtcs(), 0)
|
||||||
self.assertIs(app_sql.get_persisted_dtc_state()["mil"], False)
|
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):
|
def test_first_seen_preserved_while_last_seen_bumps(self):
|
||||||
first = datetime(2026, 2, 1, 8, 0, 0)
|
first = datetime(2026, 2, 1, 8, 0, 0)
|
||||||
later = datetime(2026, 2, 1, 8, 5, 0)
|
later = datetime(2026, 2, 1, 8, 5, 0)
|
||||||
|
|||||||
Reference in New Issue
Block a user