336 lines
12 KiB
Python
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()
|