Merge remote-tracking branch 'upstream/master' into check-accel

pull/35700/head
Shane Smiskol 4 weeks ago
commit ae2c19b0eb
  1. 9
      selfdrive/car/tests/test_models.py
  2. 2
      selfdrive/modeld/models/driving_policy.onnx
  3. 5
      system/ui/README.md
  4. 58
      system/ui/reset.py
  5. 49
      system/ui/widgets/button.py
  6. 2
      tools/cabana/dbc/dbc.cc
  7. 6
      tools/car_porting/examples/find_segments_with_message.ipynb
  8. 6
      tools/car_porting/examples/ford_vin_fingerprint.ipynb
  9. 6
      tools/car_porting/examples/hkg_canfd_gear_message.ipynb
  10. 3
      tools/lib/comma_car_segments.py
  11. 9
      tools/lib/filereader.py
  12. 2
      tools/lib/helpers.py
  13. 220
      tools/lib/logreader.py
  14. 8
      tools/lib/route.py
  15. 10
      tools/lib/tests/test_logreader.py

@ -4,7 +4,6 @@ import pytest
import random import random
import unittest # noqa: TID251 import unittest # noqa: TID251
from collections import defaultdict, Counter from collections import defaultdict, Counter
from functools import partial
import hypothesis.strategies as st import hypothesis.strategies as st
from hypothesis import Phase, given, settings from hypothesis import Phase, given, settings
from parameterized import parameterized_class from parameterized import parameterized_class
@ -22,8 +21,7 @@ from openpilot.common.basedir import BASEDIR
from openpilot.selfdrive.pandad import can_capnp_to_list from openpilot.selfdrive.pandad import can_capnp_to_list
from openpilot.selfdrive.test.helpers import read_segment_list from openpilot.selfdrive.test.helpers import read_segment_list
from openpilot.system.hardware.hw import DEFAULT_DOWNLOAD_CACHE_ROOT from openpilot.system.hardware.hw import DEFAULT_DOWNLOAD_CACHE_ROOT
from openpilot.tools.lib.logreader import LogReader, LogsUnavailable, openpilotci_source_zst, openpilotci_source, internal_source, \ from openpilot.tools.lib.logreader import LogReader, LogsUnavailable, openpilotci_source, internal_source, comma_api_source
internal_source_zst, comma_api_source, auto_source
from openpilot.tools.lib.route import SegmentName from openpilot.tools.lib.route import SegmentName
SafetyModel = car.CarParams.SafetyModel SafetyModel = car.CarParams.SafetyModel
@ -125,9 +123,8 @@ class TestCarModelBase(unittest.TestCase):
segment_range = f"{cls.test_route.route}/{seg}" segment_range = f"{cls.test_route.route}/{seg}"
try: try:
source = partial(auto_source, sources=[internal_source, internal_source_zst] if len(INTERNAL_SEG_LIST) else \ sources = [internal_source] if len(INTERNAL_SEG_LIST) else [openpilotci_source, comma_api_source]
[openpilotci_source_zst, openpilotci_source, comma_api_source]) lr = LogReader(segment_range, sources=sources, sort_by_time=True)
lr = LogReader(segment_range, source=source, sort_by_time=True)
return cls.get_testing_data_from_logreader(lr) return cls.get_testing_data_from_logreader(lr)
except (LogsUnavailable, AssertionError): except (LogsUnavailable, AssertionError):
pass pass

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:cddefa5dbe21c60858f0d321d12d1ed4b126d05a7563f651bdf021674be63d64 oid sha256:ef059460a95076f9a8600abb8c9d56c8c3b7384505b158064e2e875458b965bb
size 15701037 size 15701037

@ -8,3 +8,8 @@ Quick start:
* set `SCALE=1.5` to scale the entire UI by 1.5x * set `SCALE=1.5` to scale the entire UI by 1.5x
* https://www.raylib.com/cheatsheet/cheatsheet.html * https://www.raylib.com/cheatsheet/cheatsheet.html
* https://electronstudio.github.io/raylib-python-cffi/README.html#quickstart * https://electronstudio.github.io/raylib-python-cffi/README.html#quickstart
Style guide:
* All graphical elements should subclass [`Widget`](/system/ui/widgets/__init__.py).
* Prefer a stateful widget over a function for easy migration from QT
* All internal class variables and functions should be prefixed with `_`

@ -1,14 +1,15 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import os import os
import pyray as rl
import sys import sys
import threading import threading
from enum import IntEnum from enum import IntEnum
import pyray as rl
from openpilot.system.hardware import PC from openpilot.system.hardware import PC
from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.button import gui_button, ButtonStyle from openpilot.system.ui.widgets.button import Button, ButtonStyle
from openpilot.system.ui.widgets.label import gui_label, gui_text_box from openpilot.system.ui.widgets.label import gui_label, gui_text_box
NVME = "/dev/nvme0n1" NVME = "/dev/nvme0n1"
@ -31,8 +32,15 @@ class ResetState(IntEnum):
class Reset(Widget): class Reset(Widget):
def __init__(self, mode): def __init__(self, mode):
super().__init__() super().__init__()
self.mode = mode self._mode = mode
self.reset_state = ResetState.NONE self._reset_state = ResetState.NONE
self._cancel_button = Button("Cancel", self._cancel_callback)
self._confirm_button = Button("Confirm", self._confirm, button_style=ButtonStyle.PRIMARY)
self._reboot_button = Button("Reboot", lambda: os.system("sudo reboot"))
self._render_status = True
def _cancel_callback(self):
self._render_status = False
def _do_erase(self): def _do_erase(self):
if PC: if PC:
@ -50,10 +58,10 @@ class Reset(Widget):
if rm == 0 or fmt == 0: if rm == 0 or fmt == 0:
os.system("sudo reboot") os.system("sudo reboot")
else: else:
self.reset_state = ResetState.FAILED self._reset_state = ResetState.FAILED
def start_reset(self): def start_reset(self):
self.reset_state = ResetState.RESETTING self._reset_state = ResetState.RESETTING
threading.Timer(0.1, self._do_erase).start() threading.Timer(0.1, self._do_erase).start()
def _render(self, rect: rl.Rectangle): def _render(self, rect: rl.Rectangle):
@ -61,42 +69,38 @@ class Reset(Widget):
gui_label(label_rect, "System Reset", 100, font_weight=FontWeight.BOLD) gui_label(label_rect, "System Reset", 100, font_weight=FontWeight.BOLD)
text_rect = rl.Rectangle(rect.x + 140, rect.y + 140, rect.width - 280, rect.height - 90 - 100) text_rect = rl.Rectangle(rect.x + 140, rect.y + 140, rect.width - 280, rect.height - 90 - 100)
gui_text_box(text_rect, self.get_body_text(), 90) gui_text_box(text_rect, self._get_body_text(), 90)
button_height = 160 button_height = 160
button_spacing = 50 button_spacing = 50
button_top = rect.y + rect.height - button_height button_top = rect.y + rect.height - button_height
button_width = (rect.width - button_spacing) / 2.0 button_width = (rect.width - button_spacing) / 2.0
if self.reset_state != ResetState.RESETTING: if self._reset_state != ResetState.RESETTING:
if self.mode == ResetMode.RECOVER or self.reset_state == ResetState.FAILED: if self._mode == ResetMode.RECOVER or self._reset_state == ResetState.FAILED:
if gui_button(rl.Rectangle(rect.x, button_top, button_width, button_height), "Reboot"): self._reboot_button.render(rl.Rectangle(rect.x, button_top, rect.width, button_height))
os.system("sudo reboot") elif self._mode == ResetMode.USER_RESET:
elif self.mode == ResetMode.USER_RESET: self._cancel_button.render(rl.Rectangle(rect.x, button_top, button_width, button_height))
if gui_button(rl.Rectangle(rect.x, button_top, button_width, button_height), "Cancel"):
return False
if self.reset_state != ResetState.FAILED: if self._reset_state != ResetState.FAILED:
if gui_button(rl.Rectangle(rect.x + button_width + 50, button_top, button_width, button_height), self._confirm_button.render(rl.Rectangle(rect.x + button_width + 50, button_top, button_width, button_height))
"Confirm", button_style=ButtonStyle.PRIMARY):
self.confirm()
return True return self._render_status
def confirm(self): def _confirm(self):
if self.reset_state == ResetState.CONFIRM: if self._reset_state == ResetState.CONFIRM:
self.start_reset() self.start_reset()
else: else:
self.reset_state = ResetState.CONFIRM self._reset_state = ResetState.CONFIRM
def get_body_text(self): def _get_body_text(self):
if self.reset_state == ResetState.CONFIRM: if self._reset_state == ResetState.CONFIRM:
return "Are you sure you want to reset your device?" return "Are you sure you want to reset your device?"
if self.reset_state == ResetState.RESETTING: if self._reset_state == ResetState.RESETTING:
return "Resetting device...\nThis may take up to a minute." return "Resetting device...\nThis may take up to a minute."
if self.reset_state == ResetState.FAILED: if self._reset_state == ResetState.FAILED:
return "Reset failed. Reboot to try again." return "Reset failed. Reboot to try again."
if self.mode == ResetMode.RECOVER: if self._mode == ResetMode.RECOVER:
return "Unable to mount data partition. Partition may be corrupted. Press confirm to erase and reset your device." return "Unable to mount data partition. Partition may be corrupted. Press confirm to erase and reset your device."
return "System reset triggered. Press confirm to erase all content and settings. Press cancel to resume boot." return "System reset triggered. Press confirm to erase all content and settings. Press cancel to resume boot."

@ -1,7 +1,11 @@
import pyray as rl from collections.abc import Callable
from enum import IntEnum from enum import IntEnum
from openpilot.system.ui.lib.application import gui_app, FontWeight
import pyray as rl
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.widgets import Widget
class ButtonStyle(IntEnum): class ButtonStyle(IntEnum):
@ -148,3 +152,44 @@ def gui_button(
rl.draw_text_ex(font, text, text_pos, font_size, 0, color) rl.draw_text_ex(font, text, text_pos, font_size, 0, color)
return result return result
class Button(Widget):
def __init__(self,
text: str,
click_callback: Callable[[], None] = None,
font_size: int = DEFAULT_BUTTON_FONT_SIZE,
font_weight: FontWeight = FontWeight.MEDIUM,
button_style: ButtonStyle = ButtonStyle.NORMAL,
border_radius: int = 10,
):
super().__init__()
self._text = text
self._click_callback = click_callback
self._label_font = gui_app.font(FontWeight.SEMI_BOLD)
self._button_style = button_style
self._font_size = font_size
self._border_radius = border_radius
self._font_size = font_size
self._text_color = BUTTON_TEXT_COLOR[button_style]
self._text_size = measure_text_cached(gui_app.font(font_weight), text, font_size)
def _handle_mouse_release(self, mouse_pos: MousePos):
if self._click_callback:
print(f"Button clicked: {self._text}")
self._click_callback()
def _get_background_color(self) -> rl.Color:
if self._is_pressed:
return BUTTON_PRESSED_BACKGROUND_COLORS[self._button_style]
else:
return BUTTON_BACKGROUND_COLORS[self._button_style]
def _render(self, _):
roundness = self._border_radius / (min(self._rect.width, self._rect.height) / 2)
rl.draw_rectangle_rounded(self._rect, roundness, 10, self._get_background_color())
text_pos = rl.Vector2(0, self._rect.y + (self._rect.height - self._text_size.y) // 2)
text_pos.x = self._rect.x + (self._rect.width - self._text_size.x) // 2
rl.draw_text_ex(self._label_font, self._text, text_pos, self._font_size, 0, self._text_color)

@ -109,7 +109,7 @@ void cabana::Msg::update() {
mask[i] |= ((1ULL << sz) - 1) << shift; mask[i] |= ((1ULL << sz) - 1) << shift;
bits -= size; bits -= sz;
i = sig->is_little_endian ? i - 1 : i + 1; i = sig->is_little_endian ? i - 1 : i + 1;
} }
} }

@ -76,7 +76,7 @@
" if platform not in database:\n", " if platform not in database:\n",
" print(f\"No segments available for {platform}\")\n", " print(f\"No segments available for {platform}\")\n",
" continue\n", " continue\n",
" \n", "\n",
" all_segments = database[platform]\n", " all_segments = database[platform]\n",
" NUM_SEGMENTS = min(len(all_segments), MAX_SEGS_PER_PLATFORM)\n", " NUM_SEGMENTS = min(len(all_segments), MAX_SEGS_PER_PLATFORM)\n",
" TEST_SEGMENTS.extend(random.sample(all_segments, NUM_SEGMENTS))\n", " TEST_SEGMENTS.extend(random.sample(all_segments, NUM_SEGMENTS))\n",
@ -147,7 +147,7 @@
} }
], ],
"source": [ "source": [
"from openpilot.tools.lib.logreader import LogReader\n", "from openpilot.tools.lib.logreader import LogReader, comma_car_segments_source\n",
"from tqdm.notebook import tqdm, tnrange\n", "from tqdm.notebook import tqdm, tnrange\n",
"\n", "\n",
"# Example search for CAN ignition messages\n", "# Example search for CAN ignition messages\n",
@ -169,7 +169,7 @@
"progress_bar = tnrange(len(TEST_SEGMENTS), desc=\"segments searched\")\n", "progress_bar = tnrange(len(TEST_SEGMENTS), desc=\"segments searched\")\n",
"\n", "\n",
"for segment in TEST_SEGMENTS:\n", "for segment in TEST_SEGMENTS:\n",
" lr = LogReader(segment)\n", " lr = LogReader(segment, sources=[comma_car_segments_source])\n",
" CP = lr.first(\"carParams\")\n", " CP = lr.first(\"carParams\")\n",
" if CP is None:\n", " if CP is None:\n",
" progress_bar.update()\n", " progress_bar.update()\n",

@ -20,7 +20,7 @@
"source": [ "source": [
"\"\"\"In this example, we use the public comma car segments database to check if vin fingerprinting is feasible for ford.\"\"\"\n", "\"\"\"In this example, we use the public comma car segments database to check if vin fingerprinting is feasible for ford.\"\"\"\n",
"\n", "\n",
"from openpilot.tools.lib.logreader import LogReader\n", "from openpilot.tools.lib.logreader import LogReader, comma_car_segments_source\n",
"from openpilot.tools.lib.comma_car_segments import get_comma_car_segments_database\n", "from openpilot.tools.lib.comma_car_segments import get_comma_car_segments_database\n",
"from opendbc.car.ford.values import CAR\n", "from opendbc.car.ford.values import CAR\n",
"\n", "\n",
@ -100,7 +100,7 @@
" if platform not in database:\n", " if platform not in database:\n",
" print(f\"Skipping platform: {platform}, no data available\")\n", " print(f\"Skipping platform: {platform}, no data available\")\n",
" continue\n", " continue\n",
" \n", "\n",
" all_segments = database[platform]\n", " all_segments = database[platform]\n",
"\n", "\n",
" NUM_SEGMENTS = min(len(all_segments), MAX_SEGS_PER_PLATFORM)\n", " NUM_SEGMENTS = min(len(all_segments), MAX_SEGS_PER_PLATFORM)\n",
@ -110,7 +110,7 @@
" segments = random.sample(all_segments, NUM_SEGMENTS)\n", " segments = random.sample(all_segments, NUM_SEGMENTS)\n",
"\n", "\n",
" for segment in segments:\n", " for segment in segments:\n",
" lr = LogReader(segment)\n", " lr = LogReader(segment, sources=[comma_car_segments_source])\n",
" CP = lr.first(\"carParams\")\n", " CP = lr.first(\"carParams\")\n",
" if \"FORD\" not in CP.carFingerprint:\n", " if \"FORD\" not in CP.carFingerprint:\n",
" print(segment, CP.carFingerprint)\n", " print(segment, CP.carFingerprint)\n",

@ -72,7 +72,7 @@
" #if platform not in database:\n", " #if platform not in database:\n",
" # print(f\"Skipping platform: {platform}, no data available\")\n", " # print(f\"Skipping platform: {platform}, no data available\")\n",
" # continue\n", " # continue\n",
" \n", "\n",
" all_segments = database[platform]\n", " all_segments = database[platform]\n",
"\n", "\n",
" NUM_SEGMENTS = min(len(all_segments), MAX_SEGS_PER_PLATFORM)\n", " NUM_SEGMENTS = min(len(all_segments), MAX_SEGS_PER_PLATFORM)\n",
@ -198,12 +198,12 @@
"from opendbc.car.hyundai.hyundaicanfd import CanBus\n", "from opendbc.car.hyundai.hyundaicanfd import CanBus\n",
"\n", "\n",
"from openpilot.selfdrive.pandad import can_capnp_to_list\n", "from openpilot.selfdrive.pandad import can_capnp_to_list\n",
"from openpilot.tools.lib.logreader import LogReader\n", "from openpilot.tools.lib.logreader import LogReader, comma_car_segments_source\n",
"\n", "\n",
"message_names = [\"GEAR_SHIFTER\", \"ACCELERATOR\", \"GEAR\", \"GEAR_ALT\", \"GEAR_ALT_2\"]\n", "message_names = [\"GEAR_SHIFTER\", \"ACCELERATOR\", \"GEAR\", \"GEAR_ALT\", \"GEAR_ALT_2\"]\n",
"\n", "\n",
"for segment in TEST_SEGMENTS:\n", "for segment in TEST_SEGMENTS:\n",
" lr = LogReader(segment)\n", " lr = LogReader(segment, sources=[comma_car_segments_source])\n",
" CP = lr.first(\"carParams\")\n", " CP = lr.first(\"carParams\")\n",
" if CP is None:\n", " if CP is None:\n",
" continue\n", " continue\n",

@ -14,7 +14,8 @@ def get_comma_car_segments_database():
ret = {} ret = {}
for platform in database: for platform in database:
ret[MIGRATION.get(platform, platform)] = database[platform] # TODO: remove this when commaCarSegments is updated to remove selector
ret[MIGRATION.get(platform, platform)] = [s.rstrip('/s') for s in database[platform]]
return ret return ret

@ -1,6 +1,8 @@
import os import os
import posixpath import posixpath
import socket import socket
from functools import cache
from openpilot.common.retry import retry
from urllib.parse import urlparse from urllib.parse import urlparse
from openpilot.tools.lib.url_file import URLFile from openpilot.tools.lib.url_file import URLFile
@ -8,14 +10,16 @@ from openpilot.tools.lib.url_file import URLFile
DATA_ENDPOINT = os.getenv("DATA_ENDPOINT", "http://data-raw.comma.internal/") DATA_ENDPOINT = os.getenv("DATA_ENDPOINT", "http://data-raw.comma.internal/")
def internal_source_available(url=DATA_ENDPOINT): @cache
@retry(delay=0.0)
def internal_source_available(url: str) -> bool:
if os.path.isdir(url): if os.path.isdir(url):
return True return True
try: try:
hostname = urlparse(url).hostname hostname = urlparse(url).hostname
port = urlparse(url).port or 80 port = urlparse(url).port or 80
with socket.socket(socket.AF_INET,socket.SOCK_STREAM) as s: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(0.5) s.settimeout(0.5)
s.connect((hostname, port)) s.connect((hostname, port))
return True return True
@ -30,6 +34,7 @@ def resolve_name(fn):
return fn return fn
@cache
def file_exists(fn): def file_exists(fn):
fn = resolve_name(fn) fn = resolve_name(fn)
if fn.startswith(("http://", "https://")): if fn.startswith(("http://", "https://")):

@ -9,7 +9,7 @@ class RE:
INDEX = r'-?[0-9]+' INDEX = r'-?[0-9]+'
SLICE = fr'(?P<start>{INDEX})?:?(?P<end>{INDEX})?:?(?P<step>{INDEX})?' SLICE = fr'(?P<start>{INDEX})?:?(?P<end>{INDEX})?:?(?P<step>{INDEX})?'
SEGMENT_RANGE = fr'{ROUTE_NAME}(?:(--|/)(?P<slice>({SLICE})))?(?:/(?P<selector>([qras])))?' SEGMENT_RANGE = fr'{ROUTE_NAME}(?:(--|/)(?P<slice>({SLICE})))?(?:/(?P<selector>([qra])))?'
BOOTLOG_NAME = ROUTE_NAME BOOTLOG_NAME = ROUTE_NAME

@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import bz2 import bz2
from functools import cache, partial from functools import partial
import multiprocessing import multiprocessing
import capnp import capnp
import enum import enum
@ -13,14 +13,15 @@ import warnings
import zstandard as zstd import zstandard as zstd
from collections.abc import Callable, Iterable, Iterator from collections.abc import Callable, Iterable, Iterator
from typing import cast
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
from cereal import log as capnp_log from cereal import log as capnp_log
from openpilot.common.swaglog import cloudlog from openpilot.common.swaglog import cloudlog
from openpilot.tools.lib.comma_car_segments import get_url as get_comma_segments_url from openpilot.tools.lib.comma_car_segments import get_url as get_comma_segments_url
from openpilot.tools.lib.openpilotci import get_url from openpilot.tools.lib.openpilotci import get_url
from openpilot.tools.lib.filereader import FileReader, file_exists, internal_source_available from openpilot.tools.lib.filereader import DATA_ENDPOINT, FileReader, file_exists, internal_source_available
from openpilot.tools.lib.route import Route, SegmentRange from openpilot.tools.lib.route import QCAMERA_FILENAMES, CAMERA_FILENAMES, DCAMERA_FILENAMES, ECAMERA_FILENAMES, Route, SegmentRange
from openpilot.tools.lib.log_time_series import msgs_to_time_series from openpilot.tools.lib.log_time_series import msgs_to_time_series
LogMessage = type[capnp._DynamicStructReader] LogMessage = type[capnp._DynamicStructReader]
@ -96,14 +97,21 @@ class _LogFileReader:
class ReadMode(enum.StrEnum): class ReadMode(enum.StrEnum):
RLOG = "r" # only read rlogs RLOG = "r" # only read rlogs
QLOG = "q" # only read qlogs QLOG = "q" # only read qlogs
SANITIZED = "s" # read from the commaCarSegments database
AUTO = "a" # default to rlogs, fallback to qlogs AUTO = "a" # default to rlogs, fallback to qlogs
AUTO_INTERACTIVE = "i" # default to rlogs, fallback to qlogs with a prompt from the user AUTO_INTERACTIVE = "i" # default to rlogs, fallback to qlogs with a prompt from the user
class FileName(enum.Enum):
RLOG = ("rlog.zst", "rlog.bz2")
QLOG = ("qlog.zst", "qlog.bz2")
QCAMERA = tuple(QCAMERA_FILENAMES)
FCAMERA = tuple(CAMERA_FILENAMES)
ECAMERA = tuple(ECAMERA_FILENAMES)
DCAMERA = tuple(DCAMERA_FILENAMES)
LogPath = str | None LogPath = str | None
ValidFileCallable = Callable[[LogPath], bool] Source = Callable[[SegmentRange, FileName], list[LogPath]]
Source = Callable[[SegmentRange, ReadMode], list[LogPath]]
InternalUnavailableException = Exception("Internal source not available") InternalUnavailableException = Exception("Internal source not available")
@ -112,139 +120,129 @@ class LogsUnavailable(Exception):
pass pass
@cache def comma_api_source(sr: SegmentRange, fns: FileName) -> list[LogPath]:
def default_valid_file(fn: LogPath) -> bool:
return fn is not None and file_exists(fn)
def auto_strategy(rlog_paths: list[LogPath], qlog_paths: list[LogPath], interactive: bool, valid_file: ValidFileCallable) -> list[LogPath]:
# auto select logs based on availability
missing_rlogs = [rlog is None or not valid_file(rlog) for rlog in rlog_paths].count(True)
if missing_rlogs != 0:
if interactive:
if input(f"{missing_rlogs}/{len(rlog_paths)} rlogs were not found, would you like to fallback to qlogs for those segments? (y/n) ").lower() != "y":
return rlog_paths
else:
cloudlog.warning(f"{missing_rlogs}/{len(rlog_paths)} rlogs were not found, falling back to qlogs for those segments...")
return [rlog if valid_file(rlog) else (qlog if valid_file(qlog) else None)
for (rlog, qlog) in zip(rlog_paths, qlog_paths, strict=True)]
return rlog_paths
def apply_strategy(mode: ReadMode, rlog_paths: list[LogPath], qlog_paths: list[LogPath], valid_file: ValidFileCallable = default_valid_file) -> list[LogPath]:
if mode == ReadMode.RLOG:
return rlog_paths
elif mode == ReadMode.QLOG:
return qlog_paths
elif mode == ReadMode.AUTO:
return auto_strategy(rlog_paths, qlog_paths, False, valid_file)
elif mode == ReadMode.AUTO_INTERACTIVE:
return auto_strategy(rlog_paths, qlog_paths, True, valid_file)
raise ValueError(f"invalid mode: {mode}")
def comma_api_source(sr: SegmentRange, mode: ReadMode) -> list[LogPath]:
route = Route(sr.route_name) route = Route(sr.route_name)
rlog_paths = [route.log_paths()[seg] for seg in sr.seg_idxs]
qlog_paths = [route.qlog_paths()[seg] for seg in sr.seg_idxs]
# comma api will have already checked if the file exists # comma api will have already checked if the file exists
def valid_file(fn): if fns == FileName.RLOG:
return fn is not None return [route.log_paths()[seg] for seg in sr.seg_idxs]
else:
return apply_strategy(mode, rlog_paths, qlog_paths, valid_file=valid_file) return [route.qlog_paths()[seg] for seg in sr.seg_idxs]
def internal_source(sr: SegmentRange, mode: ReadMode, file_ext: str = "bz2") -> list[LogPath]: def internal_source(sr: SegmentRange, fns: FileName, endpoint_url: str = DATA_ENDPOINT) -> list[LogPath]:
if not internal_source_available(): if not internal_source_available(endpoint_url):
raise InternalUnavailableException raise InternalUnavailableException
def get_internal_url(sr: SegmentRange, seg, file): def get_internal_url(sr: SegmentRange, seg, file):
return f"cd:/{sr.dongle_id}/{sr.log_id}/{seg}/{file}.{file_ext}" return f"{endpoint_url.rstrip('/')}/{sr.dongle_id}/{sr.log_id}/{seg}/{file}"
# TODO: list instead of using static URLs to support routes with multiple file extensions return eval_source([[get_internal_url(sr, seg, fn) for fn in fns.value] for seg in sr.seg_idxs])
rlog_paths = [get_internal_url(sr, seg, "rlog") for seg in sr.seg_idxs]
qlog_paths = [get_internal_url(sr, seg, "qlog") for seg in sr.seg_idxs]
return apply_strategy(mode, rlog_paths, qlog_paths)
def openpilotci_source(sr: SegmentRange, fns: FileName) -> list[LogPath]:
return eval_source([[get_url(sr.route_name, seg, fn) for fn in fns.value] for seg in sr.seg_idxs])
def internal_source_zst(sr: SegmentRange, mode: ReadMode, file_ext: str = "zst") -> list[LogPath]:
return internal_source(sr, mode, file_ext)
def comma_car_segments_source(sr: SegmentRange, fns: FileName) -> list[LogPath]:
return eval_source([get_comma_segments_url(sr.route_name, seg) for seg in sr.seg_idxs])
def openpilotci_source(sr: SegmentRange, mode: ReadMode, file_ext: str = "bz2") -> list[LogPath]:
rlog_paths = [get_url(sr.route_name, seg, f"rlog.{file_ext}") for seg in sr.seg_idxs]
qlog_paths = [get_url(sr.route_name, seg, f"qlog.{file_ext}") for seg in sr.seg_idxs]
return apply_strategy(mode, rlog_paths, qlog_paths) def direct_source(file_or_url: str) -> list[str]:
return [file_or_url]
def openpilotci_source_zst(sr: SegmentRange, mode: ReadMode) -> list[LogPath]: def eval_source(files: list[list[str] | str]) -> list[LogPath]:
return openpilotci_source(sr, mode, "zst") # Returns valid file URLs given a list of possible file URLs for each segment (e.g. rlog.bz2, rlog.zst)
valid_files: list[LogPath] = []
for urls in files:
if isinstance(urls, str):
urls = [urls]
def comma_car_segments_source(sr: SegmentRange, mode=ReadMode.RLOG) -> list[LogPath]: for url in urls:
return [get_comma_segments_url(sr.route_name, seg) for seg in sr.seg_idxs] if file_exists(url):
valid_files.append(url)
break
else:
valid_files.append(None)
return valid_files
def testing_closet_source(sr: SegmentRange, mode=ReadMode.RLOG) -> list[LogPath]:
if not internal_source_available('http://testing.comma.life'):
raise InternalUnavailableException
return [f"http://testing.comma.life/download/{sr.route_name.replace('|', '/')}/{seg}/rlog" for seg in sr.seg_idxs]
def auto_source(identifier: str, sources: list[Source], default_mode: ReadMode) -> list[str]:
exceptions = {}
def direct_source(file_or_url: str) -> list[LogPath]: sr = SegmentRange(identifier)
return [file_or_url] mode = default_mode if sr.selector is None else ReadMode(sr.selector)
def get_invalid_files(files): if mode == ReadMode.QLOG:
for f in files: try_fns = [FileName.QLOG]
if f is None or not file_exists(f): else:
yield f try_fns = [FileName.RLOG]
# If selector allows it, fallback to qlogs
if mode in (ReadMode.AUTO, ReadMode.AUTO_INTERACTIVE):
try_fns.append(FileName.QLOG)
def check_source(source: Source, *args) -> list[LogPath]: # Build a dict of valid files as we evaluate each source. May contain mix of rlogs, qlogs, and None.
files = source(*args) # This function only returns when we've sourced all files, or throws an exception
assert len(files) > 0, "No files on source" valid_files: dict[int, LogPath] = {}
assert next(get_invalid_files(files), False) is False, "Some files are invalid" for fn in try_fns:
return files for source in sources:
try:
files = source(sr, fn)
# Check every source returns an expected number of files
assert len(files) == len(valid_files) or len(valid_files) == 0, f"Source {source.__name__} returned unexpected number of files"
def auto_source(sr: SegmentRange, mode=ReadMode.RLOG, sources: list[Source] = None) -> list[LogPath]: # Build a dict of valid files
if mode == ReadMode.SANITIZED: for idx, f in enumerate(files):
return comma_car_segments_source(sr, mode) if valid_files.get(idx) is None:
valid_files[idx] = f
if sources is None: # We've found all files, return them
sources = [internal_source, internal_source_zst, openpilotci_source, openpilotci_source_zst, if all(f is not None for f in valid_files.values()):
comma_api_source, comma_car_segments_source, testing_closet_source] return cast(list[str], list(valid_files.values()))
exceptions = {}
# for automatic fallback modes, auto_source needs to first check if rlogs exist for any source except Exception as e:
if mode in [ReadMode.AUTO, ReadMode.AUTO_INTERACTIVE]: exceptions[source.__name__] = e
for source in sources:
try:
return check_source(source, sr, ReadMode.RLOG)
except Exception:
pass
# Automatically determine viable source if fn == try_fns[0]:
for source in sources: missing_logs = list(valid_files.values()).count(None)
try: if mode == ReadMode.AUTO:
return check_source(source, sr, mode) cloudlog.warning(f"{missing_logs}/{len(valid_files)} rlogs were not found, falling back to qlogs for those segments...")
except Exception as e: elif mode == ReadMode.AUTO_INTERACTIVE:
exceptions[source.__name__] = e if input(f"{missing_logs}/{len(valid_files)} rlogs were not found, would you like to fallback to qlogs for those segments? (y/N) ").lower() != "y":
break
raise LogsUnavailable("auto_source could not find any valid source, exceptions for sources:\n - " + missing_logs = list(valid_files.values()).count(None)
"\n - ".join([f"{k}: {repr(v)}" for k, v in exceptions.items()])) raise LogsUnavailable(f"{missing_logs}/{len(valid_files)} logs were not found, please ensure all logs " +
"are uploaded. You can fall back to qlogs with '/a' selector at the end of the route name.\n\n" +
"Exceptions for sources:\n - " + "\n - ".join([f"{k}: {repr(v)}" for k, v in exceptions.items()]))
def parse_indirect(identifier: str) -> str: def parse_indirect(identifier: str) -> str:
if "useradmin.comma.ai" in identifier: if "useradmin.comma.ai" in identifier:
query = parse_qs(urlparse(identifier).query) query = parse_qs(urlparse(identifier).query)
return query["onebox"][0] identifier = query["onebox"][0]
elif "connect.comma.ai" in identifier:
path = urlparse(identifier).path.strip("/").split("/")
path = ['/'.join(path[:2]), *path[2:]] # recombine log id
identifier = path[0]
if len(path) > 2:
# convert url with seconds to segments
start, end = int(path[1]) // 60, int(path[2]) // 60 + 1
identifier = f"{identifier}/{start}:{end}"
# add selector if it exists
if len(path) > 3:
identifier += f"/{path[3]}"
else:
# add selector if it exists
identifier = "/".join(path)
return identifier return identifier
@ -255,7 +253,7 @@ def parse_direct(identifier: str):
class LogReader: class LogReader:
def _parse_identifier(self, identifier: str) -> list[LogPath]: def _parse_identifier(self, identifier: str) -> list[str]:
# useradmin, etc. # useradmin, etc.
identifier = parse_indirect(identifier) identifier = parse_indirect(identifier)
@ -264,20 +262,16 @@ class LogReader:
if direct_parsed is not None: if direct_parsed is not None:
return direct_source(identifier) return direct_source(identifier)
sr = SegmentRange(identifier) identifiers = auto_source(identifier, self.sources, self.default_mode)
mode = self.default_mode if sr.selector is None else ReadMode(sr.selector)
identifiers = self.source(sr, mode)
invalid_count = len(list(get_invalid_files(identifiers)))
assert invalid_count == 0, (f"{invalid_count}/{len(identifiers)} invalid log(s) found, please ensure all logs " +
"are uploaded or auto fallback to qlogs with '/a' selector at the end of the route name.")
return identifiers return identifiers
def __init__(self, identifier: str | list[str], default_mode: ReadMode = ReadMode.RLOG, def __init__(self, identifier: str | list[str], default_mode: ReadMode = ReadMode.RLOG,
source: Source = auto_source, sort_by_time=False, only_union_types=False): sources: list[Source] = None, sort_by_time=False, only_union_types=False):
if sources is None:
sources = [internal_source, openpilotci_source, comma_api_source, comma_car_segments_source]
self.default_mode = default_mode self.default_mode = default_mode
self.source = source self.sources = sources
self.identifier = identifier self.identifier = identifier
if isinstance(identifier, str): if isinstance(identifier, str):
self.identifier = [identifier] self.identifier = [identifier]

@ -10,9 +10,9 @@ from openpilot.tools.lib.auth_config import get_token
from openpilot.tools.lib.api import APIError, CommaApi from openpilot.tools.lib.api import APIError, CommaApi
from openpilot.tools.lib.helpers import RE from openpilot.tools.lib.helpers import RE
QLOG_FILENAMES = ['qlog', 'qlog.bz2', 'qlog.zst'] QLOG_FILENAMES = ['qlog.bz2', 'qlog.zst', 'qlog']
QCAMERA_FILENAMES = ['qcamera.ts'] QCAMERA_FILENAMES = ['qcamera.ts']
LOG_FILENAMES = ['rlog', 'rlog.bz2', 'raw_log.bz2', 'rlog.zst'] LOG_FILENAMES = ['rlog.bz2', 'raw_log.bz2', 'rlog.zst', 'rlog']
CAMERA_FILENAMES = ['fcamera.hevc', 'video.hevc'] CAMERA_FILENAMES = ['fcamera.hevc', 'video.hevc']
DCAMERA_FILENAMES = ['dcamera.hevc'] DCAMERA_FILENAMES = ['dcamera.hevc']
ECAMERA_FILENAMES = ['ecamera.hevc'] ECAMERA_FILENAMES = ['ecamera.hevc']
@ -241,6 +241,10 @@ class SegmentName:
@property @property
def canonical_name(self) -> str: return self._canonical_name def canonical_name(self) -> str: return self._canonical_name
#TODO should only use one name
@property
def data_name(self) -> str: return f"{self._route_name.canonical_name}/{self._num}"
@property @property
def dongle_id(self) -> str: return self._route_name.dongle_id def dongle_id(self) -> str: return self._route_name.dongle_id

@ -10,7 +10,7 @@ import requests
from parameterized import parameterized from parameterized import parameterized
from cereal import log as capnp_log from cereal import log as capnp_log
from openpilot.tools.lib.logreader import LogIterable, LogReader, comma_api_source, parse_indirect, ReadMode, InternalUnavailableException from openpilot.tools.lib.logreader import LogsUnavailable, LogIterable, LogReader, comma_api_source, parse_indirect, ReadMode, InternalUnavailableException
from openpilot.tools.lib.route import SegmentRange from openpilot.tools.lib.route import SegmentRange
from openpilot.tools.lib.url_file import URLFileException from openpilot.tools.lib.url_file import URLFileException
@ -193,17 +193,17 @@ class TestLogReader:
with subtests.test("interactive_yes"): with subtests.test("interactive_yes"):
mocker.patch("sys.stdin", new=io.StringIO("y\n")) mocker.patch("sys.stdin", new=io.StringIO("y\n"))
lr = LogReader(f"{TEST_ROUTE}/0", default_mode=ReadMode.AUTO_INTERACTIVE, source=comma_api_source) lr = LogReader(f"{TEST_ROUTE}/0", default_mode=ReadMode.AUTO_INTERACTIVE, sources=[comma_api_source])
log_len = len(list(lr)) log_len = len(list(lr))
assert qlog_len == log_len assert qlog_len == log_len
with subtests.test("interactive_no"): with subtests.test("interactive_no"):
mocker.patch("sys.stdin", new=io.StringIO("n\n")) mocker.patch("sys.stdin", new=io.StringIO("n\n"))
with pytest.raises(AssertionError): with pytest.raises(LogsUnavailable):
lr = LogReader(f"{TEST_ROUTE}/0", default_mode=ReadMode.AUTO_INTERACTIVE, source=comma_api_source) lr = LogReader(f"{TEST_ROUTE}/0", default_mode=ReadMode.AUTO_INTERACTIVE, sources=[comma_api_source])
with subtests.test("non_interactive"): with subtests.test("non_interactive"):
lr = LogReader(f"{TEST_ROUTE}/0", default_mode=ReadMode.AUTO, source=comma_api_source) lr = LogReader(f"{TEST_ROUTE}/0", default_mode=ReadMode.AUTO, sources=[comma_api_source])
log_len = len(list(lr)) log_len = len(list(lr))
assert qlog_len == log_len assert qlog_len == log_len

Loading…
Cancel
Save