Phase 9: scaffold remote server (Flask + Postgres, docker-compose for unraid)

- server/sql/init.sql: devices/tracking_points/dashcam_files schema
- server/modules/db.py: psycopg3 pool + query helpers
- server/modules/auth.py: X-Device-Name/X-Api-Key header auth
- server/app.py: /health, /api/v1/tracking/batch, /api/v1/dashcam/upload
- server/Dockerfile + docker-compose.yml: api (gunicorn) + postgres services
- server/scripts/create_device.py: one-time device/API-key registration
- server/README.md: unraid deployment + Cloudflare Tunnel exposure docs
- PLAN.md: Phase 9 marked in progress with full build notes

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Mathieu Moran
2026-08-08 19:50:52 -04:00
co-authored by Copilot
parent 261ef3f4db
commit 9bdf5bad2c
12 changed files with 582 additions and 1 deletions
+108
View File
@@ -0,0 +1,108 @@
"""Postgres connection pool + small query helpers for the yaris-obd remote server."""
import os
from contextlib import contextmanager
from psycopg_pool import ConnectionPool
_POOL = None
def _dsn():
return (
f"host={os.environ.get('POSTGRES_HOST', 'db')} "
f"port={os.environ.get('POSTGRES_PORT', '5432')} "
f"dbname={os.environ.get('POSTGRES_DB', 'yarisobd')} "
f"user={os.environ.get('POSTGRES_USER', 'yarisobd')} "
f"password={os.environ.get('POSTGRES_PASSWORD', '')}"
)
def get_pool():
global _POOL
if _POOL is None:
_POOL = ConnectionPool(conninfo=_dsn(), min_size=1, max_size=5, open=True)
return _POOL
@contextmanager
def get_cursor(commit=False):
"""Yield a cursor from the pool, optionally committing on clean exit."""
pool = get_pool()
with pool.connection() as conn:
with conn.cursor() as cur:
yield cur
if commit:
conn.commit()
def get_device_by_name(name):
with get_cursor() as cur:
cur.execute(
"SELECT id, name, api_key_hash FROM devices WHERE name = %s",
(name,),
)
row = cur.fetchone()
if row is None:
return None
return {"id": row[0], "name": row[1], "api_key_hash": row[2]}
def touch_device_last_seen(device_id):
with get_cursor(commit=True) as cur:
cur.execute(
"UPDATE devices SET last_seen_at = now() WHERE id = %s",
(device_id,),
)
def insert_tracking_points(device_id, points):
"""Bulk-insert tracking points, skipping any that already exist
(device_id, recorded_at) so a client can safely re-send a batch after a
partial-failure retry without creating duplicates."""
if not points:
return 0
with get_cursor(commit=True) as cur:
cur.executemany(
"""
INSERT INTO tracking_points
(device_id, vehicle_name, driver_name, recorded_at, gps_lat, gps_lon, speed, odometer)
VALUES (%(device_id)s, %(vehicle_name)s, %(driver_name)s, %(recorded_at)s,
%(gps_lat)s, %(gps_lon)s, %(speed)s, %(odometer)s)
ON CONFLICT (device_id, recorded_at) DO NOTHING
""",
[
{
"device_id": device_id,
"vehicle_name": p.get("vehicle_name"),
"driver_name": p.get("driver_name"),
"recorded_at": p["recorded_at"],
"gps_lat": p.get("gps_lat"),
"gps_lon": p.get("gps_lon"),
"speed": p.get("speed"),
"odometer": p.get("odometer"),
}
for p in points
],
)
return len(points)
def dashcam_file_exists(device_id, relative_path, sha256):
with get_cursor() as cur:
cur.execute(
"SELECT 1 FROM dashcam_files WHERE device_id = %s AND relative_path = %s AND sha256 = %s",
(device_id, relative_path, sha256),
)
return cur.fetchone() is not None
def insert_dashcam_file(device_id, relative_path, size_bytes, sha256, storage_path):
with get_cursor(commit=True) as cur:
cur.execute(
"""
INSERT INTO dashcam_files (device_id, relative_path, size_bytes, sha256, storage_path)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (device_id, relative_path, sha256) DO NOTHING
""",
(device_id, relative_path, size_bytes, sha256, storage_path),
)