fix(phase26): persist DTC/MIL state + server speed_source (BUG-03, BUG-05)

BUG-03: DTC/MIL codes were never persisted; lost on OBD disconnect or reboot.
- Add new dtc table schema (code, description, MIL, first/last seen, IS_SET)
  and a migration that rebuilds the original unused NAME/STATUS/DTC_ID table.
- Persist MIL + codes to SQLite on each successful OBD poll (_persist_dtc_state).
- /api/dtc falls back to the last-persisted snapshot when OBD is offline/stale.
- Fix get_overview_stats to count the real dtc table (was querying non-existent
  dtc_codes table).
- Add cleared/since tracking via IS_SET + FIRST_SEEN_AT/LAST_SEEN_AT.

BUG-05: server trip replay hardcoded speed_source:'gps'; mirror Pi's
'obd if speed>0 else gps' logic in server get_trip_route.

Adds tests/test_dtc_persistence.py (7 tests).
This commit is contained in:
Mathieu Moran
2026-08-11 00:38:05 -04:00
parent cd0583ac84
commit e2f2314318
5 changed files with 251 additions and 15 deletions
+10 -2
View File
@@ -335,9 +335,17 @@ def data():
@app.route('/api/dtc') @app.route('/api/dtc')
def api_dtc(): def api_dtc():
fresh_mil = app_obd.get_fresh_mil()
fresh_codes = app_obd.get_fresh_dtc_codes()
if fresh_mil is None and not fresh_codes:
# OBD offline/stale (disconnected or not yet polled): fall back to the
# last-persisted DTC snapshot so codes survive a disconnect/reboot.
persisted = app_sql.get_persisted_dtc_state()
fresh_mil = persisted["mil"]
fresh_codes = persisted["codes"]
return jsonify({ return jsonify({
"mil": app_obd.get_fresh_mil(), "mil": fresh_mil,
"codes": app_obd.get_fresh_dtc_codes(), "codes": fresh_codes,
}) })
@app.route('/api/stats') @app.route('/api/stats')
+13
View File
@@ -368,6 +368,19 @@ def _poll_diagnostics():
app_events.socketio.emit("obd_throttle", throttle_position) app_events.socketio.emit("obd_throttle", throttle_position)
app_events.socketio.emit("obd_odometer", odometer) app_events.socketio.emit("obd_odometer", odometer)
# Persist the latest MIL + DTC snapshot so last-known state survives an
# OBD disconnect or a power cycle (deferred import avoids a circular import).
_persist_dtc_state()
def _persist_dtc_state():
"""Best-effort persist of the current MIL + DTC codes to SQLite."""
try:
from modules import app_sql
app_sql.save_dtc_state(mil_on, dtc_codes)
except Exception as error:
print("Failed to persist DTC state to SQLite:", error)
def obd_sensors(): def obd_sensors():
with obd_sensor_lock: with obd_sensor_lock:
+116 -12
View File
@@ -46,9 +46,12 @@ SQL_CREATE_TRACKING_TABLE = """CREATE TABLE IF NOT EXISTS tracking (
SQL_CREATE_DTC_TABLE = """CREATE TABLE IF NOT EXISTS dtc ( SQL_CREATE_DTC_TABLE = """CREATE TABLE IF NOT EXISTS dtc (
id integer PRIMARY KEY, id integer PRIMARY KEY,
NAME text NOT NULL, code text NOT NULL UNIQUE,
STATUS text, description text,
DTC_ID text MIL integer,
FIRST_SEEN_AT datetime,
LAST_SEEN_AT datetime,
IS_SET integer NOT NULL DEFAULT 1
);""" );"""
SQL_CREATE_SETTINGS_TABLE = """CREATE TABLE IF NOT EXISTS settings ( SQL_CREATE_SETTINGS_TABLE = """CREATE TABLE IF NOT EXISTS settings (
@@ -161,6 +164,7 @@ def get_conn():
_migrate_tracking_table(_conn) _migrate_tracking_table(_conn)
_migrate_trips_table(_conn) _migrate_trips_table(_conn)
_migrate_trip_build_state_table(_conn) _migrate_trip_build_state_table(_conn)
_migrate_dtc_table(_conn)
else: else:
print("Error! cannot create the database connection.") print("Error! cannot create the database connection.")
return _conn return _conn
@@ -203,6 +207,112 @@ def _migrate_trip_build_state_table(conn):
conn.commit() conn.commit()
def _migrate_dtc_table(conn):
"""Rebuild the dtc table if it predates the persisted-DTC schema.
The original dtc table (NAME/STATUS/DTC_ID) was never written to by any
code path, so dropping and recreating it with the new schema is safe.
"""
existing_columns = {
row[1] for row in conn.execute("PRAGMA table_info(dtc)").fetchall()
}
if existing_columns and "code" not in existing_columns:
conn.execute("DROP TABLE IF EXISTS dtc")
create_table(conn, SQL_CREATE_DTC_TABLE)
conn.commit()
def save_dtc_state(mil_on, dtc_codes, recorded_at=None):
"""Persist a DTC snapshot (MIL + currently-set codes) to SQLite.
Last-known state can then survive an OBD disconnect or reboot. Codes that
were previously set but are no longer detected are marked cleared
(IS_SET = 0) and LAST_SEEN_AT is bumped, while FIRST_SEEN_AT preserves when
each code was first observed.
"""
conn = get_conn()
if conn is None:
return
now_str = (recorded_at or datetime.now()).isoformat()
current_codes = {c["code"] for c in dtc_codes if c.get("code")}
with _conn_lock:
try:
conn.execute(
"INSERT INTO settings (key, value) VALUES ('dtc_mil', ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
("1" if mil_on else "0",),
)
conn.execute(
"INSERT INTO settings (key, value) VALUES ('dtc_last_polled_at', ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(now_str,),
)
if current_codes:
placeholders = ",".join("?" for _ in current_codes)
conn.execute(
f"UPDATE dtc SET IS_SET = 0, LAST_SEEN_AT = ? "
f"WHERE code NOT IN ({placeholders})",
(now_str,) + tuple(current_codes),
)
else:
conn.execute(
"UPDATE dtc SET IS_SET = 0, LAST_SEEN_AT = ? WHERE IS_SET = 1",
(now_str,),
)
for code in sorted(current_codes):
description = next(
(c["description"] for c in dtc_codes if c.get("code") == code),
None,
)
conn.execute(
"""INSERT INTO dtc (code, description, MIL, FIRST_SEEN_AT,
LAST_SEEN_AT, IS_SET)
VALUES (?, ?, ?, ?, ?, 1)
ON CONFLICT(code) DO UPDATE SET
description = excluded.description,
MIL = excluded.MIL,
LAST_SEEN_AT = excluded.LAST_SEEN_AT,
IS_SET = 1""",
(code, description, 1 if mil_on else 0, now_str, now_str),
)
conn.commit()
except sqlite3.Error as e:
print("Failed to persist DTC state:", e)
def get_persisted_dtc_state():
"""Return the last-persisted MIL + DTC codes for display when OBD is offline."""
conn = get_conn()
if conn is None:
return {"mil": None, "codes": []}
with _conn_lock:
mil_row = conn.execute(
"SELECT value FROM settings WHERE key = 'dtc_mil'"
).fetchone()
codes = conn.execute(
"SELECT code, description FROM dtc WHERE IS_SET = 1 ORDER BY code ASC"
).fetchall()
mil = None
if mil_row and mil_row[0] not in (None, ""):
mil = mil_row[0] == "1"
return {
"mil": mil,
"codes": [{"code": r[0], "description": r[1]} for r in codes],
}
def count_active_dtcs():
"""Number of currently-set DTC codes persisted in SQLite (cleared excluded)."""
conn = get_conn()
if conn is None:
return 0
with _conn_lock:
row = conn.execute(
"SELECT COUNT(*) FROM dtc WHERE IS_SET = 1"
).fetchone()
return row[0] if row else 0
def insert_tracking(gps_x, gps_y, speed, odometer=None): def insert_tracking(gps_x, gps_y, speed, odometer=None):
"""Insert a single trip-tracking snapshot row (GPS position + speed).""" """Insert a single trip-tracking snapshot row (GPS position + speed)."""
conn = get_conn() conn = get_conn()
@@ -334,9 +444,9 @@ def get_overview_stats():
odo_row = conn.execute(""" odo_row = conn.execute("""
SELECT MAX(ODOMETER) FROM tracking WHERE ODOMETER IS NOT NULL AND ODOMETER > 0 SELECT MAX(ODOMETER) FROM tracking WHERE ODOMETER IS NOT NULL AND ODOMETER > 0
""").fetchone() """).fetchone()
dtc_row = conn.execute(""" dtc_row = conn.execute(
SELECT COUNT(*) FROM dtc_codes WHERE cleared_at IS NULL "SELECT COUNT(*) FROM dtc WHERE IS_SET = 1"
""").fetchone() if _table_exists(conn, "dtc_codes") else (0,) ).fetchone()
from modules import app_video as _av from modules import app_video as _av
try: try:
video_count = sum(1 for _ in _av.list_local_videos().get("files", [])) video_count = sum(1 for _ in _av.list_local_videos().get("files", []))
@@ -353,12 +463,6 @@ def get_overview_stats():
} }
def _table_exists(conn, name):
return conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (name,)
).fetchone() is not None
def list_trips(): def list_trips():
refresh_trips() refresh_trips()
conn = get_conn() conn = get_conn()
+3 -1
View File
@@ -443,7 +443,9 @@ def get_trip_route(device_id, trip_id):
"latitude": p[1], "latitude": p[1],
"longitude": p[2], "longitude": p[2],
"speed": p[3] or 0, "speed": p[3] or 0,
"speed_source": "gps", # Mirror the Pi's route_points: OBD speed when we have it,
# otherwise the point is sourced from GPS.
"speed_source": "obd" if (p[3] or 0) > 0 else "gps",
} }
for p in points for p in points
], ],
+109
View File
@@ -0,0 +1,109 @@
import os
import tempfile
import unittest
from datetime import datetime
from modules import app_sql
class DtcPersistenceTest(unittest.TestCase):
"""Tests for persisted DTC/MIL state (BUG-03)."""
def setUp(self):
handle, self.path = tempfile.mkstemp(suffix=".db")
os.close(handle)
self.original_path = app_sql.sql_path
app_sql._conn = None
app_sql.sql_path = self.path
self.conn = app_sql.get_conn()
def tearDown(self):
if app_sql._conn is not None:
app_sql._conn.close()
app_sql._conn = None
app_sql.sql_path = self.original_path
os.unlink(self.path)
def test_save_and_read_back_dtc_state(self):
app_sql.save_dtc_state(True, [
{"code": "P0300", "description": "Random misfire"},
{"code": "P0420", "description": "Catalyst efficiency"},
])
state = app_sql.get_persisted_dtc_state()
self.assertIs(state["mil"], True)
self.assertEqual(
[c["code"] for c in state["codes"]],
["P0300", "P0420"],
)
self.assertEqual(state["codes"][0]["description"], "Random misfire")
self.assertEqual(app_sql.count_active_dtcs(), 2)
def test_cleared_code_removed_from_active(self):
app_sql.save_dtc_state(True, [
{"code": "P0300", "description": "Random misfire"},
{"code": "P0420", "description": "Catalyst efficiency"},
])
# A later poll reports only P0420 -> P0300 is cleared.
app_sql.save_dtc_state(True, [
{"code": "P0420", "description": "Catalyst efficiency"},
])
state = app_sql.get_persisted_dtc_state()
self.assertEqual([c["code"] for c in state["codes"]], ["P0420"])
self.assertEqual(app_sql.count_active_dtcs(), 1)
# The cleared code keeps its history row (IS_SET=0).
rows = self.conn.execute(
"SELECT code, IS_SET FROM dtc ORDER BY code"
).fetchall()
self.assertEqual(rows, [("P0300", 0), ("P0420", 1)])
def test_empty_poll_clears_all(self):
app_sql.save_dtc_state(True, [{"code": "P0300", "description": "Misfire"}])
app_sql.save_dtc_state(False, [])
self.assertEqual(app_sql.count_active_dtcs(), 0)
self.assertIs(app_sql.get_persisted_dtc_state()["mil"], False)
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)
app_sql.save_dtc_state(True, [{"code": "P0300", "description": "M"}], first)
app_sql.save_dtc_state(True, [{"code": "P0300", "description": "M"}], later)
row = self.conn.execute(
"SELECT FIRST_SEEN_AT, LAST_SEEN_AT FROM dtc WHERE code = 'P0300'"
).fetchone()
self.assertEqual(row[0], first.isoformat())
self.assertEqual(row[1], later.isoformat())
def test_overview_stats_uses_dtc_table_not_broken_dtc_codes(self):
app_sql.save_dtc_state(True, [
{"code": "P0300", "description": "Random misfire"},
])
stats = app_sql.get_overview_stats()
self.assertEqual(stats["active_dtc"], 1)
def test_old_schema_dtc_table_migrated(self):
# Simulate the original (never-populated) dtc schema.
app_sql._conn.execute("DROP TABLE dtc")
app_sql._conn.execute(
"""CREATE TABLE dtc (
id integer PRIMARY KEY,
NAME text NOT NULL,
STATUS text,
DTC_ID text
)"""
)
app_sql._conn.commit()
app_sql._migrate_dtc_table(app_sql._conn)
columns = {
row[1] for row in app_sql._conn.execute("PRAGMA table_info(dtc)").fetchall()
}
self.assertIn("code", columns)
self.assertNotIn("DTC_ID", columns)
def test_no_persisted_state_returns_empty(self):
state = app_sql.get_persisted_dtc_state()
self.assertIsNone(state["mil"])
self.assertEqual(state["codes"], [])
if __name__ == "__main__":
unittest.main()