Put Pit Log under version control so the app and sync server can live on git.
This commit is contained in:
commit
1030bac79e
79 files changed
+8463
No files matched your search
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pit Log sync runs on the Pelican panel. This script does not create a container.
|
||||
echo "Pit Log sync uses the Pelican panel you already have."
|
||||
echo "In the panel, import this egg:"
|
||||
echo "https://git.ny.daprogs.com/RallyBlock/Pit-Log-App-Server-Installer-Proxmox/raw/branch/main/egg-pitlog.yaml"
|
||||
echo "Then create a server from Pit Log sync, give it one port, and start it."
|
||||
echo "The console prints the token. On each phone, open Car and enter http://NODE:PORT plus that token."
|
||||
exit 0
|
||||
@@ -0,0 +1,48 @@
|
||||
meta:
|
||||
version: PLCN_v3
|
||||
update_url: https://git.ny.daprogs.com/RallyBlock/Pit-Log-App-Server-Installer-Proxmox/raw/branch/main/egg-pitlog.yaml
|
||||
exported_at: '2026-10-22T20:00:00+00:00'
|
||||
name: Pit Log sync
|
||||
author: pitlog@ny.daprogs.com
|
||||
uuid: 4f8c2e91-6b3a-4d17-9e50-a1c7d8b24f06
|
||||
description: Sync server for the Pit Log phone app. Create this on the existing Pelican panel. The console prints the address port and the token.
|
||||
tags: {}
|
||||
features: {}
|
||||
docker_images:
|
||||
Python 3.13: ghcr.io/pelican-eggs/yolks:python_3.13
|
||||
Python 3.12: ghcr.io/pelican-eggs/yolks:python_3.12
|
||||
file_denylist: {}
|
||||
startup_commands:
|
||||
Default: /usr/local/bin/python /home/container/pitlog_sync.py
|
||||
config:
|
||||
files: {}
|
||||
startup:
|
||||
done:
|
||||
- Pit Log sync listening
|
||||
logs: {}
|
||||
stop: ^C
|
||||
scripts:
|
||||
installation:
|
||||
script: |-
|
||||
#!/bin/bash
|
||||
apt-get update
|
||||
apt-get install -y curl ca-certificates
|
||||
mkdir -p /mnt/server
|
||||
curl -fsSL "https://git.ny.daprogs.com/RallyBlock/Pit-Log-App-Server-Installer-Proxmox/raw/branch/main/pitlog_sync.py" -o /mnt/server/pitlog_sync.py
|
||||
test -s /mnt/server/pitlog_sync.py
|
||||
echo "install complete"
|
||||
exit 0
|
||||
container: python:3.12-slim-bookworm
|
||||
entrypoint: bash
|
||||
variables:
|
||||
- sort: 1
|
||||
name: Token
|
||||
description: Leave this blank. Each car in the app makes its own token, and that is what the phones share.
|
||||
env_variable: PITLOG_TOKEN
|
||||
default_value: ''
|
||||
user_viewable: true
|
||||
user_editable: true
|
||||
rules:
|
||||
- nullable
|
||||
- string
|
||||
- max:128
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Pit Log sync. Runs inside the container the helper script just created.
|
||||
|
||||
source /dev/stdin <<<"$FUNCTIONS_FILE_PATH"
|
||||
color
|
||||
verb_ip6
|
||||
catch_errors
|
||||
setting_up_container
|
||||
network_check
|
||||
update_os
|
||||
|
||||
PITLOG_BASE="https://git.ny.daprogs.com/RallyBlock/Pit-Log-App-Server-Installer-Proxmox/raw/branch/main"
|
||||
|
||||
msg_info "Installing Pit Log sync"
|
||||
$STD apt-get update
|
||||
$STD apt-get install -y python3 openssl ca-certificates
|
||||
install -d -o root -g root -m 755 /opt/pitlog
|
||||
if ! id pitlog >/dev/null 2>&1; then
|
||||
useradd --system --home-dir /var/lib/pitlog --shell /usr/sbin/nologin pitlog
|
||||
fi
|
||||
install -d -o pitlog -g pitlog -m 750 /var/lib/pitlog /etc/pitlog
|
||||
curl -fsSL "${PITLOG_BASE}/pitlog_sync.py" -o /opt/pitlog/pitlog_sync.py
|
||||
chown root:pitlog /opt/pitlog/pitlog_sync.py
|
||||
chmod 640 /opt/pitlog/pitlog_sync.py
|
||||
if [[ ! -s /etc/pitlog/token ]]; then
|
||||
openssl rand -hex 32 >/etc/pitlog/token
|
||||
fi
|
||||
chown root:pitlog /etc/pitlog/token
|
||||
chmod 640 /etc/pitlog/token
|
||||
cat >/etc/systemd/system/pitlog-sync.service <<'EOF'
|
||||
[Unit]
|
||||
Description=Pit Log sync
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=pitlog
|
||||
Group=pitlog
|
||||
Environment=PITLOG_TOKEN_FILE=/etc/pitlog/token
|
||||
Environment=PITLOG_DB=/var/lib/pitlog/sync.db
|
||||
Environment=PITLOG_PORT=8787
|
||||
ExecStart=/usr/bin/python3 /opt/pitlog/pitlog_sync.py
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
systemctl enable -q --now pitlog-sync.service
|
||||
msg_ok "Installed Pit Log sync"
|
||||
|
||||
motd_ssh
|
||||
customize
|
||||
cleanup_lxc
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install the Pit Log sync service on Debian or Ubuntu.
|
||||
# The Proxmox helper runs this inside the new container.
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "$(id -u)" -ne 0 ]]; then
|
||||
echo "Run this as root inside the container."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update
|
||||
apt-get install -y python3 openssl ca-certificates
|
||||
|
||||
install -d -o root -g root -m 755 /opt/pitlog
|
||||
if ! id pitlog >/dev/null 2>&1; then
|
||||
useradd --system --home-dir /var/lib/pitlog --shell /usr/sbin/nologin pitlog
|
||||
fi
|
||||
install -d -o pitlog -g pitlog -m 750 /var/lib/pitlog /etc/pitlog
|
||||
|
||||
SOURCE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/pitlog_sync.py"
|
||||
if [[ ! -f "$SOURCE" && -f /tmp/pitlog_sync.py ]]; then
|
||||
SOURCE=/tmp/pitlog_sync.py
|
||||
fi
|
||||
if [[ ! -f "$SOURCE" ]]; then
|
||||
echo "pitlog_sync.py was not next to this script."
|
||||
exit 1
|
||||
fi
|
||||
install -o root -g pitlog -m 640 "$SOURCE" /opt/pitlog/pitlog_sync.py
|
||||
|
||||
if [[ ! -s /etc/pitlog/token ]]; then
|
||||
openssl rand -hex 32 > /etc/pitlog/token
|
||||
fi
|
||||
chown root:pitlog /etc/pitlog/token
|
||||
chmod 640 /etc/pitlog/token
|
||||
|
||||
PORT="${PITLOG_PORT:-8787}"
|
||||
cat > /etc/systemd/system/pitlog-sync.service <<EOF
|
||||
[Unit]
|
||||
Description=Pit Log sync
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=pitlog
|
||||
Group=pitlog
|
||||
Environment=PITLOG_TOKEN_FILE=/etc/pitlog/token
|
||||
Environment=PITLOG_DB=/var/lib/pitlog/sync.db
|
||||
Environment=PITLOG_PORT=${PORT}
|
||||
ExecStart=/usr/bin/python3 /opt/pitlog/pitlog_sync.py
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now pitlog-sync.service
|
||||
|
||||
TOKEN="$(cat /etc/pitlog/token)"
|
||||
echo
|
||||
echo "Pit Log sync is running on port ${PORT}."
|
||||
echo "Token: ${TOKEN}"
|
||||
echo "On each phone, open Car and enter this address and the same token."
|
||||
echo "Use http:// when the phone is on the same network. Use https:// if a reverse proxy serves the port."
|
||||
@@ -0,0 +1,542 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Pit Log sync server. Phones push their log and pull everyone else's."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
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",
|
||||
]
|
||||
|
||||
_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_path():
|
||||
configured = os.environ.get("PITLOG_TOKEN_FILE")
|
||||
if configured:
|
||||
return configured
|
||||
home = os.environ.get("HOME", "")
|
||||
if home:
|
||||
return os.path.join(home, ".pitlog-token")
|
||||
return "/etc/pitlog/token"
|
||||
|
||||
|
||||
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)
|
||||
db.commit()
|
||||
return db
|
||||
|
||||
|
||||
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
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def connect():
|
||||
global _db
|
||||
path = db_path()
|
||||
_db = open_db(path)
|
||||
_spaces[path] = _db
|
||||
|
||||
|
||||
def token():
|
||||
inline = os.environ.get("PITLOG_TOKEN", "").strip()
|
||||
if inline:
|
||||
return inline
|
||||
path = token_path()
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
saved = handle.read().strip()
|
||||
except FileNotFoundError:
|
||||
saved = ""
|
||||
if saved:
|
||||
return saved
|
||||
generated = secrets.token_hex(32)
|
||||
folder = os.path.dirname(path)
|
||||
if folder:
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
handle.write(generated)
|
||||
return generated
|
||||
|
||||
|
||||
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 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 len(bearer) < 16 or any(character not in "0123456789abcdef" for character in 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)
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
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": [],
|
||||
}
|
||||
)
|
||||
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")
|
||||
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()
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pit Log sync runs on the Pelican panel. This script does not create a container.
|
||||
echo "Pit Log sync uses the Pelican panel you already have."
|
||||
echo "In the panel, import this egg:"
|
||||
echo "https://git.ny.daprogs.com/RallyBlock/Pit-Log-App-Server-Installer-Proxmox/raw/branch/main/egg-pitlog.yaml"
|
||||
echo "Then create a server from Pit Log sync, give it one port, and start it."
|
||||
echo "The console prints the token. On each phone, open Car and enter http://NODE:PORT plus that token."
|
||||
exit 0
|
||||
Reference in new issue
Block a user