Add Pi-side upload client for Phase 9 remote server
- app_sql.py: add UPLOADED column + migration on tracking table, add settings and dashcam_uploads tables, add helpers for unsent-row queries, marking rows uploaded, and dashcam upload dedupe tracking. - app_upload.py: new background upload client. Periodically batches unsent tracking rows to POST /api/v1/tracking/batch and uploads new dashcam files (sha256-verified) to POST /api/v1/dashcam/upload, with exponential backoff on failure. Exposes get_status() and a dashcam-upload enable/disable toggle backed by the settings table. - app.py: start the upload client thread at startup, add GET /api/upload/status and POST /api/upload/dashcam_toggle routes. - dash.html/dash.js: add a "Remote server upload" card with upload status and a toggle switch to pause dashcam video uploads while trip/GPS log uploads keep running (per user request - useful on a slow/metered connection). - db/upload_creds.env: new gitignore-exempted template (mirrors db/creds.env) for YARIS_SERVER_URL/YARIS_DEVICE_NAME/YARIS_API_KEY. - requirements.txt: add requests. Tested against the real deployed unraid server: tracking batch upload + dedupe, dashcam upload + dedupe, and toggle-off correctly pausing dashcam uploads while tracking uploads continue. Also verified the full Flask app imports/runs cleanly and the new API routes + dash.html toggle render correctly in a headless browser. Co-authored-by: Copilot <[email protected]>
This commit is contained in:
+119
-1
@@ -33,7 +33,8 @@ SQL_CREATE_TRACKING_TABLE = """CREATE TABLE IF NOT EXISTS tracking (
|
||||
GPSX float,
|
||||
GPSY float,
|
||||
SPEED float,
|
||||
ODOMETER float
|
||||
ODOMETER float,
|
||||
UPLOADED integer DEFAULT 0
|
||||
);"""
|
||||
|
||||
SQL_CREATE_DTC_TABLE = """CREATE TABLE IF NOT EXISTS dtc (
|
||||
@@ -43,6 +44,19 @@ SQL_CREATE_DTC_TABLE = """CREATE TABLE IF NOT EXISTS dtc (
|
||||
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)
|
||||
);"""
|
||||
|
||||
|
||||
def create_conn(db_file):
|
||||
"""Open a SQLite connection to db_file, creating its parent folder if needed.
|
||||
@@ -82,11 +96,23 @@ def get_conn():
|
||||
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)
|
||||
_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()
|
||||
@@ -103,6 +129,98 @@ def insert_tracking(gps_x, gps_y, speed, odometer=None):
|
||||
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 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 _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).
|
||||
|
||||
Reference in New Issue
Block a user