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:
@@ -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')
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user