mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-01 03:43:46 +08:00
271 lines
9.6 KiB
Python
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())
|