import sqlite3 import os import time from datetime import datetime from sqlite3 import Error from threading import Thread, Lock from modules import app_car, app_account, app_gps from modules import app_trip # Resolve db/sql/ relative to the repo root regardless of the process's cwd # (previously a relative "../db/sql/" path, which broke depending on how/from # where app.py was launched). _BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) _SQL_DIR = os.path.join(_BASE_DIR, "db", "sql") account = app_account.Account() car = app_car.Car() sCarModel = ''.join(letter for letter in car.model if letter.isalnum()) sql_file = account.name + "_" + sCarModel + ".db" sql_path = os.path.join(_SQL_DIR, sql_file) LOG_INTERVAL = 5 # seconds between trip-tracking snapshots IDLE_LOG_INTERVAL = 60 GPS_LOG_DISTANCE_METERS = 8 TRIP_REFRESH_INTERVAL = 30 TRIP_REBUILD_OVERLAP_ROWS = 120 TRIP_BUILD_VERSION = 3 _conn = None _conn_lock = Lock() _log_thread = None _last_trip_refresh = 0 SQL_CREATE_TRACKING_TABLE = """CREATE TABLE IF NOT EXISTS tracking ( id integer PRIMARY KEY, VN text NOT NULL, NAME text NOT NULL, DATETIME datetime, GPSX float, GPSY float, SPEED float, ODOMETER float, UPLOADED integer DEFAULT 0 );""" SQL_CREATE_DTC_TABLE = """CREATE TABLE IF NOT EXISTS dtc ( id integer PRIMARY KEY, 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 ( key text PRIMARY KEY, value text );""" SQL_CREATE_DASHCAM_UPLOADS_TABLE = """CREATE TABLE IF NOT EXISTS dashcam_uploads ( id integer PRIMARY KEY, RELATIVE_PATH text NOT NULL, SHA256 text NOT NULL, UPLOADED_AT datetime, UNIQUE(RELATIVE_PATH, SHA256) );""" # Cache of already-computed sha256 hashes, keyed by (path, size, mtime) so a # file's hash never needs recomputing once copied from the dashcam (its size # and mtime won't change afterward). Avoids re-hashing the entire, possibly # 20GB+, local backlog on every background-upload loop iteration and every # "pending files" list request from the phone-relay feature. SQL_CREATE_DASHCAM_HASH_CACHE_TABLE = """CREATE TABLE IF NOT EXISTS dashcam_hash_cache ( RELATIVE_PATH text PRIMARY KEY, SIZE integer NOT NULL, MTIME real NOT NULL, SHA256 text NOT NULL );""" SQL_CREATE_TRIPS_TABLE = """CREATE TABLE IF NOT EXISTS trips ( id integer PRIMARY KEY, START_TRACKING_ID integer NOT NULL, END_TRACKING_ID integer NOT NULL, START_DATETIME datetime NOT NULL, END_DATETIME datetime NOT NULL, START_GPSX float NOT NULL, START_GPSY float NOT NULL, END_GPSX float NOT NULL, END_GPSY float NOT NULL, DISTANCE_KM float NOT NULL, DURATION_SECONDS integer NOT NULL, POINT_COUNT integer NOT NULL, AVERAGE_SPEED_KMH float NOT NULL DEFAULT 0, MAX_SPEED_KMH float NOT NULL DEFAULT 0, START_ODOMETER float, END_ODOMETER float );""" SQL_CREATE_TRIP_BUILD_STATE_TABLE = """CREATE TABLE IF NOT EXISTS trip_build_state ( id integer PRIMARY KEY CHECK (id = 1), LAST_TRACKING_ID integer NOT NULL, BUILD_VERSION integer NOT NULL DEFAULT 1 );""" SQL_CREATE_ALERT_STATE_TABLE = """CREATE TABLE IF NOT EXISTS alert_state ( ALERT_KEY text PRIMARY KEY, ACTIVE integer NOT NULL DEFAULT 0, ACTIVE_SINCE real, LAST_ATTEMPT_AT real, LAST_SENT_AT real, RECOVERY_PENDING integer NOT NULL DEFAULT 0, LAST_RECOVERY_AT real, LAST_ERROR text, LAST_VALUE text );""" def create_conn(db_file): """Open a SQLite connection to db_file, creating its parent folder if needed. Returns the open connection (or None on failure) - previously this closed the connection immediately in a `finally` block and returned nothing, so every caller always got a dead `None` connection. """ os.makedirs(os.path.dirname(db_file), exist_ok=True) conn = None try: conn = sqlite3.connect(db_file, check_same_thread=False) print("SQLite library version:", sqlite3.sqlite_version) except Error as e: print(e) return conn def create_table(conn, create_table_sql): """ create a table from the create_table_sql statement :param conn: Connection object :param create_table_sql: a CREATE TABLE statement :return: """ try: c = conn.cursor() c.execute(create_table_sql) except Error as e: print(e) def get_conn(): """Return the shared SQLite connection, opening + initializing tables on first use.""" global _conn if _conn is None: _conn = create_conn(sql_path) if _conn is not None: create_table(_conn, SQL_CREATE_TRACKING_TABLE) create_table(_conn, SQL_CREATE_DTC_TABLE) create_table(_conn, SQL_CREATE_SETTINGS_TABLE) create_table(_conn, SQL_CREATE_DASHCAM_UPLOADS_TABLE) create_table(_conn, SQL_CREATE_DASHCAM_HASH_CACHE_TABLE) create_table(_conn, SQL_CREATE_TRIPS_TABLE) create_table(_conn, SQL_CREATE_TRIP_BUILD_STATE_TABLE) create_table(_conn, SQL_CREATE_ALERT_STATE_TABLE) _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 def _migrate_tracking_table(conn): """Add the UPLOADED column to a pre-existing tracking table that predates the remote-upload feature (SQLite has no "ADD COLUMN IF NOT EXISTS").""" existing_columns = {row[1] for row in conn.execute("PRAGMA table_info(tracking)").fetchall()} if "UPLOADED" not in existing_columns: conn.execute("ALTER TABLE tracking ADD COLUMN UPLOADED integer DEFAULT 0") conn.commit() def _migrate_trips_table(conn): existing_columns = { row[1] for row in conn.execute("PRAGMA table_info(trips)").fetchall() } additions = { "AVERAGE_SPEED_KMH": "float NOT NULL DEFAULT 0", "MAX_SPEED_KMH": "float NOT NULL DEFAULT 0", "START_ODOMETER": "float", "END_ODOMETER": "float", } for name, definition in additions.items(): if name not in existing_columns: conn.execute(f"ALTER TABLE trips ADD COLUMN {name} {definition}") conn.commit() def _migrate_trip_build_state_table(conn): existing_columns = { row[1] for row in conn.execute("PRAGMA table_info(trip_build_state)").fetchall() } if "BUILD_VERSION" not in existing_columns: conn.execute( "ALTER TABLE trip_build_state " "ADD COLUMN BUILD_VERSION integer NOT NULL DEFAULT 1" ) 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 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(): """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). gps_x/gps_y may be None (logged before the first GPS fix) — the row then stores NULL coordinates and relies on OBD speed for motion detection. """ conn = get_conn() if conn is None: return False sql = """INSERT INTO tracking (VN, NAME, DATETIME, GPSX, GPSY, SPEED, ODOMETER) VALUES (?, ?, ?, ?, ?, ?, ?)""" with _conn_lock: try: latitude = ( None if gps_x is None else float(str(gps_x).replace("\x00", "").strip()) ) longitude = ( None if gps_y is None else float(str(gps_y).replace("\x00", "").strip()) ) conn.execute( sql, (car.model, account.name, datetime.now(), latitude, longitude, speed, odometer), ) conn.commit() return True except (Error, TypeError, ValueError) as e: print("Failed to insert tracking row:", e) return False def should_log_tracking(position, speed, last_position, seconds_since_log): if last_position is None: return True if speed is not None and speed > 1: return True distance_meters = app_trip.haversine_km(last_position, position) * 1000 return ( distance_meters >= GPS_LOG_DISTANCE_METERS or seconds_since_log >= IDLE_LOG_INTERVAL ) def refresh_trips(force=False): """Rebuild the derived trip index when new raw tracking rows are available.""" global _last_trip_refresh conn = get_conn() if conn is None: return 0 with _conn_lock: last_tracking_id = conn.execute( "SELECT COALESCE(MAX(id), 0) FROM tracking" ).fetchone()[0] state = conn.execute( "SELECT LAST_TRACKING_ID, BUILD_VERSION " "FROM trip_build_state WHERE id = 1" ).fetchone() recently_refreshed = ( _last_trip_refresh and time.monotonic() - _last_trip_refresh < TRIP_REFRESH_INTERVAL ) if ( not force and state is not None and state[1] == TRIP_BUILD_VERSION and (state[0] == last_tracking_id or recently_refreshed) ): return conn.execute("SELECT COUNT(*) FROM trips").fetchone()[0] rebuild_start_id = 1 if not force and state is not None and state[1] == TRIP_BUILD_VERSION: rebuild_start_id = max(1, state[0] - TRIP_REBUILD_OVERLAP_ROWS) intersecting_trip = conn.execute( """SELECT MIN(START_TRACKING_ID) FROM trips WHERE END_TRACKING_ID >= ?""", (rebuild_start_id,), ).fetchone() if intersecting_trip[0] is not None: rebuild_start_id = min(rebuild_start_id, intersecting_trip[0]) rows = conn.execute( """SELECT id, DATETIME, GPSX, GPSY, SPEED, ODOMETER FROM tracking WHERE id >= ? ORDER BY id ASC""", (rebuild_start_id,), ).fetchall() from modules import app_obd if not app_obd.was_car_ever_connected(): # Never connected to the car — preserve existing trips, don't delete them. return conn.execute("SELECT COUNT(*) FROM trips").fetchone()[0] trips = app_trip.build_trip_summaries(rows) with _conn_lock: try: conn.execute( "DELETE FROM trips WHERE END_TRACKING_ID >= ?", (rebuild_start_id,), ) conn.executemany( """INSERT INTO trips ( id, START_TRACKING_ID, END_TRACKING_ID, START_DATETIME, END_DATETIME, START_GPSX, START_GPSY, END_GPSX, END_GPSY, DISTANCE_KM, DURATION_SECONDS, POINT_COUNT, AVERAGE_SPEED_KMH, MAX_SPEED_KMH, START_ODOMETER, END_ODOMETER ) VALUES ( :id, :start_tracking_id, :end_tracking_id, :start_datetime, :end_datetime, :start_gpsx, :start_gpsy, :end_gpsx, :end_gpsy, :distance_km, :duration_seconds, :point_count, :average_speed_kmh, :max_speed_kmh, :start_odometer, :end_odometer )""", trips, ) conn.execute( """INSERT INTO trip_build_state ( id, LAST_TRACKING_ID, BUILD_VERSION ) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET LAST_TRACKING_ID = excluded.LAST_TRACKING_ID, BUILD_VERSION = excluded.BUILD_VERSION""", (last_tracking_id, TRIP_BUILD_VERSION), ) conn.commit() _last_trip_refresh = time.monotonic() except Error: conn.rollback() raise with _conn_lock: return conn.execute("SELECT COUNT(*) FROM trips").fetchone()[0] def get_overview_stats(): conn = get_conn() with _conn_lock: row = conn.execute(""" SELECT COUNT(*), COALESCE(SUM(DISTANCE_KM),0), COALESCE(SUM(DURATION_SECONDS),0), COALESCE(MAX(MAX_SPEED_KMH),0) FROM trips """).fetchone() 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 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", [])) except Exception: video_count = 0 from modules import app_settings base = app_settings._number_value("odometer_baseline", float, 0) cumul = conn.execute("SELECT COALESCE(SUM(DISTANCE_KM), 0) FROM trips").fetchone() computed = base + (cumul[0] if cumul else 0) return { "trip_count": row[0], "total_km": row[1], "total_duration_s": row[2], "all_time_max_speed": row[3], "active_dtc": dtc_row[0] if dtc_row else 0, "video_count": video_count, "odometer_km": computed if base > 0 else (odo_row[0] if odo_row and odo_row[0] else None), "odometer_source": "manual" if base > 0 else "obd", } def list_trips(): refresh_trips() conn = get_conn() if conn is None: return [] with _conn_lock: rows = conn.execute( """SELECT id, START_DATETIME, END_DATETIME, START_GPSX, START_GPSY, END_GPSX, END_GPSY, DISTANCE_KM, DURATION_SECONDS, POINT_COUNT, AVERAGE_SPEED_KMH, MAX_SPEED_KMH, START_ODOMETER, END_ODOMETER FROM trips ORDER BY START_DATETIME ASC""" ).fetchall() # If real OBD odometer is available use it; otherwise fall back to # baseline + cumulative distance (continuous across trips). from modules import app_settings baseline = app_settings._number_value("odometer_baseline", float, 0) result = [] cumulative_km = baseline for row in rows: dist = row[7] or 0 start_odo = row[12] if row[12] is not None else round(cumulative_km, 1) end_odo = row[13] if row[13] is not None else round(cumulative_km + dist, 1) cumulative_km += dist result.append({ "id": row[0], "start_datetime": row[1], "end_datetime": row[2], "start": {"latitude": row[3], "longitude": row[4]}, "end": {"latitude": row[5], "longitude": row[6]}, "distance_km": row[7], "duration_seconds": row[8], "point_count": row[9], "average_speed_kmh": row[10], "max_speed_kmh": row[11], "odo_start_km": start_odo, "odo_end_km": end_odo, }) result.reverse() # newest first for display return result def get_trip_route(trip_id, max_points=2000): refresh_trips() conn = get_conn() if conn is None: return None with _conn_lock: trip = conn.execute( """SELECT id, START_DATETIME, END_DATETIME, DISTANCE_KM, DURATION_SECONDS, POINT_COUNT, AVERAGE_SPEED_KMH, MAX_SPEED_KMH, START_TRACKING_ID, END_TRACKING_ID FROM trips WHERE id = ?""", (trip_id,), ).fetchone() if trip is None: return None rows = conn.execute( """SELECT id, DATETIME, GPSX, GPSY, SPEED, ODOMETER FROM tracking WHERE id BETWEEN ? AND ? ORDER BY DATETIME ASC, id ASC""", (trip[8], trip[9]), ).fetchall() prior = conn.execute( """SELECT COALESCE(SUM(DISTANCE_KM), 0) FROM trips WHERE START_DATETIME < ?""", (trip[1],), ).fetchone() from modules import app_settings baseline = app_settings._number_value("odometer_baseline", float, 0) prior_km = prior[0] if prior and prior[0] is not None else 0.0 return { "trip": { "id": trip[0], "start_datetime": trip[1], "end_datetime": trip[2], "distance_km": trip[3], "duration_seconds": trip[4], "point_count": trip[5], "average_speed_kmh": trip[6], "max_speed_kmh": trip[7], }, "points": app_trip.route_points( rows, max_points=max_points, odometer_baseline=baseline, prior_km=prior_km, ), } def delete_trip(trip_id): """Delete a computed trip record. Raw tracking rows are NOT removed.""" conn = get_conn() if conn is None: return False with _conn_lock: cur = conn.execute("DELETE FROM trips WHERE id = ?", (trip_id,)) conn.commit() return cur.rowcount > 0 def get_setting(key, default=None): """Read a persisted UI/app setting (e.g. the dashcam-upload toggle).""" conn = get_conn() if conn is None: return default with _conn_lock: cur = conn.execute("SELECT value FROM settings WHERE key = ?", (key,)) row = cur.fetchone() return row[0] if row is not None else default def set_setting(key, value): conn = get_conn() if conn is None: return with _conn_lock: conn.execute( "INSERT INTO settings (key, value) VALUES (?, ?) " "ON CONFLICT(key) DO UPDATE SET value = excluded.value", (key, value), ) conn.commit() def set_settings(settings): """Persist a validated group of settings atomically.""" conn = get_conn() if conn is None: raise RuntimeError("Settings database is not available.") with _conn_lock: try: conn.executemany( "INSERT INTO settings (key, value) VALUES (?, ?) " "ON CONFLICT(key) DO UPDATE SET value = excluded.value", settings.items(), ) conn.commit() except sqlite3.Error: conn.rollback() raise def get_alert_states(): conn = get_conn() if conn is None: return {} with _conn_lock: rows = conn.execute( """SELECT ALERT_KEY, ACTIVE, ACTIVE_SINCE, LAST_ATTEMPT_AT, LAST_SENT_AT, RECOVERY_PENDING, LAST_RECOVERY_AT, LAST_ERROR, LAST_VALUE FROM alert_state""" ).fetchall() return { row[0]: { "active": bool(row[1]), "active_since": row[2], "last_attempt_at": row[3], "last_sent_at": row[4], "recovery_pending": bool(row[5]), "last_recovery_at": row[6], "last_error": row[7], "last_value": row[8], } for row in rows } def save_alert_state(alert_key, state): conn = get_conn() if conn is None: raise RuntimeError("SQLite connection unavailable") with _conn_lock: conn.execute( """INSERT INTO alert_state ( ALERT_KEY, ACTIVE, ACTIVE_SINCE, LAST_ATTEMPT_AT, LAST_SENT_AT, RECOVERY_PENDING, LAST_RECOVERY_AT, LAST_ERROR, LAST_VALUE ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(ALERT_KEY) DO UPDATE SET ACTIVE = excluded.ACTIVE, ACTIVE_SINCE = excluded.ACTIVE_SINCE, LAST_ATTEMPT_AT = excluded.LAST_ATTEMPT_AT, LAST_SENT_AT = excluded.LAST_SENT_AT, RECOVERY_PENDING = excluded.RECOVERY_PENDING, LAST_RECOVERY_AT = excluded.LAST_RECOVERY_AT, LAST_ERROR = excluded.LAST_ERROR, LAST_VALUE = excluded.LAST_VALUE""", ( alert_key, 1 if state["active"] else 0, state["active_since"], state["last_attempt_at"], state["last_sent_at"], 1 if state["recovery_pending"] else 0, state["last_recovery_at"], state["last_error"], state["last_value"], ), ) conn.commit() def get_unsent_tracking_rows(limit=100): """Return up to `limit` tracking rows not yet uploaded to the remote server, oldest first, as a list of dicts ready to be sent as JSON.""" conn = get_conn() if conn is None: return [] with _conn_lock: cur = conn.execute( """SELECT id, VN, NAME, DATETIME, GPSX, GPSY, SPEED, ODOMETER FROM tracking WHERE UPLOADED = 0 ORDER BY id ASC LIMIT ?""", (limit,), ) rows = cur.fetchall() return [ { "id": r[0], "vn": r[1], "name": r[2], "datetime": r[3], "gpsx": r[4], "gpsy": r[5], "speed": r[6], "odometer": r[7], } for r in rows ] def count_unsent_tracking_rows(): conn = get_conn() if conn is None: return 0 with _conn_lock: cur = conn.execute("SELECT COUNT(*) FROM tracking WHERE UPLOADED = 0") return cur.fetchone()[0] def mark_tracking_rows_uploaded(ids): """Flip UPLOADED=1 for the given row ids after a successful server ack.""" if not ids: return conn = get_conn() if conn is None: return with _conn_lock: conn.executemany("UPDATE tracking SET UPLOADED = 1 WHERE id = ?", [(i,) for i in ids]) conn.commit() def dashcam_file_marked_uploaded(relative_path, sha256): """Whether this exact (path, content) was already confirmed uploaded.""" conn = get_conn() if conn is None: return False with _conn_lock: cur = conn.execute( "SELECT 1 FROM dashcam_uploads WHERE RELATIVE_PATH = ? AND SHA256 = ?", (relative_path, sha256), ) return cur.fetchone() is not None def dashcam_file_uploaded_for_stat(relative_path, size, mtime): """Whether the current local file version is confirmed uploaded. Uses the existing hash cache so list/retention scans never rehash large video files merely to display their status. """ conn = get_conn() if conn is None: return False with _conn_lock: cur = conn.execute( """SELECT 1 FROM dashcam_hash_cache AS cache JOIN dashcam_uploads AS uploads ON uploads.RELATIVE_PATH = cache.RELATIVE_PATH AND uploads.SHA256 = cache.SHA256 WHERE cache.RELATIVE_PATH = ? AND cache.SIZE = ? AND cache.MTIME = ?""", (relative_path, size, mtime), ) return cur.fetchone() is not None def uploaded_dashcam_file_stats(): """Return exact uploaded file versions as (path, size, mtime) tuples.""" conn = get_conn() if conn is None: return set() with _conn_lock: cur = conn.execute( """SELECT cache.RELATIVE_PATH, cache.SIZE, cache.MTIME FROM dashcam_hash_cache AS cache JOIN dashcam_uploads AS uploads ON uploads.RELATIVE_PATH = cache.RELATIVE_PATH AND uploads.SHA256 = cache.SHA256""" ) return set(cur.fetchall()) def mark_dashcam_file_uploaded(relative_path, sha256): conn = get_conn() if conn is None: return with _conn_lock: conn.execute( "INSERT OR IGNORE INTO dashcam_uploads (RELATIVE_PATH, SHA256, UPLOADED_AT) VALUES (?, ?, ?)", (relative_path, sha256, datetime.now()), ) conn.commit() def get_cached_dashcam_hash(relative_path, size, mtime): """Return a previously-computed sha256 for this exact (path, size, mtime), or None if not cached / the file has changed since it was last hashed.""" conn = get_conn() if conn is None: return None with _conn_lock: cur = conn.execute( "SELECT SHA256 FROM dashcam_hash_cache WHERE RELATIVE_PATH = ? AND SIZE = ? AND MTIME = ?", (relative_path, size, mtime), ) row = cur.fetchone() return row[0] if row is not None else None def set_cached_dashcam_hash(relative_path, size, mtime, sha256): conn = get_conn() if conn is None: return with _conn_lock: conn.execute( "INSERT INTO dashcam_hash_cache (RELATIVE_PATH, SIZE, MTIME, SHA256) VALUES (?, ?, ?, ?) " "ON CONFLICT(RELATIVE_PATH) DO UPDATE SET SIZE = excluded.SIZE, MTIME = excluded.MTIME, SHA256 = excluded.SHA256", (relative_path, size, mtime, sha256), ) conn.commit() def _log_loop(): # Deferred import to avoid a circular import at module load time # (app_obd.py imports app_sql, and app_sql needs app_obd.speed here). from modules import app_obd last_position = None last_logged_at = None while True: gps_x = app_gps.LocationX gps_y = app_gps.LocationY speed = app_obd.get_fresh_speed() if gps_x is not None and gps_y is not None: try: position = ( float(str(gps_x).replace("\x00", "").strip()), float(str(gps_y).replace("\x00", "").strip()), ) except (TypeError, ValueError) as error: print("Skipping invalid GPS tracking snapshot:", error) else: now = time.monotonic() seconds_since_log = ( now - last_logged_at if last_logged_at is not None else IDLE_LOG_INTERVAL ) if should_log_tracking( position, speed, last_position, seconds_since_log ) and insert_tracking(position[0], position[1], speed, app_obd.odometer): last_position = position last_logged_at = now elif speed is not None and speed > 1: # No GPS fix yet but the car is moving (OBD speed) — log a # speed-only row so a rolling start / mid-drive reboot isn't lost. # Coordinates stay NULL until the first fix; trip building treats # these as OBD-motion events with zero GPS distance. insert_tracking(None, None, speed, app_obd.odometer) time.sleep(LOG_INTERVAL) def start_logging(): """Start the background thread that periodically snapshots GPS+speed into SQLite.""" global _log_thread if _log_thread is None or not _log_thread.is_alive(): get_conn() # make sure the db file/tables exist before the first insert _log_thread = Thread(target=_log_loop) _log_thread.daemon = True _log_thread.start() print(f"SQL tracking-logger thread started, alive: {_log_thread.is_alive()}") else: print(f"SQL tracking-logger thread already running, alive: {_log_thread.is_alive()}") if __name__ == '__main__': get_conn()