#!/usr/bin/env python3 import argparse import os import pyautogui import subprocess import dearpygui.dearpygui as dpg import multiprocessing import uuid import signal import yaml # type: ignore from openpilot.common.swaglog import cloudlog from openpilot.common.basedir import BASEDIR from openpilot.tools.jotpluggler.data import DataManager from openpilot.tools.jotpluggler.datatree import DataTree from openpilot.tools.jotpluggler.layout import LayoutManager 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 self.num_segments = 0 self.x_axis_bounds = (0.0, 0.0) # (min_time, max_time) self.x_axis_observers = [] # callbacks for x-axis changes self._updating_x_axis = False 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 texture_tag = "pause_texture" if self.is_playing else "play_texture" dpg.configure_item("play_pause_button", texture_tag=texture_tag) def seek(self, time_s: float): 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 dpg.configure_item("play_pause_button", texture_tag="play_texture") return self.current_time_s def set_x_axis_bounds(self, min_time: float, max_time: float, source_panel=None): if self._updating_x_axis: return new_bounds = (min_time, max_time) if new_bounds == self.x_axis_bounds: return self.x_axis_bounds = new_bounds self._updating_x_axis = True # prevent recursive updates try: for callback in self.x_axis_observers: try: callback(min_time, max_time, source_panel) except Exception as e: print(f"Error in x-axis sync callback: {e}") finally: self._updating_x_axis = False def add_x_axis_observer(self, callback): if callback not in self.x_axis_observers: self.x_axis_observers.append(callback) def remove_x_axis_observer(self, callback): if callback in self.x_axis_observers: self.x_axis_observers.remove(callback) 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 = DataTree(self.data_manager, self.playback_manager) self.layout_manager = LayoutManager(self.data_manager, self.playback_manager, self.worker_manager, scale=self.scale) self.data_manager.add_observer(self.on_data_loaded) self._total_segments = 0 def _create_global_themes(self): with dpg.theme(tag="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="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) for tag, color in (("active_tab_theme", (37, 37, 38, 255)), ("inactive_tab_theme", (70, 70, 75, 255))): with dpg.theme(tag=tag): for cmp, target in ((dpg.mvChildWindow, dpg.mvThemeCol_ChildBg), (dpg.mvInputText, dpg.mvThemeCol_FrameBg), (dpg.mvImageButton, dpg.mvThemeCol_Button)): with dpg.theme_component(cmp): dpg.add_theme_color(target, color) with dpg.theme(tag="tab_bar_theme"): with dpg.theme_component(dpg.mvChildWindow): dpg.add_theme_color(dpg.mvThemeCol_ChildBg, (51, 51, 55, 255)) def on_data_loaded(self, data: dict): duration = data.get('duration', 0.0) self.playback_manager.set_route_duration(duration) if data.get('metadata_loaded'): self.playback_manager.num_segments = data.get('total_segments', 0) self._total_segments = data.get('total_segments', 0) dpg.set_value("load_status", f"Loading... 0/{self._total_segments} segments processed") elif data.get('reset'): self.playback_manager.current_time_s = 0.0 self.playback_manager.duration_s = 0.0 self.playback_manager.is_playing = False self._total_segments = 0 dpg.set_value("load_status", "Loading...") dpg.set_value("timeline_slider", 0.0) dpg.configure_item("timeline_slider", max_value=0.0) dpg.configure_item("play_pause_button", texture_tag="play_texture") dpg.configure_item("load_button", enabled=True) elif 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}/{self._total_segments} segments processed") dpg.configure_item("timeline_slider", max_value=duration) def save_layout_to_yaml(self, filepath: str): layout_dict = self.layout_manager.to_dict() with open(filepath, 'w') as f: yaml.dump(layout_dict, f, default_flow_style=False, sort_keys=False) def load_layout_from_yaml(self, filepath: str): with open(filepath) as f: layout_dict = yaml.safe_load(f) self.layout_manager.clear_and_load_from_dict(layout_dict) self.layout_manager.create_ui("main_plot_area") def save_layout_dialog(self): if dpg.does_item_exist("save_layout_dialog"): dpg.delete_item("save_layout_dialog") with dpg.file_dialog( callback=self._save_layout_callback, tag="save_layout_dialog", width=int(700 * self.scale), height=int(400 * self.scale), default_filename="layout", default_path=os.path.join(os.path.dirname(os.path.realpath(__file__)), "layouts") ): dpg.add_file_extension(".yaml") def load_layout_dialog(self): if dpg.does_item_exist("load_layout_dialog"): dpg.delete_item("load_layout_dialog") with dpg.file_dialog( callback=self._load_layout_callback, tag="load_layout_dialog", width=int(700 * self.scale), height=int(400 * self.scale), default_path=os.path.join(os.path.dirname(os.path.realpath(__file__)), "layouts") ): dpg.add_file_extension(".yaml") def _save_layout_callback(self, sender, app_data): filepath = app_data['file_path_name'] try: self.save_layout_to_yaml(filepath) dpg.set_value("load_status", f"Layout saved to {os.path.basename(filepath)}") except Exception: dpg.set_value("load_status", "Error saving layout") cloudlog.exception(f"Error saving layout to {filepath}") dpg.delete_item("save_layout_dialog") def _load_layout_callback(self, sender, app_data): filepath = app_data['file_path_name'] try: self.load_layout_from_yaml(filepath) dpg.set_value("load_status", f"Layout loaded from {os.path.basename(filepath)}") except Exception: dpg.set_value("load_status", "Error loading layout") cloudlog.exception(f"Error loading layout from {filepath}:") dpg.delete_item("load_layout_dialog") def setup_ui(self): with dpg.texture_registry(): script_dir = os.path.dirname(os.path.realpath(__file__)) for image in ["play", "pause", "x", "split_h", "split_v", "plus"]: texture = dpg.load_image(os.path.join(script_dir, "assets", f"{image}.png")) dpg.add_static_texture(width=texture[0], height=texture[1], default_value=texture[3], tag=f"{image}_texture") with dpg.window(tag="Primary Window"): with dpg.group(horizontal=True): # Left panel - Data tree with dpg.child_window(label="Sidebar", width=int(300 * self.scale), tag="sidebar_window", border=True, resizable_x=True): with dpg.group(horizontal=True): dpg.add_input_text(tag="route_input", width=int(-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() with dpg.table(header_row=False, policy=dpg.mvTable_SizingStretchProp): dpg.add_table_column(init_width_or_weight=0.5) dpg.add_table_column(init_width_or_weight=0.5) with dpg.table_row(): dpg.add_button(label="Save Layout", callback=self.save_layout_dialog, width=-1) dpg.add_button(label="Load Layout", callback=self.load_layout_dialog, width=-1) dpg.add_separator() self.data_tree.create_ui("sidebar_window") # Right panel - Plots and timeline with dpg.group(tag="right_panel"): with dpg.child_window(label="Plot Window", border=True, height=int(-(32 + 13 * self.scale)), tag="main_plot_area"): self.layout_manager.create_ui("main_plot_area") with dpg.child_window(label="Timeline", border=True): with dpg.table(header_row=False): btn_size = int(13 * self.scale) dpg.add_table_column(width_fixed=True, init_width_or_weight=(btn_size + 8)) # 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_image_button(texture_tag="play_texture", tag="play_pause_button", callback=self.toggle_play_pause, width=btn_size, height=btn_size) dpg.add_slider_float(tag="timeline_slider", default_value=0.0, label="", width=-1, callback=self.timeline_drag) dpg.add_text("", tag="fps_counter") with dpg.item_handler_registry(tag="plot_resize_handler"): dpg.add_item_resize_handler(callback=self.on_plot_resize) dpg.bind_item_handler_registry("right_panel", "plot_resize_handler") dpg.set_primary_window("Primary Window", True) def on_plot_resize(self, sender, app_data, user_data): self.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 toggle_play_pause(self, sender): self.playback_manager.toggle_play_pause() def timeline_drag(self, sender, app_data): self.playback_manager.seek(app_data) def update_frame(self, font): self.data_tree.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.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, layout_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/JetBrainsMono-Medium.ttf"), int(13 * scale * 2)) # 2x then scale for hidpi dpg.bind_font(default_font) dpg.set_global_font_scale(0.5) 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 layout_to_load: try: controller.load_layout_from_yaml(layout_to_load) print(f"Loaded layout from {layout_to_load}") except Exception as e: print(f"Failed to load layout from {layout_to_load}: {e}") cloudlog.exception(f"Error loading layout from {layout_to_load}") 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("--layout", type=str, help="Path to YAML layout file to load on startup") 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, layout_to_load=args.layout)