Files
yaris-obd/modules/app_sql.py
T
2026-08-09 19:04:47 -04:00

336 lines
12 KiB
Python

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
# 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
_conn = None
_conn_lock = Lock()
_log_thread = None
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,
NAME text NOT NULL,
STATUS text,
DTC_ID text
);"""
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
);"""
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)
_migrate_tracking_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 insert_tracking(gps_x, gps_y, speed, odometer=None):
"""Insert a single trip-tracking snapshot row (GPS position + speed)."""
conn = get_conn()
if conn is None:
return
sql = """INSERT INTO tracking (VN, NAME, DATETIME, GPSX, GPSY, SPEED, ODOMETER)
VALUES (?, ?, ?, ?, ?, ?, ?)"""
with _conn_lock:
try:
conn.execute(sql, (car.model, account.name, datetime.now(), gps_x, gps_y, speed, odometer))
conn.commit()
except Error as e:
print("Failed to insert tracking row:", e)
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 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
while True:
gps_x = app_gps.LocationX
gps_y = app_gps.LocationY
speed = app_obd.speed
if gps_x is not None and gps_y is not None:
insert_tracking(gps_x, gps_y, speed)
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()