Revive yaris-obd: USB OBD adapter, real GPS/GNSS, offline map, hotspot, SQLite logging, dashcam retrieval

Squashed rewrite of the project revival work (replaces prior history on this
branch to remove personal data that had been accidentally committed - real
LAN IP, GPS coordinates, home WiFi SSID, and phone model). See PLAN.md for
full phase-by-phase details. Summary:

- Phase 2: adapt app_obd.py for a USB OBD-II adapter (was Bluetooth-only),
  auto-detect /dev/ttyUSB*, fix several pre-existing crash bugs.
- Phase 3/4: make the web UI work fully offline (vendored Socket.IO/Leaflet/
  Chart.js/FontAwesome, offline map tile downloader script, live GPS marker
  tracking fix).
- Phase 5: systemd deployment/autostart on the Pi.
- Phase 6: WiFi hotspot mode via NetworkManager (auto-switch between home
  WiFi and a local AP for in-car web UI access), mDNS hostname + port 80
  redirect so the UI is reachable at a plain hostname with no port number.
- Phase 7: fix app_db.py/app_sql.py bugs, wire up local SQLite trip logging.
- Phase 8: brand-agnostic USB dashcam file retrieval (lsblk transport-based
  detection, works with any dashcam brand/folder layout).
- Rewritten readme.md with a proper project overview.

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Mathieu Moran
2026-08-08 18:01:35 -04:00
co-authored by Copilot
parent f91a3d479d
commit 261ef3f4db
46 changed files with 2150 additions and 255 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+217 -38
View File
@@ -1,38 +1,217 @@
import os
import shutil
from tqdm import tqdm
def mount_device(device, mount_point):
os.system(f"sudo mount {device} {mount_point}")
def unmount_device(mount_point):
os.system(f"sudo umount {mount_point}")
def copy_files(source_folder, destination_folder):
if not os.path.exists(destination_folder):
os.makedirs(destination_folder)
for root, dirs, files in os.walk(source_folder):
for file in tqdm(files, desc="Copying files", unit="file"):
source_path = os.path.join(root, file)
destination_path = os.path.join(destination_folder, file)
# Check if the file already exists in the destination folder
if not os.path.exists(destination_path):
shutil.copy2(source_path, destination_folder)
if __name__ == "__main__":
source_device = "/dev/sda"
mount_point = "/mnt/dash"
destination_folder = "~/yaris-obd/dash/videos"
try:
# Mount the device
mount_device(source_device, mount_point)
# Copy files from source to destination
copy_files(os.path.join(mount_point, "DCIM"), os.path.expanduser(destination_folder))
finally:
# Unmount the device (even if an exception occurs)
unmount_device(mount_point)
"""Dashcam USB retrieval.
Detects a dashcam plugged in as a USB mass-storage device (no hardcoded
/dev/sda, no brand-specific assumptions), mounts it read-only, copies any
video files it finds (any folder layout - DCIM/, MOVIE/, NORM/EVENT/PARKING/,
etc. - all common dashcam brands are covered since we just walk the whole
filesystem for video extensions instead of assuming a specific folder name),
then unmounts it again.
Detection is done via `lsblk`, filtering for partitions whose parent disk is
attached over USB (TRAN == "usb"). This is what actually distinguishes a
plugged-in dashcam/USB stick from the Pi's own storage (the boot/root
partitions are on the onboard SD card, transport "mmc", never "usb") - so
it's safe to run against real hardware without risk of touching the Pi's own
filesystem, regardless of which /dev/sdX letter the kernel happens to assign.
"""
import json
import os
import shutil
import subprocess
VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".ts", ".mkv"}
# Filesystem types dashcams commonly ship formatted as (SD cards are almost
# always FAT32/exFAT; a few older units use NTFS).
DASHCAM_FS_TYPES = {"vfat", "exfat", "ntfs", "ntfs3"}
DEFAULT_MOUNT_POINT = "/mnt/dashcam"
DEFAULT_DESTINATION = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "dash", "videos"
)
def list_usb_storage_partitions():
"""Return a list of dicts describing USB-attached storage partitions.
Each dict has: path, fstype, label, size, mountpoint. Only partitions
whose parent disk reports TRAN "usb" are included - this is what makes
the detection brand/device-agnostic (no hardcoded /dev/sda) while still
being safe (never matches the Pi's own onboard mmc storage).
"""
try:
result = subprocess.run(
["lsblk", "-J", "-o", "NAME,PATH,TRAN,FSTYPE,LABEL,SIZE,MOUNTPOINT,TYPE"],
capture_output=True,
text=True,
check=True,
)
except (subprocess.CalledProcessError, FileNotFoundError) as e:
print("Failed to run lsblk:", e)
return []
try:
data = json.loads(result.stdout)
except json.JSONDecodeError as e:
print("Failed to parse lsblk output:", e)
return []
partitions = []
def walk(devices, inherited_tran=None):
for device in devices:
tran = device.get("tran") or inherited_tran
if device.get("type") == "part" and tran == "usb":
partitions.append(
{
"path": device.get("path"),
"fstype": device.get("fstype"),
"label": device.get("label"),
"size": device.get("size"),
"mountpoint": device.get("mountpoint"),
}
)
children = device.get("children") or []
if children:
walk(children, tran)
walk(data.get("blockdevices", []))
return partitions
def find_dashcam_partition():
"""Pick the best candidate USB partition to import from, or None.
Prefers partitions with a recognized dashcam filesystem type; if several
are found, the first one is returned (a single dashcam SD card should
only expose one data partition).
"""
partitions = list_usb_storage_partitions()
if not partitions:
return None
recognized = [p for p in partitions if p["fstype"] in DASHCAM_FS_TYPES]
return (recognized or partitions)[0]
def mount_device(device_path, mount_point=DEFAULT_MOUNT_POINT):
"""Mount device_path read-only at mount_point. Returns True on success."""
os.makedirs(mount_point, exist_ok=True)
try:
subprocess.run(
["sudo", "mount", "-o", "ro", device_path, mount_point],
capture_output=True,
text=True,
check=True,
)
return True
except subprocess.CalledProcessError as e:
print(f"Failed to mount {device_path}: {e.stderr.strip()}")
return False
def unmount_device(mount_point=DEFAULT_MOUNT_POINT):
"""Unmount mount_point. Returns True on success (or if already unmounted)."""
try:
subprocess.run(
["sudo", "umount", mount_point],
capture_output=True,
text=True,
check=True,
)
return True
except subprocess.CalledProcessError as e:
# Not mounted is fine - nothing to clean up.
if "not mounted" in e.stderr.lower():
return True
print(f"Failed to unmount {mount_point}: {e.stderr.strip()}")
return False
def find_video_files(source_folder):
"""Walk source_folder, returning (absolute_path, relative_path) for every
file whose extension looks like a video, regardless of folder layout."""
found = []
for root, _dirs, files in os.walk(source_folder):
for file in files:
if os.path.splitext(file)[1].lower() in VIDEO_EXTENSIONS:
abs_path = os.path.join(root, file)
rel_path = os.path.relpath(abs_path, source_folder)
found.append((abs_path, rel_path))
return found
def copy_files(source_folder, destination_folder):
"""Copy every video file found under source_folder into destination_folder,
preserving its relative sub-folder path (dashcams often split files into
date/event sub-folders - flattening them risks filename collisions).
Files already present at the destination (same relative path, same size)
are skipped so re-running an import doesn't re-copy everything.
Returns a summary dict: {found, copied, skipped, errors}.
"""
os.makedirs(destination_folder, exist_ok=True)
videos = find_video_files(source_folder)
copied = 0
skipped = 0
errors = []
for abs_path, rel_path in videos:
dest_path = os.path.join(destination_folder, rel_path)
if os.path.exists(dest_path) and os.path.getsize(dest_path) == os.path.getsize(abs_path):
skipped += 1
continue
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
try:
shutil.copy2(abs_path, dest_path)
copied += 1
except OSError as e:
errors.append(f"{rel_path}: {e}")
return {"found": len(videos), "copied": copied, "skipped": skipped, "errors": errors}
def import_dashcam(destination_folder=DEFAULT_DESTINATION, mount_point=DEFAULT_MOUNT_POINT):
"""Full detect -> mount -> copy -> unmount pipeline.
Returns a summary dict describing what happened, always unmounting the
device again afterwards (even if copying fails partway through).
"""
partition = find_dashcam_partition()
if partition is None:
return {"status": "no_device", "message": "No USB dashcam/storage device detected."}
device_path = partition["path"]
# Already mounted elsewhere (e.g. by the desktop environment) - read
# straight from there instead of trying to mount it again ourselves.
already_mounted_at = partition.get("mountpoint")
mounted_here = False
try:
source = already_mounted_at
if not source:
if not mount_device(device_path, mount_point):
return {
"status": "mount_failed",
"device": device_path,
"message": f"Failed to mount {device_path}.",
}
mounted_here = True
source = mount_point
summary = copy_files(source, destination_folder)
summary["status"] = "ok"
summary["device"] = device_path
return summary
finally:
if mounted_here:
unmount_device(mount_point)
if __name__ == "__main__":
print(import_dashcam())
+23 -15
View File
@@ -2,22 +2,30 @@ from pymongo import MongoClient
from dotenv import load_dotenv
import os
load_dotenv("../db/creds.env")
# Load creds.env from the repo's db/ folder regardless of the process's cwd.
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
load_dotenv(os.path.join(_BASE_DIR, "db", "creds.env"))
mUser = os.getenv()
mPass = os.getenv()
mDB = os.getenv()
mUser = os.getenv("mangosrv_user")
mPass = os.getenv("mangosrv_pass")
mDB = os.getenv("mangosrv_db")
def get_database():
# Provide the mongodb atlas url to connect python to mongodb using pymongo
CONNECTION_STRING = "mongodb+srv://"+ mUser + ":" + mPass + "@cluster.mongodb.net/" + mDB
# Create a connection using MongoClient. You can import MongoClient or use pymongo.MongoClient
client = MongoClient(CONNECTION_STRING)
if not mUser or not mPass or not mDB:
raise RuntimeError(
"MongoDB credentials are missing. Set mangosrv_user, mangosrv_pass, "
"and mangosrv_db in db/creds.env."
)
# Provide the mongodb atlas url to connect python to mongodb using pymongo
CONNECTION_STRING = "mongodb+srv://" + mUser + ":" + mPass + "@cluster.mongodb.net/" + mDB
# Create a connection using MongoClient. You can import MongoClient or use pymongo.MongoClient
client = MongoClient(CONNECTION_STRING)
return client[mDB]
# This is added so that many files can reuse the function get_database()
if __name__ == "__main__":
# Get the database
dbname = get_database()
if __name__ == "__main__":
# Get the database
dbname = get_database()
+10 -3
View File
@@ -5,6 +5,7 @@ import time, threading
socketio = SocketIO()
uptime_lock = threading.Lock()
thread_uptime = None
@socketio.on('connect')
def connect_event():
@@ -14,9 +15,15 @@ def connect_event():
uptime()
def uptime():
thread = Thread(target=loop_uptime)
thread.daemon = True
thread.start()
global thread_uptime
if thread_uptime is None or not thread_uptime.is_alive():
thread_uptime = Thread(target=loop_uptime)
thread_uptime.daemon = True
thread_uptime.start()
print(f"Uptime thread started, alive: {thread_uptime.is_alive()}")
else:
print(f"Uptime thread already running, alive: {thread_uptime.is_alive()}")
def loop_uptime():
uptime_lock.acquire()
+119 -22
View File
@@ -1,22 +1,119 @@
from modules.app_events import socketio
from threading import Thread
class GPS:
def __init__(self):
self.LocationX = None
self.LocationY= None
@socketio.on('gps')
def get_gps():
global LocationX
global LocationY
LocationX = "45.5202844"
LocationY = "-73.5493208"
Location = [LocationX, LocationY]
socketio.emit('gps_location', Location)
print("websocket should be sent now")
return print("socket.io emitted")
from modules.app_events import socketio
from threading import Thread, Lock
import serial
import time
# SIM868 GNSS/GSM/GPRS HAT is wired to the Pi's GPIO UART (jumper set to "B: Pi-SIM868"),
# exposed as /dev/serial0 once the Pi's onboard Bluetooth has been disabled/freed.
SERIAL_PORT = "/dev/serial0"
BAUDRATE = 115200
POLL_INTERVAL = 2 # seconds between AT+CGNSINF polls
LocationX = None
LocationY = None
_serial_lock = Lock()
_serial = None
_gps_thread = None
class GPS:
def __init__(self):
self.LocationX = None
self.LocationY = None
def _get_serial():
global _serial
if _serial is None or not _serial.is_open:
_serial = serial.Serial(SERIAL_PORT, BAUDRATE, timeout=1)
time.sleep(0.3)
_serial.reset_input_buffer()
return _serial
def _send_at(cmd, wait=0.5):
"""Send an AT command to the SIM868 module and return the raw response."""
ser = _get_serial()
ser.write((cmd + "\r\n").encode())
time.sleep(wait)
return ser.read(1000).decode(errors="replace")
def _ensure_gnss_powered():
resp = _send_at("AT+CGNSPWR?")
if "+CGNSPWR: 0" in resp:
print("GNSS was off, powering it on...")
_send_at("AT+CGNSPWR=1", wait=1)
def _parse_cgnsinf(resp):
"""Parse a +CGNSINF response line into a dict, or None if there's no fix yet.
Format: +CGNSINF: run,fix,utc,lat,lon,alt,speed,course,fixmode,reserved1,
hdop,pdop,vdop,reserved2,gps_sat_view,gnss_sat_used,gnss_sat_view,cn0,hpa,vpa
"""
for line in resp.splitlines():
line = line.strip()
if line.startswith("+CGNSINF:"):
fields = line.split(":", 1)[1].strip().split(",")
if len(fields) < 8 or fields[1] != "1":
return None
lat, lon = fields[3], fields[4]
if not lat or not lon:
return None
return {
"lat": lat,
"lon": lon,
"alt": fields[5],
"speed": fields[6],
"course": fields[7],
}
return None
def _poll_loop():
global LocationX, LocationY
try:
with _serial_lock:
_ensure_gnss_powered()
except Exception as e:
print("Failed to initialize GNSS:", str(e))
while True:
try:
with _serial_lock:
resp = _send_at("AT+CGNSINF")
fix = _parse_cgnsinf(resp)
except Exception as e:
print("GPS read error:", str(e))
fix = None
if fix:
LocationX = fix["lat"]
LocationY = fix["lon"]
socketio.emit("gps_location", [LocationX, LocationY])
time.sleep(POLL_INTERVAL)
def start_gps():
"""Start the background thread that continuously polls the SIM868 for a GNSS fix."""
global _gps_thread
if _gps_thread is None or not _gps_thread.is_alive():
_gps_thread = Thread(target=_poll_loop)
_gps_thread.daemon = True
_gps_thread.start()
print(f"GPS thread started, alive: {_gps_thread.is_alive()}")
else:
print(f"GPS thread already running, alive: {_gps_thread.is_alive()}")
return GPS()
@socketio.on("gps")
def get_gps():
"""Return the last known GPS location and emit it over the 'gps_location' event."""
Location = [LocationX, LocationY]
socketio.emit("gps_location", Location)
print("gps_location emitted:", Location)
return Location
+98 -60
View File
@@ -1,7 +1,7 @@
from modules import app_gps, app_sql, app_events
import obd, time, threading
import glob, obd, os, time, threading
from datetime import datetime
from threading import Thread
from threading import Event, Thread
status = None
speed = 0
@@ -16,88 +16,126 @@ class OBD:
obd_status = ["Disconnected", "Connected", "Connection...", "wtf"]
obd_speed_lock = threading.Lock()
obd_rpm_lock = threading.Lock()
thread_speed = None
thread_rpm = None
stop_speed = Event()
stop_rpm = Event()
try:
print(obd_status[2])
connection = obd.OBD() # put linux /dev/rfi... something in () for the rpi bluetooth adpater
connection = None
def _find_obd_port():
"""Locate the USB-serial OBD-II adapter.
Honors an OBD_PORT env var override; otherwise picks the first
/dev/ttyUSB* device found (the PL2303 USB adapter shows up here).
Returns None if nothing is found, letting python-obd fall back to its
own auto-scan (which also covers /dev/rfcomm* for the old Bluetooth setup).
"""
env_port = os.environ.get("OBD_PORT")
if env_port:
return env_port
ports = sorted(glob.glob("/dev/ttyUSB*"))
return ports[0] if ports else None
def connect_obd():
"""(Re)connect to the OBD-II adapter. Safe to call repeatedly."""
global connection
port = _find_obd_port()
try:
print(obd_status[2])
connection = obd.OBD(portstr=port) if port else obd.OBD()
except Exception as e:
print("An error occurred:", str(e))
connection = None
return connection
connect_obd()
except Exception as e:
print("An error occurred:", str(e))
def get_speedtime():
global speed
date = datetime.now()
today = str(date.today())
time_now = str(date.strftime("%H:%M:%S"))
return [str(time_now), speed]
time_now = str(datetime.now().strftime("%H:%M:%S"))
return [time_now, speed]
def obd_connection():
if connection.is_connected():
def obd_connection():
global thread_speed, thread_rpm
if connection is None or not connection.is_connected():
connect_obd()
if connection is not None and connection.is_connected():
print(obd_status[1])
app_events.socketio.emit('obd_status', obd_status[1])
print("OBD connection established.")
if thread_speed is None or not thread_speed.is_alive():
stop_speed.clear()
thread_speed = Thread(target=obd_speed)
thread_speed.daemon = True
thread_speed.start()
print(f'thread_speed alive? {thread_speed.is_alive()}')
if thread_rpm is None or not thread_rpm.is_alive():
stop_rpm.clear()
thread_rpm = Thread(target=obd_rpm)
thread_rpm.daemon = True
thread_rpm.start()
print(f'thread_rpm alive? {thread_rpm.is_alive()}')
else:
print("OBD connection failed.")
print(obd_status[0])
app_events.socketio.emit('obd_status', obd_status[0])
app_events.socketio.emit('obd_speed', "-")
app_events.socketio.emit('obm_error', "-")
app_events.socketio.emit('obd_error', "-")
app_events.socketio.emit('obd_rpm', "-")
# debug speed for testing
thread_speed = Thread(target=obd_speed)
thread_speed.daemon = True
thread_speed.start()
# debug rpm for testing
thread_rpm = Thread(target=obd_rpm)
thread_rpm.daemon = True
thread_rpm.start()
def obd_speed():
obd_speed_lock.acquire()
cmd = obd.commands.SPEED # select an OBD command (sensor)
response = connection.query(cmd) # send the command and parse the response
loop = True
try:
while loop == True:
global speed
speed = speed + 1
time.sleep(10)
global speed
with obd_speed_lock:
cmd = obd.commands.SPEED # select an OBD command (sensor)
while not stop_speed.is_set():
if connection is None or not connection.is_connected():
print("OBD disconnected, stopping speed polling.")
break
response = connection.query(cmd) # send the command and parse the response
if response.is_null():
print("No data received. #"+ str(get_speedtime()))
print(app_gps.get_gps())
app_events.socketio.emit('obd_speed', get_speedtime())
print("No data received. #" + str(get_speedtime()))
print(app_gps.get_gps())
else:
print("Original value:", response.value)
print("Value in kph:", response.value.to("kph"))
app_events.socketio.emit('obd_speed', response.value.to("kph"))
finally:
obd_speed_lock.release()
speed = round(response.value.to("kph").magnitude, 1)
print("Speed:", speed, "kph")
app_events.socketio.emit('obd_speed', get_speedtime())
time.sleep(2)
def obd_rpm():
obd_speed_lock.acquire()
cmd = obd.commands.RPM # select an OBD command (sensor)
response = connection.query(cmd) # send the command and parse the response
loop = True
try:
while loop == True:
global rpm
speed = speed + 1
time.sleep(1)
global rpm
with obd_rpm_lock:
cmd = obd.commands.RPM # select an OBD command (sensor)
while not stop_rpm.is_set():
if connection is None or not connection.is_connected():
print("OBD disconnected, stopping rpm polling.")
break
response = connection.query(cmd) # send the command and parse the response
if response.is_null():
print("No data received. #"+ str(get_speedtime()))
app_events.socketio.emit('obd_speed', get_speedtime())
else:
print("RPM:", response.value)
app_events.socketio.emit('obd_speed', response.value.to("kph"))
finally:
obd_rpm_lock.release()
print("No RPM data received. #" + str(get_speedtime()))
app_events.socketio.emit('obd_rpm', "-")
else:
rpm = round(response.value.magnitude, 0)
print("RPM:", rpm)
app_events.socketio.emit('obd_rpm', rpm)
time.sleep(1)
def kill_obd(obd_type):
if obd_type == "speed":
obd_speed.loop = False
stop_speed.set()
elif obd_type == "rpm":
obd_rpm.loop = False
stop_rpm.set()
+136 -78
View File
@@ -1,78 +1,136 @@
import sqlite3
from modules import app_obd, app_car, app_account, app_gps
from datetime import datetime
from sqlite3 import Error
currentdatetime = datetime.now()
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 = "../db/sql/" + sql_file
def create_conn(db_file):
conn = None
try:
conn = sqlite3.connect(db_file)
print(sqlite3.version)
except Error as e:
print(e)
finally:
if conn:
conn.close()
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 main():
db = sql_path
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
);"""
sql_create_dtc_table = """CREATE TABLE IF NOT EXISTS dtc (
id integer PRIMARY KEY,
NAME text NOT NULL,
STATUS text,
DTC_ID text
);"""
# create a database connection
conn = create_conn(db)
# create tables
if conn is not None:
# create diagnostics table
create_table(conn, sql_create_tracking_table)
# create diagnostics table
create_table(conn, sql_create_dtc_table)
else:
print("Error! cannot create the database connection.")
if __name__ == '__main__':
create_conn(sql_path)
# ## VN, NAME, DATE, TIME, GPS_X, GPS_Y, SPEED, ODOMETER
# (["VN", "NAME", "DATE", "TIME", "GPSX", "GPSY", "SPEED", "ODOMETER"])
# ([car.model, account.name, currentday, currenttime, GPSX, GPSY, SPEED, ODOMETER])
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
);"""
SQL_CREATE_DTC_TABLE = """CREATE TABLE IF NOT EXISTS dtc (
id integer PRIMARY KEY,
NAME text NOT NULL,
STATUS text,
DTC_ID 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)
else:
print("Error! cannot create the database connection.")
return _conn
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 _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()