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:
+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 (
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user