Files
Pit-Log-App/sync/pitlog_sync.py
T

584 lines
20 KiB
Python

#!/usr/bin/env python3
"""Pit Log sync server. Phones push their log and pull everyone else's."""
import hashlib
import json
import os
import sqlite3
import sys
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
MAX_BODY = 8_000_000
NIGHT_FIELDS = [
"dateEpochDay", "trackName", "carName", "raceClass", "discipline", "airTempF",
"notes", "createdAt", "crossPercent", "springLf", "springRf", "springLr", "springRr",
"camberLf", "camberRf", "casterLf", "casterRf", "swayBarHole", "blockLr", "blockRr",
]
RUN_FIELDS = [
"session", "tireSet", "laps", "finish", "staggerInches",
"coldLf", "coldRf", "coldLr", "coldRr", "hotLf", "hotRf", "hotLr", "hotRr",
"lfInside", "lfMiddle", "lfOutside", "rfInside", "rfMiddle", "rfOutside",
"lrInside", "lrMiddle", "lrOutside", "rrInside", "rrMiddle", "rrOutside",
"handling", "handlingWhere", "notes", "recordedAt",
]
SHEET_FIELDS = [
"recordedAt", "note", "crossPercent", "springLf", "springRf", "springLr", "springRr",
"camberLf", "camberRf", "casterLf", "casterRf", "swayBarHole", "blockLr", "blockRr",
"toeFront", "toeRear",
"weightLf", "weightRf", "weightLr", "weightRr",
"rideHeight", "pinion", "brakeFront",
]
_lock = threading.Lock()
_db = None
_spaces = {}
def db_path():
configured = os.environ.get("PITLOG_DB")
if configured:
return configured
home = os.environ.get("HOME", "")
if home and os.path.isdir(home) and os.access(home, os.W_OK):
return os.path.join(home, "sync.db")
return "/var/lib/pitlog/sync.db"
def listen_port():
for name in ("PITLOG_PORT", "SERVER_PORT"):
value = os.environ.get(name, "").strip()
if value:
return int(value)
return 8787
def token_ok(bearer):
if len(bearer) == 8 and bearer.isdigit():
return True
return len(bearer) >= 16 and all(character in "0123456789abcdef" for character in bearer)
def legacy_token():
inline = os.environ.get("PITLOG_TOKEN", "").strip().lower()
if inline:
return inline
path = os.environ.get("PITLOG_TOKEN_FILE", "/etc/pitlog/token")
try:
with open(path, "r", encoding="utf-8") as handle:
return handle.read().strip().lower()
except FileNotFoundError:
return ""
def space_path(bearer):
root = db_path()
legacy = legacy_token()
if legacy and bearer == legacy:
return root
folder = os.path.join(os.path.dirname(root) or ".", "cars")
os.makedirs(folder, exist_ok=True)
digest = hashlib.sha256(bearer.encode("utf-8")).hexdigest()
return os.path.join(folder, digest + ".db")
def open_db(path):
folder = os.path.dirname(path)
if folder:
os.makedirs(folder, exist_ok=True)
db = sqlite3.connect(path, check_same_thread=False)
db.row_factory = sqlite3.Row
db.execute("PRAGMA journal_mode=WAL")
db.executescript(SCHEMA)
ensure_columns(
db,
"sheets",
{
"weightLf": "INTEGER",
"weightRf": "INTEGER",
"weightLr": "INTEGER",
"weightRr": "INTEGER",
"rideHeight": "REAL",
"pinion": "REAL",
"brakeFront": "REAL",
},
)
db.commit()
return db
def ensure_columns(db, table, columns):
present = {row["name"] for row in db.execute(f"PRAGMA table_info({table})")}
for name, kind in columns.items():
if name not in present:
db.execute(f"ALTER TABLE {table} ADD COLUMN {name} {kind}")
def space(bearer):
path = space_path(bearer)
with _lock:
db = _spaces.get(path)
if db is None:
db = open_db(path)
_spaces[path] = db
return db
SCHEMA = """
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
INSERT OR IGNORE INTO meta(key, value) VALUES ('rev', '0');
CREATE TABLE IF NOT EXISTS profile (
id INTEGER PRIMARY KEY CHECK (id = 1),
carName TEXT NOT NULL,
raceClass TEXT NOT NULL,
discipline TEXT NOT NULL,
updatedAt INTEGER NOT NULL,
rev INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS nights (
syncId TEXT PRIMARY KEY,
updatedAt INTEGER NOT NULL,
deleted INTEGER NOT NULL,
rev INTEGER NOT NULL,
dateEpochDay INTEGER,
trackName TEXT,
carName TEXT,
raceClass TEXT,
discipline TEXT,
airTempF INTEGER,
notes TEXT,
createdAt INTEGER,
crossPercent REAL,
springLf INTEGER,
springRf INTEGER,
springLr INTEGER,
springRr INTEGER,
camberLf REAL,
camberRf REAL,
casterLf REAL,
casterRf REAL,
swayBarHole INTEGER,
blockLr REAL,
blockRr REAL
);
CREATE TABLE IF NOT EXISTS runs (
syncId TEXT PRIMARY KEY,
nightSyncId TEXT NOT NULL,
updatedAt INTEGER NOT NULL,
deleted INTEGER NOT NULL,
rev INTEGER NOT NULL,
session TEXT,
tireSet TEXT,
laps INTEGER,
finish INTEGER,
staggerInches REAL,
coldLf REAL, coldRf REAL, coldLr REAL, coldRr REAL,
hotLf REAL, hotRf REAL, hotLr REAL, hotRr REAL,
lfInside INTEGER, lfMiddle INTEGER, lfOutside INTEGER,
rfInside INTEGER, rfMiddle INTEGER, rfOutside INTEGER,
lrInside INTEGER, lrMiddle INTEGER, lrOutside INTEGER,
rrInside INTEGER, rrMiddle INTEGER, rrOutside INTEGER,
handling TEXT,
handlingWhere TEXT,
notes TEXT,
recordedAt INTEGER
);
CREATE TABLE IF NOT EXISTS sheets (
syncId TEXT PRIMARY KEY,
updatedAt INTEGER NOT NULL,
deleted INTEGER NOT NULL,
rev INTEGER NOT NULL,
recordedAt INTEGER,
note TEXT,
crossPercent REAL,
springLf INTEGER,
springRf INTEGER,
springLr INTEGER,
springRr INTEGER,
camberLf REAL,
camberRf REAL,
casterLf REAL,
casterRf REAL,
swayBarHole INTEGER,
blockLr REAL,
blockRr REAL,
toeFront REAL,
toeRear REAL,
weightLf INTEGER,
weightRf INTEGER,
weightLr INTEGER,
weightRr INTEGER,
rideHeight REAL,
pinion REAL,
brakeFront REAL
);
"""
def connect():
global _db
path = db_path()
_db = open_db(path)
_spaces[path] = _db
def current_rev(db):
return int(db.execute("SELECT value FROM meta WHERE key = 'rev'").fetchone()["value"])
def next_rev(db):
db.execute("UPDATE meta SET value = CAST(value AS INTEGER) + 1 WHERE key = 'rev'")
return current_rev(db)
def row_dict(row, fields):
if row is None:
return None
data = {field: row[field] for field in fields}
data["syncId"] = row["syncId"]
data["updatedAt"] = row["updatedAt"]
data["deleted"] = bool(row["deleted"])
if "nightSyncId" in row.keys():
data["nightSyncId"] = row["nightSyncId"]
return data
def values_match(existing, incoming, fields):
for field in fields:
if existing[field] != incoming.get(field):
return False
return bool(existing["deleted"]) == bool(incoming.get("deleted"))
def store_record(db, table, incoming, fields, extra_columns=""):
sync_id = str(incoming.get("syncId") or "").strip()
if not sync_id or len(sync_id) > 80:
raise ValueError("A record is missing its id.")
updated = int(incoming.get("updatedAt") or 0)
deleted = 1 if incoming.get("deleted") else 0
existing = db.execute(f"SELECT * FROM {table} WHERE syncId = ?", (sync_id,)).fetchone()
if existing is not None and int(existing["updatedAt"]) > updated:
return False, row_dict(existing, fields)
if (
existing is not None
and int(existing["updatedAt"]) == updated
and values_match(existing, incoming, fields)
and (extra_columns == "" or existing["nightSyncId"] == incoming.get("nightSyncId"))
):
return True, None
rev = next_rev(db)
columns = ["syncId", "updatedAt", "deleted", "rev"]
values = [sync_id, updated, deleted, rev]
if extra_columns:
night_sync = str(incoming.get("nightSyncId") or "").strip()
if not night_sync:
raise ValueError("A run is missing its race night.")
columns.append("nightSyncId")
values.append(night_sync)
for field in fields:
columns.append(field)
values.append(incoming.get(field))
placeholders = ", ".join("?" for _ in columns)
updates = ", ".join(f"{column} = excluded.{column}" for column in columns if column != "syncId")
db.execute(
f"INSERT INTO {table} ({', '.join(columns)}) VALUES ({placeholders}) "
f"ON CONFLICT(syncId) DO UPDATE SET {updates}",
values,
)
return True, None
def profile_row(row):
if row is None:
return None
return {
"carName": row["carName"],
"raceClass": row["raceClass"],
"discipline": row["discipline"],
"updatedAt": row["updatedAt"],
}
def names_differ(existing, incoming):
return (
existing["carName"] != incoming.get("carName")
or existing["raceClass"] != incoming.get("raceClass")
or existing["discipline"] != incoming.get("discipline")
)
def store_profile(db, incoming):
if not isinstance(incoming, dict):
return None
updated = int(incoming.get("updatedAt") or 0)
car_name = str(incoming.get("carName") or "")[:80]
race_class = str(incoming.get("raceClass") or "")[:80]
discipline = str(incoming.get("discipline") or "")[:40]
existing = db.execute("SELECT * FROM profile WHERE id = 1").fetchone()
if existing is not None and int(existing["updatedAt"]) > updated:
return profile_row(existing)
if existing is not None and int(existing["updatedAt"]) == updated and not names_differ(existing, incoming):
return None
if existing is not None and int(existing["updatedAt"]) == updated and names_differ(existing, incoming):
return profile_row(existing)
rev = next_rev(db)
db.execute(
"""
INSERT INTO profile (id, carName, raceClass, discipline, updatedAt, rev)
VALUES (1, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
carName = excluded.carName,
raceClass = excluded.raceClass,
discipline = excluded.discipline,
updatedAt = excluded.updatedAt,
rev = excluded.rev
""",
(car_name, race_class, discipline, updated, rev),
)
return None
def changed_since(db, table, since, fields):
rows = db.execute(f"SELECT * FROM {table} WHERE rev > ?", (since,)).fetchall()
return [row_dict(row, fields) for row in rows]
def sync_body(payload, db=None):
if not isinstance(payload, dict):
raise ValueError("The sync body has to be an object.")
since = int(payload.get("rev") or 0)
forced = {"nights": [], "runs": [], "sheets": []}
accepted = []
with _lock:
db = db or _db
profile_reply = store_profile(db, payload.get("profile"))
for table, key, fields, extra in (
("nights", "nights", NIGHT_FIELDS, ""),
("runs", "runs", RUN_FIELDS, "nightSyncId"),
("sheets", "sheets", SHEET_FIELDS, ""),
):
for item in payload.get(key) or []:
if not isinstance(item, dict):
raise ValueError("A sync record was not an object.")
ok, winner = store_record(db, table, item, fields, extra)
if ok:
accepted.append(item.get("syncId"))
elif winner is not None:
forced[key].append(winner)
since_nights = changed_since(db, "nights", since, NIGHT_FIELDS)
since_runs = changed_since(db, "runs", since, RUN_FIELDS)
since_sheets = changed_since(db, "sheets", since, SHEET_FIELDS)
db.commit()
rev = current_rev(db)
seen = {item["syncId"] for item in since_nights}
nights = since_nights + [item for item in forced["nights"] if item["syncId"] not in seen]
seen = {item["syncId"] for item in since_runs}
runs = since_runs + [item for item in forced["runs"] if item["syncId"] not in seen]
seen = {item["syncId"] for item in since_sheets}
sheets = since_sheets + [item for item in forced["sheets"] if item["syncId"] not in seen]
return {
"rev": rev,
"accepted": accepted,
"profile": profile_reply,
"nights": nights,
"runs": runs,
"sheets": sheets,
}
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def handle(self):
try:
super().handle()
except (ConnectionError, TimeoutError):
return
def send_error(self, code, message=None, explain=None):
try:
self.close_connection = True
self.send_response_only(code)
self.send_header("Content-Length", "0")
self.send_header("Connection", "close")
self.end_headers()
except (ConnectionError, TimeoutError):
self.close_connection = True
def log_message(self, fmt, *args):
sys.stdout.write("%s - %s\n" % (self.address_string(), fmt % args))
def do_GET(self):
if self.path.split("?", 1)[0] != "/health":
self._send(404, {"error": "Not found."})
return
self._send(200, {"ok": True})
def do_POST(self):
if self.path.split("?", 1)[0] != "/v1/sync":
self._send(404, {"error": "Not found."})
return
header = self.headers.get("Authorization", "")
bearer = header[7:].strip().lower() if header.startswith("Bearer ") else ""
if not token_ok(bearer):
self._send(401, {"error": "The token was refused."})
return
length = int(self.headers.get("Content-Length", "0"))
if length <= 0 or length > MAX_BODY:
self._send(400, {"error": "The sync body is empty or too large."})
return
try:
payload = json.loads(self.rfile.read(length).decode("utf-8"))
self._send(200, sync_body(payload, space(bearer)))
except ValueError as error:
self._send(400, {"error": str(error)})
except Exception:
self._send(500, {"error": "The server couldn't save that sync."})
def _send(self, status, payload):
body = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def serve():
connect()
port = listen_port()
print(f"Pit Log sync listening on {port}", flush=True)
print("Each car in the app has its own token.", flush=True)
server = ThreadingHTTPServer(("0.0.0.0", port), Handler)
try:
server.serve_forever()
except KeyboardInterrupt:
print("Pit Log sync stopped.", flush=True)
finally:
server.server_close()
def selftest():
import tempfile
handle = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
handle.close()
os.environ["PITLOG_DB"] = handle.name
os.environ["PITLOG_TOKEN"] = "test-token"
global _db
_db = None
connect()
first = sync_body(
{
"rev": 0,
"profile": {"carName": "1976 Chevy Camaro", "raceClass": "Hobby Stock", "discipline": "Oval", "updatedAt": 1},
"nights": [
{
"syncId": "night-a",
"updatedAt": 10,
"deleted": False,
"dateEpochDay": 20000,
"trackName": "Tri-City Raceway",
"carName": "1976 Chevy Camaro",
"raceClass": "Hobby Stock",
"discipline": "Oval",
"airTempF": 70,
"notes": "",
"createdAt": 10,
"crossPercent": 53.5,
"springLf": 950,
"springRf": 1000,
"springLr": 200,
"springRr": 175,
"camberLf": 2.0,
"camberRf": -7.0,
"casterLf": 2.0,
"casterRf": 4.0,
"swayBarHole": 4,
"blockLr": 2.0,
"blockRr": 2.0,
}
],
"runs": [
{
"syncId": "run-a",
"nightSyncId": "night-a",
"updatedAt": 11,
"deleted": False,
"session": "Heat",
"tireSet": "Scuffs",
"laps": 15,
"finish": 4,
"staggerInches": 1.25,
"coldLf": 16,
"coldRf": 22,
"coldLr": 15,
"coldRr": 18,
"hotLf": 20.5,
"hotRf": 28,
"hotLr": 18.5,
"hotRr": 23.5,
"lfInside": 168,
"lfMiddle": 158,
"lfOutside": 149,
"rfInside": 214,
"rfMiddle": 198,
"rfOutside": 182,
"lrInside": 162,
"lrMiddle": 154,
"lrOutside": 146,
"rrInside": 196,
"rrMiddle": 180,
"rrOutside": 168,
"handling": "Loose",
"handlingWhere": "Exit",
"notes": "",
"recordedAt": 11,
}
],
"sheets": [
{
"syncId": "sheet-a",
"updatedAt": 12,
"deleted": False,
"recordedAt": 12,
"note": "",
"weightLf": 800,
"weightRf": 760,
"weightLr": 740,
"weightRr": 700,
"rideHeight": 5.0,
"pinion": -2.0,
"brakeFront": 62.0,
}
],
}
)
second = sync_body({"rev": 0, "profile": {"carName": "1976 Chevy Camaro", "raceClass": "Hobby Stock", "discipline": "Oval", "updatedAt": 0}, "nights": [], "runs": [], "sheets": []})
if second["nights"][0]["trackName"] != "Tri-City Raceway":
raise SystemExit("second phone did not receive the night")
if second["runs"][0]["nightSyncId"] != "night-a":
raise SystemExit("second phone did not receive the run")
if second["sheets"][0]["weightLf"] != 800 or second["sheets"][0]["pinion"] != -2.0:
raise SystemExit("second phone did not receive the setup")
older = sync_body(
{
"rev": first["rev"],
"profile": None,
"nights": [{"syncId": "night-a", "updatedAt": 5, "deleted": False, "dateEpochDay": 1, "trackName": "Old", "carName": "x", "raceClass": "x", "discipline": "x", "airTempF": None, "notes": "", "createdAt": 5, "crossPercent": None, "springLf": None, "springRf": None, "springLr": None, "springRr": None, "camberLf": None, "camberRf": None, "casterLf": None, "casterRf": None, "swayBarHole": None, "blockLr": None, "blockRr": None}],
"runs": [],
"sheets": [],
}
)
if older["nights"][0]["trackName"] != "Tri-City Raceway":
raise SystemExit("an older edit replaced the night")
print("selftest ok")
return 0
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "selftest":
raise SystemExit(selftest())
serve()