Files
StarPilot/scripts/speed_limit_vision/download_connect_routes.py
T
firestar5683 fe4f42a616 friar carl
2026-03-31 13:27:22 -05:00

271 lines
9.6 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import re
from dataclasses import dataclass
from email.utils import parsedate_to_datetime
from pathlib import Path
from urllib.parse import urlparse
import requests
from scripts.speed_limit_vision import common
API_HOST = os.getenv("COMMA_API_HOST", "https://api.commadotai.com")
DEFAULT_FILES_MANIFEST = common.preferred_files_manifest_path()
STREAM_FILE_NAMES = {
"fcamera": {"fcamera.hevc"},
"qlog": {"qlog.zst", "qlog.bz2", "qlog"},
"rlog": {"rlog.zst", "rlog.bz2", "rlog"},
"qcamera": {"qcamera.ts"},
"dcamera": {"dcamera.hevc"},
"ecamera": {"ecamera.hevc"},
}
@dataclass(frozen=True)
class RouteRequest:
dongle_id: str
log_id: str
segment_filter: set[int] | None
@property
def canonical_name(self) -> str:
return f"{self.dongle_id}|{self.log_id}"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Download route files directly from comma connect into the speed-limit review layout.")
parser.add_argument("routes", nargs="*", help="Route ids like 'dongle/logid' or segment ids like 'dongle/logid--9'.")
parser.add_argument("--routes-file", type=Path, help="Optional file containing one route id per line.")
parser.add_argument("--clip-root", type=Path, default=common.preferred_clip_root(), help="Destination root for downloaded segment directories.")
parser.add_argument("--qlog-mtimes", type=Path, default=common.preferred_qlog_mtimes_path(), help="Path to qlog mtime manifest used by bookmark replay.")
parser.add_argument("--files-manifest", type=Path, default=DEFAULT_FILES_MANIFEST, help="Path to downloaded-files manifest.")
parser.add_argument("--streams", default="fcamera,qlog", help="Comma-separated stream set: fcamera,qlog,rlog,qcamera,dcamera,ecamera.")
parser.add_argument("--segments", help="Optional segment filter, e.g. '0,2,5-8'.")
parser.add_argument("--overwrite", action="store_true", help="Redownload files even if they already exist locally.")
parser.add_argument("--timeout", type=float, default=60.0, help="HTTP timeout in seconds.")
return parser.parse_args()
def read_token() -> str:
token = os.getenv("COMMA_JWT", "").strip()
if token:
return token
auth_path = Path.home() / ".comma" / "auth.json"
if not auth_path.is_file():
raise FileNotFoundError(f"Missing auth token at {auth_path}. Run python3 tools/lib/auth.py first or set COMMA_JWT.")
auth = json.loads(auth_path.read_text(encoding="utf-8"))
token = auth.get("access_token", "").strip()
if not token:
raise ValueError(f"No access_token in {auth_path}")
return token
def parse_segment_spec(spec: str | None) -> set[int] | None:
if not spec:
return None
selected: set[int] = set()
for part in spec.split(","):
part = part.strip()
if not part:
continue
if "-" in part:
start_text, end_text = part.split("-", 1)
start = int(start_text)
end = int(end_text)
selected.update(range(min(start, end), max(start, end) + 1))
else:
selected.add(int(part))
return selected
def load_route_inputs(args: argparse.Namespace) -> list[str]:
raw_routes = list(args.routes)
if args.routes_file:
raw_routes.extend(line.strip() for line in args.routes_file.expanduser().resolve().read_text(encoding="utf-8").splitlines())
if not raw_routes:
raise ValueError("No routes provided.")
return [route for route in raw_routes if route and not route.lstrip().startswith("#")]
def parse_route_request(raw: str, default_segments: set[int] | None) -> RouteRequest:
text = raw.strip().strip("'\"")
text = text.replace("|", "/")
match = re.fullmatch(r"([0-9a-f]{16})/([^/]+)", text)
if not match:
raise ValueError(f"Unrecognized route id: {raw}")
dongle_id = match.group(1)
tail = match.group(2)
segment_filter = set(default_segments) if default_segments else None
parts = tail.split("--")
if len(parts) == 3 and parts[-1].isdigit():
log_id = "--".join(parts[:2])
segment = int(parts[-1])
if segment_filter is None:
segment_filter = {segment}
else:
segment_filter.add(segment)
else:
log_id = tail
if len(log_id) != 20:
raise ValueError(f"Invalid log id in route: {raw}")
return RouteRequest(dongle_id=dongle_id, log_id=log_id, segment_filter=segment_filter)
def merge_route_requests(raw_routes: list[str], default_segments: set[int] | None) -> list[RouteRequest]:
merged: dict[tuple[str, str], set[int] | None] = {}
for raw in raw_routes:
request = parse_route_request(raw, default_segments)
key = (request.dongle_id, request.log_id)
if key not in merged:
merged[key] = None if request.segment_filter is None else set(request.segment_filter)
continue
if merged[key] is None or request.segment_filter is None:
merged[key] = None
else:
merged[key].update(request.segment_filter)
return [RouteRequest(dongle_id=dongle_id, log_id=log_id, segment_filter=segments) for (dongle_id, log_id), segments in sorted(merged.items())]
def selected_file_names(streams_csv: str) -> set[str]:
selected: set[str] = set()
for stream in streams_csv.split(","):
stream = stream.strip()
if not stream:
continue
if stream not in STREAM_FILE_NAMES:
raise ValueError(f"Unknown stream '{stream}'. Valid streams: {', '.join(sorted(STREAM_FILE_NAMES))}")
selected.update(STREAM_FILE_NAMES[stream])
return selected
def api_get_json(session: requests.Session, endpoint: str, timeout: float):
response = session.get(f"{API_HOST}/{endpoint.lstrip('/')}", timeout=timeout)
response.raise_for_status()
return response.json()
def iter_route_file_urls(files_payload: dict, file_names: set[str]):
for value in files_payload.values():
if not isinstance(value, list):
continue
for url in value:
parsed = urlparse(url)
parts = parsed.path.strip("/").split("/")
if len(parts) < 4:
continue
segment_text = parts[-2]
file_name = parts[-1]
if not segment_text.isdigit() or file_name not in file_names:
continue
yield int(segment_text), file_name, url
def download_to_path(session: requests.Session, url: str, dest_path: Path, overwrite: bool, timeout: float) -> int | None:
dest_path.parent.mkdir(parents=True, exist_ok=True)
if dest_path.exists() and not overwrite:
return int(dest_path.stat().st_mtime)
response = session.get(url, stream=True, timeout=timeout)
response.raise_for_status()
temp_path = dest_path.with_suffix(dest_path.suffix + ".part")
with temp_path.open("wb") as handle:
for chunk in response.iter_content(chunk_size=1 << 20):
if chunk:
handle.write(chunk)
temp_path.replace(dest_path)
last_modified = response.headers.get("Last-Modified")
if last_modified:
epoch = int(parsedate_to_datetime(last_modified).timestamp())
os.utime(dest_path, (epoch, epoch))
return epoch
return int(dest_path.stat().st_mtime)
def load_manifest_lines(path: Path) -> dict[str, str]:
if not path.is_file():
return {}
lines: dict[str, str] = {}
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line:
continue
key = line.split(" ", 1)[0]
lines[key] = line
return lines
def write_manifest(path: Path, lines: dict[str, str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as handle:
for key in sorted(lines):
handle.write(lines[key] + "\n")
def main() -> int:
args = parse_args()
token = read_token()
route_requests = merge_route_requests(load_route_inputs(args), parse_segment_spec(args.segments))
file_names = selected_file_names(args.streams)
clip_root = args.clip_root.expanduser().resolve()
qlog_mtimes_path = args.qlog_mtimes.expanduser().resolve()
files_manifest_path = args.files_manifest.expanduser().resolve()
api_session = requests.Session()
api_session.headers.update({
"Authorization": f"JWT {token}",
"User-Agent": "OpenpilotTools",
})
download_session = requests.Session()
download_session.headers.update({
"User-Agent": "OpenpilotTools",
})
qlog_lines = load_manifest_lines(qlog_mtimes_path)
file_lines = load_manifest_lines(files_manifest_path)
for request in route_requests:
route_meta = api_get_json(api_session, f"v1/route/{request.canonical_name}", timeout=args.timeout)
files_payload = api_get_json(api_session, f"v1/route/{request.canonical_name}/files", timeout=args.timeout)
log_id = request.log_id
start_time = route_meta.get("start_time", "")
print(f"{request.canonical_name}: downloading streams={sorted(file_names)} segments={sorted(request.segment_filter) if request.segment_filter else 'all'}")
for segment, file_name, url in iter_route_file_urls(files_payload, file_names):
if request.segment_filter is not None and segment not in request.segment_filter:
continue
segment_name = f"{log_id}--{segment}"
segment_dir = clip_root / segment_name
dest_path = segment_dir / file_name
epoch = download_to_path(download_session, url, dest_path, args.overwrite, args.timeout)
print(f" {segment_name}/{file_name} <- {urlparse(url).netloc}")
file_lines[f"{segment_name} {dest_path}"] = f"{segment_name} {dest_path}"
if file_name.startswith("qlog") and epoch is not None:
qlog_lines[str(dest_path)] = f"{dest_path} {epoch}"
if start_time:
print(f" route start_time={start_time}")
write_manifest(qlog_mtimes_path, qlog_lines)
write_manifest(files_manifest_path, file_lines)
print(f"Updated {qlog_mtimes_path}")
print(f"Updated {files_manifest_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())