Add trip history and route replay

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Mathieu Moran
2026-08-09 19:48:06 -04:00
co-authored by Copilot
parent 3a981f5c6d
commit d3bc313e7f
13 changed files with 1158 additions and 25 deletions
+251 -5
View File
@@ -6,6 +6,7 @@ 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
@@ -20,10 +21,16 @@ 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 = 2
_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,
@@ -69,6 +76,29 @@ SQL_CREATE_DASHCAM_HASH_CACHE_TABLE = """CREATE TABLE IF NOT EXISTS dashcam_hash
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
);"""
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
);"""
def create_conn(db_file):
"""Open a SQLite connection to db_file, creating its parent folder if needed.
@@ -111,7 +141,11 @@ def get_conn():
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)
_migrate_tracking_table(_conn)
_migrate_trips_table(_conn)
_migrate_trip_build_state_table(_conn)
else:
print("Error! cannot create the database connection.")
return _conn
@@ -126,20 +160,212 @@ def _migrate_tracking_table(conn):
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",
}
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 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
return False
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))
latitude = float(str(gps_x).replace("\x00", "").strip())
longitude = float(str(gps_y).replace("\x00", "").strip())
conn.execute(
sql,
(car.model, account.name, datetime.now(), latitude, longitude, speed, odometer),
)
conn.commit()
except Error as e:
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
FROM tracking WHERE id >= ? ORDER BY id ASC""",
(rebuild_start_id,),
).fetchall()
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
) 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
)""",
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 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
FROM trips ORDER BY START_DATETIME DESC"""
).fetchall()
return [{
"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],
} for row in rows]
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
FROM tracking WHERE id BETWEEN ? AND ?
ORDER BY DATETIME ASC, id ASC""",
(trip[8], trip[9]),
).fetchall()
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),
}
def get_setting(key, default=None):
@@ -307,13 +533,33 @@ def _log_loop():
# (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.speed
speed = app_obd.get_fresh_speed()
if gps_x is not None and gps_y is not None:
insert_tracking(gps_x, gps_y, speed)
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):
last_position = position
last_logged_at = now
time.sleep(LOG_INTERVAL)