#!/usr/bin/env python3 import argparse import os import pyautogui import subprocess import dearpygui.dearpygui as dpg import multiprocessing import uuid import signal from openpilot.common.basedir import BASEDIR from openpilot.tools.jotpluggler.data import DataManager from openpilot.tools.jotpluggler.views import DataTreeView from openpilot.tools.jotpluggler.layout import PlotLayoutManager DEMO_ROUTE = "a2a0ccea32023010|2023-07-27--13-01-19" class WorkerManager: def __init__(self, max_workers=None): self.pool = multiprocessing.Pool(max_workers or min(4, multiprocessing.cpu_count()), initializer=WorkerManager.worker_initializer) self.active_tasks = {} def submit_task(self, func, args_list, callback=None, task_id=None): task_id = task_id or str(uuid.uuid4()) if task_id in self.active_tasks: try: self.active_tasks[task_id].terminate() except Exception: pass def handle_success(result): self.active_tasks.pop(task_id, None) if callback: try: callback(result) except Exception as e: print(f"Callback for task {task_id} failed: {e}") def handle_error(error): self.active_tasks.pop(task_id, None) print(f"Task {task_id} failed: {error}") async_result = self.pool.starmap_async(func, args_list, callback=handle_success, error_callback=handle_error) self.active_tasks[task_id] = async_result return task_id @staticmethod def worker_initializer(): signal.signal(signal.SIGINT, signal.SIG_IGN) def shutdown(self): for task in self.active_tasks.values(): try: task.terminate() except Exception: pass self.pool.terminate() self.pool.join() class PlaybackManager: def __init__(self): self.is_playing = False self.current_time_s = 0.0 self.duration_s = 0.0 def set_route_duration(self, duration: float): self.duration_s = duration self.seek(min(self.current_time_s, duration)) def toggle_play_pause(self): if not self.is_playing and self.current_time_s >= self.duration_s: self.seek(0.0) self.is_playing = not self.is_playing def seek(self, time_s: float): self.is_playing = False self.current_time_s = max(0.0, min(time_s, self.duration_s)) def update_time(self, delta_t: float): if self.is_playing: self.current_time_s = min(self.current_time_s + delta_t, self.duration_s) if self.current_time_s >= self.duration_s: self.is_playing = False return self.current_time_s class MainController: def __init__(self, scale: float = 1.0): self.scale = scale self.data_manager = DataManager() self.playback_manager = PlaybackManager() self.worker_manager = WorkerManager() self._create_global_themes() self.data_tree_view = DataTreeView(self.data_manager, self.playback_manager) self.plot_layout_manager = PlotLayoutManager(self.data_manager, self.playback_manager, self.worker_manager, scale=self.scale) self.data_manager.add_observer(self.on_data_loaded) def _create_global_themes(self): with dpg.theme(tag="global_line_theme"): with dpg.theme_component(dpg.mvLineSeries): scaled_thickness = max(1.0, self.scale) dpg.add_theme_style(dpg.mvPlotStyleVar_LineWeight, scaled_thickness, category=dpg.mvThemeCat_Plots) with dpg.theme(tag="global_timeline_theme"): with dpg.theme_component(dpg.mvInfLineSeries): scaled_thickness = max(1.0, self.scale) dpg.add_theme_style(dpg.mvPlotStyleVar_LineWeight, scaled_thickness, category=dpg.mvThemeCat_Plots) dpg.add_theme_color(dpg.mvPlotCol_Line, (255, 0, 0, 128), category=dpg.mvThemeCat_Plots) def on_data_loaded(self, data: dict): duration = data.get('duration', 0.0) self.playback_manager.set_route_duration(duration) if data.get('loading_complete'): num_paths = len(self.data_manager.get_all_paths()) dpg.set_value("load_status", f"Loaded {num_paths} data paths") dpg.configure_item("load_button", enabled=True) elif data.get('segment_added'): segment_count = data.get('segment_count', 0) dpg.set_value("load_status", f"Loading... {segment_count} segments processed") dpg.configure_item("timeline_slider", max_value=duration) def setup_ui(self): dpg.set_viewport_resize_callback(callback=self.on_viewport_resize) with dpg.window(tag="Primary Window"): with dpg.group(horizontal=True): # Left panel - Data tree with dpg.child_window(label="Data Pool", width=300 * self.scale, tag="data_pool_window", border=True, resizable_x=True): with dpg.group(horizontal=True): dpg.add_input_text(tag="route_input", width=-75 * self.scale, hint="Enter route name...") dpg.add_button(label="Load", callback=self.load_route, tag="load_button", width=-1) dpg.add_text("Ready to load route", tag="load_status") dpg.add_separator() dpg.add_text("Available Data") dpg.add_separator() dpg.add_input_text(tag="search_input", width=-1, hint="Search fields...", callback=self.search_data) dpg.add_separator() with dpg.group(tag="data_tree_container", track_offset=True): pass # Right panel - Plots and timeline with dpg.group(): with dpg.child_window(label="Plot Window", border=True, height=-(30 + 13 * self.scale), tag="main_plot_area"): self.plot_layout_manager.create_ui("main_plot_area") with dpg.child_window(label="Timeline", border=True): with dpg.table(header_row=False, borders_innerH=False, borders_innerV=False, borders_outerH=False, borders_outerV=False): dpg.add_table_column(width_fixed=True, init_width_or_weight=int(50 * self.scale)) # Play button dpg.add_table_column(width_stretch=True) # Timeline slider dpg.add_table_column(width_fixed=True, init_width_or_weight=int(50 * self.scale)) # FPS counter with dpg.table_row(): dpg.add_button(label="Play", tag="play_pause_button", callback=self.toggle_play_pause, width=int(50 * self.scale)) dpg.add_slider_float(tag="timeline_slider", default_value=0.0, label="", width=-1, callback=self.timeline_drag) dpg.add_text("", tag="fps_counter") dpg.set_primary_window("Primary Window", True) def on_viewport_resize(self): self.plot_layout_manager.on_viewport_resize() def load_route(self): route_name = dpg.get_value("route_input").strip() if route_name: dpg.set_value("load_status", "Loading route...") dpg.configure_item("load_button", enabled=False) self.data_manager.load_route(route_name) def search_data(self): search_term = dpg.get_value("search_input") self.data_tree_view.search_data(search_term) def toggle_play_pause(self, sender): self.playback_manager.toggle_play_pause() label = "Pause" if self.playback_manager.is_playing else "Play" dpg.configure_item(sender, label=label) def timeline_drag(self, sender, app_data): self.playback_manager.seek(app_data) dpg.configure_item("play_pause_button", label="Play") def update_frame(self, font): self.data_tree_view.update_frame(font) new_time = self.playback_manager.update_time(dpg.get_delta_time()) if not dpg.is_item_active("timeline_slider"): dpg.set_value("timeline_slider", new_time) self.plot_layout_manager.update_all_panels() dpg.set_value("fps_counter", f"{dpg.get_frame_rate():.1f} FPS") def shutdown(self): self.worker_manager.shutdown() def main(route_to_load=None): dpg.create_context() # TODO: find better way of calculating display scaling try: w, h = next(tuple(map(int, l.split()[0].split('x'))) for l in subprocess.check_output(['xrandr']).decode().split('\n') if '*' in l) # actual resolution scale = pyautogui.size()[0] / w # scaled resolution except Exception: scale = 1 with dpg.font_registry(): default_font = dpg.add_font(os.path.join(BASEDIR, "selfdrive/assets/fonts/Inter-Regular.ttf"), int(13 * scale)) dpg.bind_font(default_font) viewport_width, viewport_height = int(1200 * scale), int(800 * scale) mouse_x, mouse_y = pyautogui.position() # TODO: find better way of creating the window where the user is (default dpg behavior annoying on multiple displays) dpg.create_viewport( title='JotPluggler', width=viewport_width, height=viewport_height, x_pos=mouse_x - viewport_width // 2, y_pos=mouse_y - viewport_height // 2 ) dpg.setup_dearpygui() controller = MainController(scale=scale) controller.setup_ui() if route_to_load: dpg.set_value("route_input", route_to_load) controller.load_route() dpg.show_viewport() # Main loop try: while dpg.is_dearpygui_running(): controller.update_frame(default_font) dpg.render_dearpygui_frame() finally: controller.shutdown() dpg.destroy_context() if __name__ == "__main__": parser = argparse.ArgumentParser(description="A tool for visualizing openpilot logs.") parser.add_argument("--demo", action="store_true", help="Use the demo route instead of providing one") parser.add_argument("route", nargs='?', default=None, help="Optional route name to load on startup.") args = parser.parse_args() route = DEMO_ROUTE if args.demo else args.route main(route_to_load=route)