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
+13
View File
@@ -368,6 +368,19 @@ def _poll_diagnostics():
app_events.socketio.emit("obd_throttle", throttle_position)
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():
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 (
id integer PRIMARY KEY,
NAME text NOT NULL,
STATUS text,
DTC_ID text
code text NOT NULL UNIQUE,
description 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 (
@@ -161,6 +164,7 @@ def get_conn():
_migrate_tracking_table(_conn)
_migrate_trips_table(_conn)
_migrate_trip_build_state_table(_conn)
_migrate_dtc_table(_conn)
else:
print("Error! cannot create the database connection.")
return _conn
@@ -203,6 +207,112 @@ def _migrate_trip_build_state_table(conn):
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):
"""Insert a single trip-tracking snapshot row (GPS position + speed)."""
conn = get_conn()
@@ -334,9 +444,9 @@ def get_overview_stats():
odo_row = conn.execute("""
SELECT MAX(ODOMETER) FROM tracking WHERE ODOMETER IS NOT NULL AND ODOMETER > 0
""").fetchone()
dtc_row = conn.execute("""
SELECT COUNT(*) FROM dtc_codes WHERE cleared_at IS NULL
""").fetchone() if _table_exists(conn, "dtc_codes") else (0,)
dtc_row = conn.execute(
"SELECT COUNT(*) FROM dtc WHERE IS_SET = 1"
).fetchone()
from modules import app_video as _av
try:
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():
refresh_trips()
conn = get_conn()