mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 05:23:41 +08:00
978105ad9b
Document StarPilot setup and third-party attribution. Keep cloud authentication scoped to the selected provider. Co-authored-by: Daniel Koepping <elkoled@gmail.com> Co-authored-by: Danny <dannyirwin31@yahoo.com> Co-authored-by: firestarsdog <229254897+firestarsdog@users.noreply.github.com> Co-authored-by: Adeeb Shihadeh <adeebshihadeh@gmail.com>
379 lines
13 KiB
Python
Executable File
379 lines
13 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
CLI tool for downloading files and querying the comma API.
|
|
Called by C++ replay/cabana via subprocess.
|
|
|
|
Subcommands:
|
|
route-files <route> - Get route file URLs as JSON
|
|
resolve-route-files <route> - Resolve logs and cameras from API and internal storage
|
|
download <url> - Download/decompress URL to local cache, print local path
|
|
decompress <path> - Decompress a local log file, print temporary path
|
|
devices - List user's devices as JSON
|
|
device-routes <did> - List routes for a device as JSON
|
|
"""
|
|
import argparse
|
|
import bz2
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
import traceback
|
|
from urllib.parse import urlparse
|
|
|
|
from openpilot.common.hardware.hw import Paths
|
|
|
|
|
|
def api_call(func):
|
|
"""Run an API call, outputting JSON result or error to stdout."""
|
|
from openpilot.tools.lib.api import CommaApi, UnauthorizedError, APIError
|
|
from openpilot.tools.lib.auth_config import get_token
|
|
|
|
try:
|
|
result = func(CommaApi(get_token()))
|
|
json.dump(result, sys.stdout)
|
|
except UnauthorizedError:
|
|
json.dump({"error": "unauthorized"}, sys.stdout)
|
|
except APIError as e:
|
|
error = "not_found" if getattr(e, 'status_code', 0) == 404 else str(e)
|
|
json.dump({"error": error}, sys.stdout)
|
|
except Exception as e:
|
|
json.dump({"error": str(e)}, sys.stdout)
|
|
sys.stdout.write("\n")
|
|
sys.stdout.flush()
|
|
|
|
|
|
def cache_file_path(url, compression=None):
|
|
url_without_query = url.split("?")[0]
|
|
if compression:
|
|
url_without_query = f"decompressed-{compression}:{url_without_query}"
|
|
return os.path.join(Paths.download_cache_root(), hashlib.sha256(url_without_query.encode()).hexdigest())
|
|
|
|
|
|
def compression_type(data):
|
|
if data.startswith(b'BZh'):
|
|
return 'bz2'
|
|
if data.startswith(b'\x28\xb5\x2f\xfd'):
|
|
return 'zst'
|
|
return None
|
|
|
|
|
|
def make_decompressor(compression):
|
|
if compression == 'bz2':
|
|
return bz2.BZ2Decompressor()
|
|
if compression == 'zst':
|
|
import zstandard as zstd
|
|
return zstd.ZstdDecompressor().decompressobj()
|
|
raise ValueError(f"Unsupported compression type: {compression}")
|
|
|
|
|
|
def decompress_file(source, destination, compression=None, max_bytes=0):
|
|
with open(source, 'rb') as src, open(destination, 'wb') as dst:
|
|
header = src.read(4)
|
|
compression = compression or compression_type(header)
|
|
decompressor = make_decompressor(compression)
|
|
written = 0
|
|
def write(data):
|
|
nonlocal written
|
|
written += len(data)
|
|
if max_bytes and written > max_bytes:
|
|
raise ValueError("Decompressed log exceeds replay memory limit")
|
|
dst.write(data)
|
|
write(decompressor.decompress(header))
|
|
# Bound a single decoder allocation as well as the completed output.
|
|
while data := src.read(256 if max_bytes else 1024 * 1024):
|
|
write(decompressor.decompress(data))
|
|
if not decompressor.eof:
|
|
raise EOFError(f"Compressed {compression} file ended before the end-of-stream marker")
|
|
|
|
|
|
def materialize_cached_file(source, url, compression):
|
|
local_path = cache_file_path(url, compression)
|
|
if os.path.exists(local_path):
|
|
return local_path
|
|
|
|
tmp_fd, tmp_path = tempfile.mkstemp(dir=Paths.download_cache_root())
|
|
os.close(tmp_fd)
|
|
try:
|
|
decompress_file(source, tmp_path, compression)
|
|
shutil.move(tmp_path, local_path)
|
|
except Exception:
|
|
try:
|
|
os.unlink(tmp_path)
|
|
except OSError:
|
|
pass
|
|
raise
|
|
return local_path
|
|
|
|
|
|
def cmd_route_files(args):
|
|
api_call(lambda api: api.get(f"v1/route/{args.route}/files"))
|
|
|
|
|
|
def resolve_route_files(route_name: str, begin: int = 0, end: int = -1, selector: str = "a") -> dict[int, dict[str, str]]:
|
|
from openpilot.tools.lib.filereader import DATA_ENDPOINT
|
|
from openpilot.tools.lib.file_sources import InternalUnavailableException, internal_source
|
|
from openpilot.tools.lib.route import FileName, SegmentRange
|
|
from openpilot.tools.lib.api import CommaApi
|
|
from openpilot.tools.lib.auth_config import get_token
|
|
|
|
sr = SegmentRange(route_name)
|
|
api_error = None
|
|
try:
|
|
route_files = CommaApi(get_token()).get(f"v1/route/{sr.route_name}/files")
|
|
except Exception as e:
|
|
api_error = e
|
|
route_files = {}
|
|
fields = {"rlog": FileName.RLOG, "qlog": FileName.QLOG,
|
|
"narrow_road": FileName.FCAMERA, "cabin": FileName.DCAMERA, "wide_road": FileName.ECAMERA, "qcamera": FileName.QCAMERA}
|
|
names = {name: key for key, fns in fields.items() for name in fns}
|
|
names.update({"rlog": "rlog", "qlog": "qlog"})
|
|
api_files: dict[int, dict[str, str]] = {}
|
|
for url in (url for urls in route_files.values() for url in urls):
|
|
_, number, name = urlparse(url).path.rsplit("/", 2)
|
|
if name in names:
|
|
api_files.setdefault(int(number), {})[names[name]] = url
|
|
# Preserve replay's inclusive segment bounds.
|
|
open_ended = end < 0
|
|
local_internal = os.path.isdir(DATA_ENDPOINT)
|
|
if open_ended:
|
|
end = max(begin, max(api_files, default=0))
|
|
if local_internal:
|
|
internal_dir = os.path.join(DATA_ENDPOINT, sr.dongle_id, sr.log_id)
|
|
if os.path.isdir(internal_dir):
|
|
end = max(end, max((int(name) for name in os.listdir(internal_dir) if name.isdigit()), default=0))
|
|
indices = list(range(max(0, begin), end + 1))
|
|
segments: dict[int, dict[str, str]] = {seg: {} for seg in indices}
|
|
internal_errors: dict[str, Exception] = {}
|
|
|
|
def resolve(key, requested):
|
|
files = {seg: api_files[seg][key] for seg in requested if key in api_files.get(seg, {})}
|
|
missing = [seg for seg in requested if seg not in files]
|
|
if missing:
|
|
try:
|
|
files.update(internal_source(sr, missing, fields[key]))
|
|
except Exception as e:
|
|
if e is not InternalUnavailableException:
|
|
internal_errors[key] = e
|
|
for seg, path in files.items():
|
|
segments[seg][key] = path
|
|
|
|
log_types = ["qlog"] if selector == "q" else ["rlog"]
|
|
if selector == "a":
|
|
log_types.append("qlog")
|
|
for key in log_types:
|
|
resolve(key, [seg for seg in indices if not segments[seg]])
|
|
if open_ended and not local_internal:
|
|
# Remote internal storage exposes individual files, not a segment listing.
|
|
# Probe the contiguous tail beyond the API's last segment until no selected log exists.
|
|
number = end + 1
|
|
while True:
|
|
internal_errors.clear()
|
|
segments[number] = {}
|
|
for key in log_types:
|
|
if not segments[number]:
|
|
resolve(key, [number])
|
|
if not segments[number]:
|
|
if internal_errors:
|
|
print(f"Internal discovery stopped at segment {number}; keeping resolved logs", file=sys.stderr)
|
|
traceback.print_exception(next(iter(internal_errors.values())))
|
|
del segments[number]
|
|
break
|
|
indices.append(number)
|
|
number += 1
|
|
available = [seg for seg in indices if segments[seg]]
|
|
if not available:
|
|
if api_error is not None:
|
|
raise api_error
|
|
raise FileNotFoundError(f"No {selector} logs found for {sr.route_name} in segments {indices} through the API or internal storage")
|
|
for key in ("narrow_road", "cabin", "wide_road", "qcamera"):
|
|
resolve(key, available)
|
|
return {seg: segments[seg] for seg in available}
|
|
|
|
|
|
def cmd_resolve_route_files(args):
|
|
try:
|
|
result = resolve_route_files(args.route, args.begin, args.end, args.selector)
|
|
except Exception as e:
|
|
error = f"{type(e).__name__} resolving {args.route} (begin={args.begin}, end={args.end}, selector={args.selector}): {e}"
|
|
print(error, file=sys.stderr)
|
|
traceback.print_exc()
|
|
result = {"error": error}
|
|
json.dump(result, sys.stdout)
|
|
sys.stdout.write("\n")
|
|
sys.stdout.flush()
|
|
|
|
|
|
def cmd_download(args):
|
|
url = args.url
|
|
use_cache = not args.no_cache
|
|
|
|
if use_cache:
|
|
for compression in ('bz2', 'zst'):
|
|
decompressed_path = cache_file_path(url, compression)
|
|
if os.path.exists(decompressed_path):
|
|
sys.stdout.write(decompressed_path + "\n")
|
|
sys.stdout.flush()
|
|
return
|
|
|
|
local_path = cache_file_path(url)
|
|
if os.path.exists(local_path):
|
|
with open(local_path, 'rb') as f:
|
|
compression = compression_type(f.read(4))
|
|
if compression:
|
|
local_path = materialize_cached_file(local_path, url, compression)
|
|
sys.stdout.write(local_path + "\n")
|
|
sys.stdout.flush()
|
|
return
|
|
|
|
try:
|
|
# Stream the file in a single HTTP request instead of making
|
|
# a separate Range request per chunk (which was very slow).
|
|
from openpilot.tools.lib.url_file import URLFile
|
|
pool = URLFile.pool_manager()
|
|
r = pool.request("GET", url, preload_content=False)
|
|
if r.status not in (200, 206):
|
|
sys.stderr.write(f"ERROR:HTTP {r.status}\n")
|
|
sys.stderr.flush()
|
|
sys.exit(1)
|
|
|
|
total = int(r.headers.get('content-length', 0))
|
|
if total <= 0:
|
|
sys.stderr.write("ERROR:File not found or empty\n")
|
|
sys.stderr.flush()
|
|
sys.exit(1)
|
|
|
|
os.makedirs(Paths.download_cache_root(), exist_ok=True)
|
|
tmp_fd, tmp_path = tempfile.mkstemp(dir=Paths.download_cache_root())
|
|
try:
|
|
downloaded = 0
|
|
chunk_size = 1024 * 1024
|
|
compression = None
|
|
decompressor = None
|
|
with os.fdopen(tmp_fd, 'wb') as f:
|
|
for data in r.stream(chunk_size):
|
|
if downloaded == 0:
|
|
compression = compression_type(data)
|
|
if compression:
|
|
decompressor = make_decompressor(compression)
|
|
f.write(decompressor.decompress(data) if decompressor else data)
|
|
downloaded += len(data)
|
|
sys.stderr.write(f"PROGRESS:{downloaded}:{total}\n")
|
|
sys.stderr.flush()
|
|
|
|
if decompressor and not decompressor.eof:
|
|
raise EOFError(f"Compressed {compression} file ended before the end-of-stream marker")
|
|
|
|
if decompressor:
|
|
if use_cache:
|
|
output_path = cache_file_path(url, compression)
|
|
shutil.move(tmp_path, output_path)
|
|
else:
|
|
output_path = tmp_path
|
|
sys.stdout.write(output_path + "\n")
|
|
elif use_cache:
|
|
shutil.move(tmp_path, local_path)
|
|
sys.stdout.write(local_path + "\n")
|
|
else:
|
|
sys.stdout.write(tmp_path + "\n")
|
|
except Exception:
|
|
try:
|
|
os.unlink(tmp_path)
|
|
except OSError:
|
|
pass
|
|
raise
|
|
finally:
|
|
r.release_conn()
|
|
|
|
except Exception as e:
|
|
sys.stderr.write(f"ERROR:{e}\n")
|
|
sys.stderr.flush()
|
|
sys.exit(1)
|
|
|
|
sys.stdout.flush()
|
|
|
|
|
|
def cmd_decompress(args):
|
|
os.makedirs(Paths.download_cache_root(), exist_ok=True)
|
|
output_fd, output_path = tempfile.mkstemp(dir=Paths.download_cache_root())
|
|
os.close(output_fd)
|
|
try:
|
|
decompress_file(args.path, output_path, max_bytes=getattr(args, 'max_bytes', 0))
|
|
except Exception as e:
|
|
try:
|
|
os.unlink(output_path)
|
|
except OSError:
|
|
pass
|
|
sys.stderr.write(f"ERROR:{e}\n")
|
|
sys.stderr.flush()
|
|
sys.exit(1)
|
|
sys.stdout.write(output_path + "\n")
|
|
sys.stdout.flush()
|
|
|
|
|
|
def cmd_devices(args):
|
|
api_call(lambda api: api.get("v1/me/devices/"))
|
|
|
|
|
|
def cmd_device_routes(args):
|
|
def fetch(api):
|
|
if args.preserved:
|
|
return api.get(f"v1/devices/{args.dongle_id}/routes/preserved")
|
|
params = {}
|
|
if args.start is not None:
|
|
params['start'] = args.start
|
|
if args.end is not None:
|
|
params['end'] = args.end
|
|
return api.get(f"v1/devices/{args.dongle_id}/routes_segments", params=params)
|
|
api_call(fetch)
|
|
|
|
|
|
def main():
|
|
parent = os.environ.get('SP_REPLAY_HELPER_PARENT')
|
|
if parent is not None:
|
|
import ctypes
|
|
import signal
|
|
if sys.platform != 'linux' or ctypes.CDLL(None).prctl(1, signal.SIGKILL, 0, 0, 0) != 0 or os.getppid() != int(parent):
|
|
raise RuntimeError('Replay source owner exited')
|
|
parser = argparse.ArgumentParser(description="File downloader CLI for openpilot tools")
|
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
|
|
p_rf = subparsers.add_parser("route-files")
|
|
p_rf.add_argument("route")
|
|
p_rf.set_defaults(func=cmd_route_files)
|
|
|
|
p_resolve = subparsers.add_parser("resolve-route-files")
|
|
p_resolve.add_argument("route")
|
|
p_resolve.add_argument("--begin", type=int, default=0)
|
|
p_resolve.add_argument("--end", type=int, default=-1, help="Inclusive last segment; negative means open-ended")
|
|
p_resolve.add_argument("--selector", choices=("a", "r", "q"), default="a")
|
|
p_resolve.set_defaults(func=cmd_resolve_route_files)
|
|
|
|
p_dl = subparsers.add_parser("download")
|
|
p_dl.add_argument("url")
|
|
p_dl.add_argument("--no-cache", action="store_true")
|
|
p_dl.set_defaults(func=cmd_download)
|
|
|
|
p_dc = subparsers.add_parser("decompress")
|
|
p_dc.add_argument("path")
|
|
p_dc.add_argument("--max-bytes", type=int, default=0)
|
|
p_dc.set_defaults(func=cmd_decompress)
|
|
|
|
p_dev = subparsers.add_parser("devices")
|
|
p_dev.set_defaults(func=cmd_devices)
|
|
|
|
p_dr = subparsers.add_parser("device-routes")
|
|
p_dr.add_argument("dongle_id")
|
|
p_dr.add_argument("--start", type=int, default=None)
|
|
p_dr.add_argument("--end", type=int, default=None)
|
|
p_dr.add_argument("--preserved", action="store_true")
|
|
p_dr.set_defaults(func=cmd_device_routes)
|
|
|
|
args = parser.parse_args()
|
|
args.func(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|