From a128236026e37984c4e91542f93ae89a5cd3481d Mon Sep 17 00:00:00 2001 From: Calvin Park Date: Thu, 29 May 2025 14:07:28 -0700 Subject: [PATCH] TSK Manager v0.10.0 --- .gitignore | 1 + launch_chffrplus.sh | 22 ++ tsk/__init__.py | 0 tsk/common/__init__.py | 0 tsk/common/env.py | 47 ++++ tsk/common/key_file_manager.py | 132 +++++++++ tsk/main.py | 79 ++++++ tsk/prefetch.py | 476 +++++++++++++++++++++++++++++++++ tsk/reboot_menu/__init__.py | 0 tsk/reboot_menu/actions.py | 136 ++++++++++ tsk/reboot_menu/ui.py | 72 +++++ tsk/tools_menu/__init__.py | 0 tsk/tools_menu/actions.py | 80 ++++++ tsk/tools_menu/extractor.py | 314 ++++++++++++++++++++++ tsk/tools_menu/keyboard.py | 216 +++++++++++++++ tsk/tools_menu/payload.bin | Bin 0 -> 4096 bytes tsk/tools_menu/ui.py | 65 +++++ tsk/ui/__init__.py | 0 tsk/ui/button.py | 59 ++++ tsk/ui/dialog.py | 178 ++++++++++++ tsk/ui/header.py | 119 +++++++++ tsk/ui/layout.py | 67 +++++ 22 files changed, 2063 insertions(+) create mode 100644 tsk/__init__.py create mode 100644 tsk/common/__init__.py create mode 100644 tsk/common/env.py create mode 100644 tsk/common/key_file_manager.py create mode 100755 tsk/main.py create mode 100755 tsk/prefetch.py create mode 100644 tsk/reboot_menu/__init__.py create mode 100644 tsk/reboot_menu/actions.py create mode 100644 tsk/reboot_menu/ui.py create mode 100644 tsk/tools_menu/__init__.py create mode 100644 tsk/tools_menu/actions.py create mode 100644 tsk/tools_menu/extractor.py create mode 100644 tsk/tools_menu/keyboard.py create mode 100644 tsk/tools_menu/payload.bin create mode 100644 tsk/tools_menu/ui.py create mode 100644 tsk/ui/__init__.py create mode 100644 tsk/ui/button.py create mode 100644 tsk/ui/dialog.py create mode 100644 tsk/ui/header.py create mode 100644 tsk/ui/layout.py diff --git a/.gitignore b/.gitignore index 8dc8f41d..222c03d9 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ venv/ .tags .ipynb_checkpoints .idea +*.iml .overlay_init .overlay_consistent .sconsign.dblite diff --git a/launch_chffrplus.sh b/launch_chffrplus.sh index d4689aae..584e1df6 100755 --- a/launch_chffrplus.sh +++ b/launch_chffrplus.sh @@ -77,6 +77,28 @@ function launch { # write tmux scrollback to a file tmux capture-pane -pq -S-1000 > /tmp/launch_log + ####### TSK + set -v + + # Prepare /cache/params + sudo mkdir -p /cache/params || true + sudo chown -R comma:comma /cache/params + + # Run TSKM + cd /data/openpilot + python3 tsk/prefetch.py + python3 tsk/main.py + #bash # Debug + + # Success - rm -rf /data/openpilot && mv /data/tsk-recommended /data/openpilot + # Retry - exit without doing anything + # Bail - rm -rf /data/tsk-nightly-dev && rm /data/continue.sh + + sudo reboot + exit + ####### TSK + + # This never runs # start manager cd system/manager if [ ! -f $DIR/prebuilt ]; then diff --git a/tsk/__init__.py b/tsk/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tsk/common/__init__.py b/tsk/common/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tsk/common/env.py b/tsk/common/env.py new file mode 100644 index 00000000..5e17ddf3 --- /dev/null +++ b/tsk/common/env.py @@ -0,0 +1,47 @@ +# tsk/common/env.py +import os +import time + + +RECOMMENDED_OP_USER = "commaai" +RECOMMENDED_OP_BRANCH = "nightly-dev" +RECOMMENDED_OP_DIR = "/data/tsk-recommended" +ALTERNATE_OP_USER = "sunnypilot" +ALTERNATE_OP_BRANCH = "staging-c3-new" +ALTERNATE_OP_DIR = "/data/tsk-alternate" + + +def is_agnos(): + return os.path.exists("/AGNOS") + + +def is_calvins_c3x() -> bool: + try: + with open("/persist/comma/dongle_id") as f: + content = f.read() + if "2decf199" in content: + return True + + except: + pass + + return False + + +def is_cache_dir_new() -> bool: + try: + cache_dir = "/cache/params" + mod_time = os.path.getmtime(cache_dir) + age = time.time() - mod_time + day = 60 * 60 * 24 + + return age < day + + except: + pass + + return False + + +def is_in_car() -> bool: + return False diff --git a/tsk/common/key_file_manager.py b/tsk/common/key_file_manager.py new file mode 100644 index 00000000..04a92317 --- /dev/null +++ b/tsk/common/key_file_manager.py @@ -0,0 +1,132 @@ +# tsk/common/key_file_manager.py + +import os +import re +import threading +import time + +from tsk.common.env import is_agnos +from tsk.ui.layout import Theme + + +class KeyFileManager: + + DATA_PARAMS_D_SECOCKEY_PATH = "/data/params/d/SecOCKey" + CACHE_PARAMS_SECOCKEY_PATH = "/cache/params/SecOCKey" + HOME_SECOCKEY_PATH = os.path.expanduser("~/SecOCKey") + + def __init__(self): + self.installed_key = KeyFileManager._read_key_from_files() # Initialize installed_key + threading.Thread(target=self._update_key_status_loop, daemon=True).start() + + @staticmethod + def _is_key_valid(key: str) -> bool: + """Checks if the key is a valid 32-character lowercase hexadecimal string.""" + if not isinstance(key, str): + return False + + if len(key) != 32: + return False + + pattern = r"^[0-9a-f]{32}$" + return bool(re.match(pattern, key)) + + @staticmethod + def _read_key_from_file(file_path: str) -> str | None: + """ + Reads and validates a key from the given file path. + If the key is invalid, the file is deleted. + + Returns: + The key if it's valid, None otherwise. + """ + if not os.path.exists(file_path): + return None + + try: + with open(file_path, "r") as f: + key = f.read().strip() + if KeyFileManager._is_key_valid(key): + return key + else: + # Key is invalid, delete the file + try: + os.remove(file_path) + print(f"Deleted invalid key file: {file_path} which contained {key}") + except Exception as e: + print(f"Error deleting invalid key file {file_path}: {e}") + return None + except Exception as e: + print(f"Error reading key file {file_path}: {e}") + return None # Return None on any error + + @staticmethod + def _read_key_from_files() -> str | None: + """Reads the key from the appropriate file(s) based on the AGNOS environment.""" + if not is_agnos(): + return KeyFileManager._read_key_from_file(KeyFileManager.HOME_SECOCKEY_PATH) + + data_params_d_secockey = KeyFileManager._read_key_from_file(KeyFileManager.DATA_PARAMS_D_SECOCKEY_PATH) + cache_params_secockey = KeyFileManager._read_key_from_file(KeyFileManager.CACHE_PARAMS_SECOCKEY_PATH) + + existing_key = cache_params_secockey or data_params_d_secockey + + if not existing_key: + return None + + # Write the existing key to missing files + if data_params_d_secockey != existing_key: + KeyFileManager._write_key_to_file(KeyFileManager.DATA_PARAMS_D_SECOCKEY_PATH, existing_key) + if cache_params_secockey != existing_key: + KeyFileManager._write_key_to_file(KeyFileManager.CACHE_PARAMS_SECOCKEY_PATH, existing_key) + + return existing_key + + @staticmethod + def _write_key_to_file(file_path: str, key: str) -> None: + """Writes the key to the specified file path.""" + print(f"Writing key to file: {key} {file_path}") + try: + with open(file_path, "w") as f: + f.write(key) + except Exception as e: + print(f"Error writing key to file {file_path}: {e}") + + def _update_key_status_loop(self) -> None: + """Periodically updates the key status.""" + while True: + self.installed_key = KeyFileManager._read_key_from_files() + time.sleep(Theme.status_update_interval) # Check every x second + + def install_key(self, key: str) -> None: + """Installs the key by writing it to the appropriate file(s) based on the AGNOS environment.""" + if not KeyFileManager._is_key_valid(key): + print("Invalid key format. Installation aborted.") + return + + if not is_agnos(): + KeyFileManager._write_key_to_file(KeyFileManager.HOME_SECOCKEY_PATH, key) + KeyFileManager._installed_key = KeyFileManager._read_key_from_files() + return + + KeyFileManager._write_key_to_file(KeyFileManager.DATA_PARAMS_D_SECOCKEY_PATH, key) + KeyFileManager._write_key_to_file(KeyFileManager.CACHE_PARAMS_SECOCKEY_PATH, key) + self.installed_key = KeyFileManager._read_key_from_files() + + def uninstall_key(self) -> None: + """Deletes the key from the appropriate file(s) based on the AGNOS environment.""" + def _delete_file(file_path: str): + if os.path.exists(file_path): + try: + os.remove(file_path) + print(f"Deleted key file: {file_path}") + except Exception as e: + print(f"Error deleting key file {file_path}: {e}") + + if not is_agnos(): + _delete_file(KeyFileManager.HOME_SECOCKEY_PATH) + + else: + _delete_file(KeyFileManager.DATA_PARAMS_D_SECOCKEY_PATH) + _delete_file(KeyFileManager.CACHE_PARAMS_SECOCKEY_PATH) + self.installed_key = KeyFileManager._read_key_from_files() diff --git a/tsk/main.py b/tsk/main.py new file mode 100755 index 00000000..643c48bc --- /dev/null +++ b/tsk/main.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +# tsk/main.py +import sys + +import pyray as rl + +from openpilot.system.ui.lib.application import gui_app +from tsk.common.env import is_calvins_c3x +from tsk.reboot_menu.ui import RebootMenuUI +from tsk.tools_menu.ui import ToolsMenuUI +from tsk.ui.header import Header +from tsk.ui.layout import Theme + + +class TSKManager: + """Manages the TSK Manager UI, switching between menus.""" + + def __init__(self): + """Initializes the TSK Manager.""" + self.current_menu = Theme.menu_tools + self.header = Header() + self.tools_menu = ToolsMenuUI() + self.reboot_menu = RebootMenuUI() + + def render(self, rect: rl.Rectangle): + """Renders the current menu within the given rectangle.""" + + new_menu = self.header.render(rect, self.current_menu) + if new_menu is not None: + self.current_menu = new_menu + + menu_rect = rl.Rectangle(rect.x, rect.y + self.header._calculate_height(), rect.width, + rect.height - self.header._calculate_height()) + + if self.current_menu == Theme.menu_tools: + self.tools_menu.render(menu_rect) + elif self.current_menu == Theme.menu_reboot: + self.reboot_menu.render(menu_rect) + return True + + +def setup_environment(): + """Performs initial environment setup, such as enabling SSH.""" + if is_calvins_c3x(): + with open("/data/params/d/GithubUsername", "w") as f: + f.write("calvinpark") + with open("/data/params/d/GithubSshKeys", "w") as f: + f.write("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQD30Dz8yY3n1DchzsPbuuWMXMBtyeW2Yh5aOjrjLSvUBjqs9OoPrPfOMAPiaKqE6EfEcjV90He9A6q7OywTy5kTD6JsjjoULJKHiGbDdQlclXE2fO/wTnmxPO9yjdDJqiFrPsSGbT/4R78TVUUkEwD+6DcDGtJd7hHQ/GQCWn78kZ/UsZqcukGjhuwI98gOnIOmX3ui2W6/2NrP3IH7GJWnIvDIHafHYwnRkNU7WQ5zyiUw2GX65dTrXt0pDpX/nYp0qjwORf91DTZCg6fimdUo2WAmhYXnQb66IKESpNVfIVA8L0PRNkSepc3RARX0bPgqYGj6TLy9s87UT11mq/ASuIo9IVYWt6okYvloQcwrX6uxKsGutXouXDraxP648s1ErM6BC3tOOagay19cZdQl53k0CZbkIXODlpM/QaW7MdagH7PVzlGGIuHohDAe3M/ltJjRmRfdj89cCGusBlFB5RuLZpzYskp353NZ1qxhL086Mfyg0bBdDK+CGLJ7bY0=\n" + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILXx7npi7/QYSOu2Z0Bhldtey4L2nxEyZKYQY/BIHdak") + with open("/data/params/d/SshEnabled", "w") as f: + f.write("1") + with open("/data/params/d/HasAcceptedTerms", "w") as f: + f.write("2") + with open("/data/params/d/CompletedTrainingVersion", "w") as f: + f.write("0.2.0") + + +def main(): + """Main function to initialize and run the TSK Manager.""" + + setup_environment() + + gui_app.init_window("TSK Manager") + tskm = TSKManager() + + while not rl.window_should_close(): + rl.begin_drawing() + rl.clear_background(rl.BLACK) + + tskm.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) + + rl.end_drawing() + + rl.close_window() + sys.exit(0) # Necessary for macOS + + +if __name__ == "__main__": + main() diff --git a/tsk/prefetch.py b/tsk/prefetch.py new file mode 100755 index 00000000..d92d18d7 --- /dev/null +++ b/tsk/prefetch.py @@ -0,0 +1,476 @@ +#!/usr/bin/env python3 +# tsk/prefetch.py +""" +Prefetch Script for TSK Manager + +This script runs before the main TSK Manager to prefetch necessary repositories. +It creates a GUI window with progress bars to track git clone operations for two repositories: +1. The recommended openpilot repository +2. The alternate openpilot repository + +Features: +- Visual progress tracking with progress bars +- Directory cleanup before cloning +- Per-operation retry mechanism (up to 5 retries per operation) +- Automatic exit when operations complete or max retries reached +""" + +# Standard library imports +import os +import re +import shutil +import subprocess +import sys +import threading +import time +from typing import List + +# Third-party imports +import pyray as rl + +# Local imports +from openpilot.system.ui.lib.application import gui_app +from tsk.common.env import ( + RECOMMENDED_OP_USER, + RECOMMENDED_OP_BRANCH, + RECOMMENDED_OP_DIR, + ALTERNATE_OP_USER, + ALTERNATE_OP_BRANCH, + ALTERNATE_OP_DIR +) + +# ------------------------------------------------------------------------- +# Configuration Constants +# ------------------------------------------------------------------------- + +# Retry settings +MAX_RETRIES = 10 # Maximum number of retry attempts per operation +RETRY_DELAY = 10 # Seconds to wait between retry attempts + +# ------------------------------------------------------------------------- +# Git Clone Progress Tracker +# ------------------------------------------------------------------------- + +class GitCloneProgress: + """ + Tracks the progress of a git clone operation. + + This class handles running a git clone command in a separate thread, + parsing its output to track progress, and managing retries if the + operation fails. + + Each instance represents one git clone operation with its own progress + tracking and retry mechanism. + """ + + def __init__(self, command: List[str], title: str, target_dir: str = None): + """ + Initialize a git clone progress tracker. + + Args: + command: The git clone command as a list of strings. + title: The title to display for this clone operation. + target_dir: The target directory to delete before cloning (if provided). + """ + # Command and identification + self.command = command + self.title = title + self.target_dir = target_dir + + # Progress and status tracking + self.progress = 0 + self.status = "Initializing..." + self.completed = False + self.failed = False + + # Process and thread management + self.process = None + self.thread = None + + # Retry mechanism + self.retry_count = 0 + self.retry_needed = False + self.retry_timer = 0 + + def start(self): + """ + Start the git clone process in a separate thread. + + This allows the GUI to remain responsive while the clone operation + runs in the background. + """ + self.thread = threading.Thread(target=self._run_process) + self.thread.daemon = True # Thread will exit when main program exits + self.thread.start() + + def _run_process(self): + """ + Run the git clone process and update progress. + + This method: + 1. Deletes the target directory if it exists + 2. Starts the git clone process + 3. Parses output to track progress + 4. Updates status based on completion or failure + """ + try: + # Step 1: Delete target directory if it exists + if self.target_dir and os.path.exists(self.target_dir): + self.status = f"Deleting existing directory: {self.target_dir}" + try: + shutil.rmtree(self.target_dir) + self.status = f"Deleted directory: {self.target_dir}" + except Exception as e: + # If directory deletion fails, mark as failed and prepare for retry + self.status = f"Error deleting directory: {str(e)}" + self.failed = True + self.retry_needed = True + self.retry_timer = time.time() + return + + # Step 2: Start the git clone process + self.status = "Starting clone operation..." + self.process = subprocess.Popen( + self.command, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + universal_newlines=True, + bufsize=1 + ) + + # Step 3: Parse output to track progress + for line in self.process.stdout: + self._parse_progress(line) + + # Step 4: Check process completion status + self.process.wait() + if self.process.returncode == 0: + # Success case + self.progress = 100 + self.status = "Done" + self.completed = True + else: + # Failure case - prepare for retry + self.status = f"Failed with code {self.process.returncode}" + self.failed = True + self.retry_needed = True + self.retry_timer = time.time() + except Exception as e: + # Handle any unexpected exceptions + self.status = f"Error: {str(e)}" + self.failed = True + self.retry_needed = True + self.retry_timer = time.time() + + def _parse_progress(self, line: str): + """ + Parse git output to extract progress information. + + Git clone outputs progress in two main phases: + 1. "Receiving objects: x%" - Maps to 0-90% of our progress bar + 2. "Resolving deltas: x%" - Maps to 90-100% of our progress bar + + Args: + line: A line of output from the git clone process + """ + # Phase 1: Look for "Receiving objects: x%" pattern + receiving_match = re.search(r'Receiving objects:\s+(\d+)%', line) + if receiving_match: + # Direct mapping for receiving objects phase + self.progress = int(receiving_match.group(1)) + self.status = line.strip() + return + + # Phase 2: Look for "Resolving deltas: x%" pattern + resolving_match = re.search(r'Resolving deltas:\s+(\d+)%', line) + if resolving_match: + # Map resolving deltas from 0-100% to 90-100% of overall progress + delta_progress = int(resolving_match.group(1)) + adjusted_progress = 90 + (delta_progress / 10) + # Keep progress at least at current level (never go backwards) + self.progress = max(self.progress, adjusted_progress) + self.status = line.strip() + return + + # Update status with current operation for any other informative lines + if line.strip(): + self.status = line.strip() + + def reset(self): + """ + Reset the progress tracker for a retry. + + This prepares the tracker for a fresh attempt after a failure. + """ + self.progress = 0 + self.status = "Initializing retry..." + self.completed = False + self.failed = False + self.process = None + self.thread = None + self.retry_needed = False + + def check_retry(self): + """ + Check if it's time to retry and handle the retry if needed. + + This method: + 1. Checks if a retry is needed and possible + 2. Waits for the retry delay to elapse + 3. Increments retry count and starts a new attempt + + Returns: + bool: True if a retry was initiated, False otherwise + """ + if self.failed and self.retry_needed and self.retry_count < MAX_RETRIES: + # Check if retry delay has elapsed + if time.time() - self.retry_timer >= RETRY_DELAY: + self.retry_count += 1 + self.reset() + self.start() + return True + return False + +# ------------------------------------------------------------------------- +# Main Application +# ------------------------------------------------------------------------- + +class PrefetchApp: + """ + GUI application to track git clone operations. + + This class creates a window with progress bars to visualize the status + of git clone operations. It handles: + 1. Setting up and starting clone operations + 2. Rendering the UI with progress bars and status text + 3. Managing the application lifecycle + 4. Coordinating retries for failed operations + """ + + # UI Constants + PROGRESS_BAR_HEIGHT = 100 + PROGRESS_BAR_WIDTH = 2000 + PADDING = 20 + FONT_SIZE = 80 + TITLE_FONT_SIZE = 120 + BAR_BG_COLOR = rl.Color(40, 40, 40, 255) # Dark gray for progress bar background + BAR_FG_COLOR = rl.Color(54, 77, 239, 255) # Blue for progress bar foreground + WHITE_TEXT_COLOR = rl.Color(255, 255, 255, 255) # White for most text + GRAY_TEXT_COLOR = rl.Color(100, 100, 100, 255) # Dimmer gray for status text + + def __init__(self): + """ + Initialize the prefetch application. + + Sets up the clone operations but doesn't start them yet. + """ + self.initialize_operations() + + def initialize_operations(self): + """ + Initialize or reset the clone operations. + + Creates GitCloneProgress instances for each repository we need to clone, + but only if the target directories don't already exist. This handles all + three possibilities gracefully: none, one, or both directories may exist. + """ + self.clone_operations = [] + + # Check if the recommended openpilot directory exists + recommended_exists = os.path.exists(RECOMMENDED_OP_DIR) + # Check if the alternate openpilot directory exists + alternate_exists = os.path.exists(ALTERNATE_OP_DIR) + + # Only create operation for recommended repository if directory doesn't exist + if not recommended_exists: + self.clone_operations.append( + GitCloneProgress( + ["/usr/bin/git", "clone", "--progress", + f"https://github.com/{RECOMMENDED_OP_USER}/openpilot.git", + "-b", RECOMMENDED_OP_BRANCH, "--depth=1", + "--recurse-submodules", RECOMMENDED_OP_DIR], + f"{RECOMMENDED_OP_USER}/{RECOMMENDED_OP_BRANCH}", + RECOMMENDED_OP_DIR # Target directory to delete before cloning + ) + ) + + # Only create operation for alternate repository if directory doesn't exist + if not alternate_exists: + self.clone_operations.append( + GitCloneProgress( + ["/usr/bin/git", "clone", "--progress", + f"https://github.com/{ALTERNATE_OP_USER}/openpilot.git", + "-b", ALTERNATE_OP_BRANCH, "--depth=1", + "--recurse-submodules", ALTERNATE_OP_DIR], + f"{ALTERNATE_OP_USER}/{ALTERNATE_OP_BRANCH}", + ALTERNATE_OP_DIR # Target directory to delete before cloning + ) + ) + + def run(self): + """ + Run the prefetch application with retry mechanism. + + This method: + 1. Initializes the window + 2. Starts clone operations + 3. Runs the main loop until completion or window close + 4. Handles cleanup and exit + """ + # Step 1: Initialize window using gui_app (consistent with main.py) + gui_app.init_window("TSK Prefetch") + + # Step 2: Start clone operations + self.start_operations() + + # Step 3: Main loop + while not rl.window_should_close(): + # Check for retries in each operation + for op in self.clone_operations: + op.check_retry() + + # Check if all operations are complete or have reached max retries + all_done = True + for op in self.clone_operations: + if not op.completed and (not op.failed or op.retry_count < MAX_RETRIES): + all_done = False + break + + # Exit condition - all operations are either complete or have reached max retries + if all_done: + time.sleep(1) # Show final state briefly + break + + # Render the current frame + self._render_frame() + + # Step 4: Cleanup and exit + rl.close_window() + + def start_operations(self): + """ + Start all clone operations. + + Initiates the background threads for all git clone operations. + """ + for op in self.clone_operations: + op.start() + + def _render_frame(self): + """ + Render a single frame of the application. + + This method: + 1. Clears the background + 2. Draws the title + 3. For each operation: + a. Draws the operation title with retry info + b. Draws the progress bar + c. Draws the progress percentage + d. Draws the status text with retry countdown if applicable + """ + # Step 1: Begin drawing and clear background + rl.begin_drawing() + rl.clear_background(rl.Color(0, 0, 0, 255)) # Black background + + # Step 2: Draw title + title = "Prefetching" + title_width = rl.measure_text_ex(gui_app.font(), title, self.TITLE_FONT_SIZE, 0).x + rl.draw_text_ex( + gui_app.font(), + title, + rl.Vector2((gui_app.width - title_width) // 2, self.PADDING), + self.TITLE_FONT_SIZE, + 0, + self.WHITE_TEXT_COLOR + ) + + # Step 3: Draw progress bars for each operation + y_offset = self.PADDING * 3 + self.TITLE_FONT_SIZE * 2 + + for i, op in enumerate(self.clone_operations): + # Step 3a: Draw operation title with retry count if applicable + title_text = op.title + if op.retry_count > 0: + title_text += f" (Retry {op.retry_count}/{MAX_RETRIES})" + + rl.draw_text_ex( + gui_app.font(), + title_text, + rl.Vector2(self.PADDING, y_offset), + self.FONT_SIZE, + 0, + self.WHITE_TEXT_COLOR + ) + y_offset += self.FONT_SIZE + self.PADDING + + # Step 3b: Draw progress bar background + bar_x = (gui_app.width - self.PROGRESS_BAR_WIDTH) // 2 + bar_rect = rl.Rectangle(bar_x, y_offset, self.PROGRESS_BAR_WIDTH, self.PROGRESS_BAR_HEIGHT) + rl.draw_rectangle_rec(bar_rect, self.BAR_BG_COLOR) + + # Step 3c: Draw progress bar foreground + progress_width = (op.progress / 100.0) * self.PROGRESS_BAR_WIDTH + progress_rect = rl.Rectangle(bar_x, y_offset, progress_width, self.PROGRESS_BAR_HEIGHT) + rl.draw_rectangle_rec(progress_rect, self.BAR_FG_COLOR) + + # Step 3d: Draw progress percentage + progress_text = f"{op.progress}%" + text_width = rl.measure_text_ex(gui_app.font(), progress_text, self.FONT_SIZE, 0).x + rl.draw_text_ex( + gui_app.font(), + progress_text, + rl.Vector2( + bar_x + (self.PROGRESS_BAR_WIDTH - text_width) // 2, + y_offset + (self.PROGRESS_BAR_HEIGHT - self.FONT_SIZE) // 2 + ), + self.FONT_SIZE, + 0, + self.WHITE_TEXT_COLOR + ) + + # Step 3e: Draw status text with retry information if applicable + status_y = y_offset + self.PROGRESS_BAR_HEIGHT + self.PADDING + status_text = op.status + + # Add retry countdown or max retries reached message if applicable + if op.failed and op.retry_needed and op.retry_count < MAX_RETRIES: + countdown = max(0, int(RETRY_DELAY - (time.time() - op.retry_timer))) + status_text += f" - Retrying in {countdown}s..." + elif op.failed and op.retry_count >= MAX_RETRIES: + status_text += f" - Max retries reached" + + rl.draw_text_ex( + gui_app.font(), + status_text, + rl.Vector2(self.PADDING, status_y), + self.FONT_SIZE, + 0, + self.GRAY_TEXT_COLOR + ) + + # Update y_offset for next operation + y_offset += self.PROGRESS_BAR_HEIGHT + self.PADDING * 2 + self.FONT_SIZE * 2 + + # End drawing + rl.end_drawing() + +# ------------------------------------------------------------------------- +# Main Entry Point +# ------------------------------------------------------------------------- + +def main(): + """ + Main function to run the prefetch application. + + This is the entry point when the script is executed directly. + """ + app = PrefetchApp() + app.run() + + # Exit with success code + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/tsk/reboot_menu/__init__.py b/tsk/reboot_menu/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tsk/reboot_menu/actions.py b/tsk/reboot_menu/actions.py new file mode 100644 index 00000000..b5757548 --- /dev/null +++ b/tsk/reboot_menu/actions.py @@ -0,0 +1,136 @@ +# tsk/reboot_menu/actions.py +import os +import shutil +import sys # Import the sys module + +from tsk.common.env import is_agnos, RECOMMENDED_OP_USER, RECOMMENDED_OP_BRANCH, RECOMMENDED_OP_DIR, ALTERNATE_OP_USER, \ + ALTERNATE_OP_BRANCH, ALTERNATE_OP_DIR +from tsk.common.key_file_manager import KeyFileManager +from tsk.ui.dialog import YesNoDialog + + +class Rebooter: + # Actions based on launch_chffrplus.sh + # Reboot is handled in that sh + + CONTINUE_FILE = "/data/continue.sh" + OPENPILOT_DIR = "/data/openpilot" + + def __init__(self): + self.is_agnos: bool = is_agnos() + + def recommended_action(self): + print("Recommended button pressed") + key = KeyFileManager().installed_key + if key: + question = f"Key installed: {key}\n\n" + else: + question = "!!!! Key not installed.\n" \ + "!!!! Comma can't drive your car.\n\n" + question += f"Reboot and install {RECOMMENDED_OP_USER}/{RECOMMENDED_OP_BRANCH}?" + should_reboot = YesNoDialog.ask(question) + if not should_reboot: + print("Action cancelled") + return + print("Action confirmed") + + # Remove /data/openpilot + if self.is_agnos: + shutil.rmtree(self.OPENPILOT_DIR, ignore_errors=True) + print(f"Removed {self.OPENPILOT_DIR}") + + # Remove /data/tsk-alternate + if self.is_agnos: + shutil.rmtree(ALTERNATE_OP_DIR, ignore_errors=True) + print(f"Removed {ALTERNATE_OP_DIR}") + + # Move /data/tsk-recommended to /data/openpilot + if self.is_agnos: + shutil.move(RECOMMENDED_OP_DIR, self.OPENPILOT_DIR) + print(f"Moved {RECOMMENDED_OP_DIR} to {self.OPENPILOT_DIR}") + + sys.exit(0) + + def alternate_action(self): + print("Alternate button pressed") + key = KeyFileManager().installed_key + if key: + question = f"Key installed: {key}\n\n" + else: + question = "!!!! Key not installed.\n" \ + "!!!! Comma can't drive your car.\n\n" + question += f"Reboot and install {ALTERNATE_OP_USER}/{ALTERNATE_OP_BRANCH}?" + should_reboot = YesNoDialog.ask(question) + if not should_reboot: + print("Action cancelled") + return + print("Action confirmed") + + # Remove /data/openpilot + if self.is_agnos: + shutil.rmtree(self.OPENPILOT_DIR, ignore_errors=True) + print(f"Removed {self.OPENPILOT_DIR}") + + # Remove /data/tsk-recommended + if self.is_agnos: + shutil.rmtree(RECOMMENDED_OP_DIR, ignore_errors=True) + print(f"Removed {RECOMMENDED_OP_DIR}") + + # Move /data/tsk-alternate to /data/openpilot + if self.is_agnos: + shutil.move(ALTERNATE_OP_DIR, self.OPENPILOT_DIR) + print(f"Moved {ALTERNATE_OP_DIR} to {self.OPENPILOT_DIR}") + + sys.exit(0) + + def bail_action(self): + print("Bail button pressed") + key = KeyFileManager().installed_key + if key: + question = f"Key installed: {key}\n\n" + else: + question = "!!!! Key not installed.\n" \ + "!!!! Comma can't drive your car.\n\n" + question += "Reboot and install a different fork/branch?" + should_reboot = YesNoDialog.ask(question) + if not should_reboot: + print("Action cancelled") + return + print("Action confirmed") + + # Remove /data/tsk-recommended since it won't be used + if self.is_agnos: + shutil.rmtree(RECOMMENDED_OP_DIR, ignore_errors=True) + print(f"Removed {RECOMMENDED_OP_DIR}") + + # Remove /data/tsk-alternate since it won't be used + if self.is_agnos: + shutil.rmtree(ALTERNATE_OP_DIR, ignore_errors=True) + print(f"Removed {ALTERNATE_OP_DIR}") + + # /data/openpilot is deleted by the installer + + # Delete /data/continue.sh to trigger an installer without a reset + if self.is_agnos: + if os.path.exists(self.CONTINUE_FILE): + os.remove(self.CONTINUE_FILE) + print(f"Removed {self.CONTINUE_FILE}") + + sys.exit(0) + + def retry_action(self): + print("Retry button pressed") + key = KeyFileManager().installed_key + if key: + question = f"Key installed: {key}\n\n" + else: + question = "!!!! Key not installed.\n\n" + question += "Reboot without changing anything?" + should_reboot = YesNoDialog.ask(question) + if not should_reboot: + print("Action cancelled") + return + print("Action confirmed") + + # Do nothing + sys.exit(0) diff --git a/tsk/reboot_menu/ui.py b/tsk/reboot_menu/ui.py new file mode 100644 index 00000000..049b69dc --- /dev/null +++ b/tsk/reboot_menu/ui.py @@ -0,0 +1,72 @@ +# tsk/reboot_menu/ui.py +import pyray as rl +from openpilot.system.ui.lib.application import gui_app + +from tsk.common.env import RECOMMENDED_OP_USER, RECOMMENDED_OP_BRANCH, ALTERNATE_OP_USER, ALTERNATE_OP_BRANCH +from tsk.reboot_menu.actions import Rebooter # Import the class +from tsk.ui.button import Button +from tsk.ui.header import Header +from tsk.ui.layout import Layout, Theme # Import Theme + + +class RebootMenuUI: + """Manages the Restart Menu and its state.""" + + def __init__(self): + self.rebooter = Rebooter() # Create an instance here + + self.header = Header() + header_height = self.header._calculate_height() + key_status_height = Theme.key_status_font_size * 1.5 # Approximate height + combined_header_height = header_height + key_status_height + + rect = rl.Rectangle(0, 0, gui_app.width, gui_app.height) # Dummy rectangle for initial calculations + # Calculate button height using the same function as in tools.py + button_height = Layout.calculate_button_dimensions(rect.height, combined_header_height) + + # Calculate start_y to position the buttons correctly + start_x, start_y = Layout.calculate_button_positions(rect, combined_header_height, 3) + + # Button positions + button1_x = start_x + button2_x = start_x + 600 + 80 + button3_x = start_x + 2 * (600 + 80) + + button_y = start_y + + self.recommended_button = Button(self.rebooter.recommended_action, [ # Call the method on the instance + {"text": "Install", "x_offset": 70, "y_offset": 40}, + {"text": f"{RECOMMENDED_OP_USER}/", "x_offset": 70, "y_offset": 140}, + {"text": f"{RECOMMENDED_OP_BRANCH}", "x_offset": 70, "y_offset": 240}, + ], 600, button_height, button1_x, button_y, 90) + + self.alternate_button = Button(self.rebooter.alternate_action, [ # Call the method on the instance + {"text": "Install", "x_offset": 70, "y_offset": 40}, + {"text": f"{ALTERNATE_OP_USER}/", "x_offset": 70, "y_offset": 140}, + {"text": f"{ALTERNATE_OP_BRANCH}", "x_offset": 70, "y_offset": 240}, + ], 600, button_height, button2_x, button_y, 90) + + self.bail_button = Button(self.rebooter.bail_action, [ # Call the method on the instance + {"text": "Install a", "x_offset": 70, "y_offset": 40}, + {"text": "different", "x_offset": 70, "y_offset": 140}, + {"text": "fork/branch", "x_offset": 70, "y_offset": 240}, + ], 600, button_height, button3_x, button_y, 90) + + # Add the "Retry" button + retry_button_width = 3 * 600 + 2 * 80 + retry_button_x = (rect.width - retry_button_width) / 2 + rect.x + retry_button_y = button_y + button_height + 80 + + self.retry_button = Button(self.rebooter.retry_action, [ # Call the method on the instance + {"text": "Reboot to try again", + "x_offset": (retry_button_width - rl.measure_text_ex(gui_app.font(), "Reboot to try again", 90, 1.0).x) / 2, + "y_offset": 60}, + ], retry_button_width, 200, retry_button_x, retry_button_y, 90) + + def render(self, rect: rl.Rectangle): + """Renders the Restart Menu.""" + + self.recommended_button.render() + self.alternate_button.render() + self.bail_button.render() + self.retry_button.render() diff --git a/tsk/tools_menu/__init__.py b/tsk/tools_menu/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tsk/tools_menu/actions.py b/tsk/tools_menu/actions.py new file mode 100644 index 00000000..d6cbc838 --- /dev/null +++ b/tsk/tools_menu/actions.py @@ -0,0 +1,80 @@ +# tsk/tools_menu/actions.py +import traceback + +from tsk.common.env import RECOMMENDED_OP_BRANCH, RECOMMENDED_OP_USER +from tsk.common.env import is_cache_dir_new, is_in_car +from tsk.common.key_file_manager import KeyFileManager +from tsk.tools_menu.extractor import NotAGNOSError, BoarddNotRunningError, RetryError, TSKExtractor +from tsk.ui.dialog import OkayDialog +from tsk.ui.dialog import YesNoDialog + + +def tsk_extractor_action(): + """Action to perform when the TSK Extractor button is pressed.""" + print("TSK Extractor button pressed") + + try: + secoc_key = TSKExtractor.hack() + key_manager = KeyFileManager() + key_manager.install_key(secoc_key) + message = "Success!\n\n" + message += "This is your key:\n" + message += secoc_key + "\n\n" + message += "Take a photo of this screen." + OkayDialog.ask(message, 120) + except NotAGNOSError as e: + message = str(e) + OkayDialog.ask(message, 120) + except (BoarddNotRunningError, RetryError) as e: + message = f"Can't talk to the car: {e}" + OkayDialog.ask(message, 120, True) + except Exception as e: + e.add_note("\n!!!! Unexpected error. Please take a photo, post it on #toyota-security, and ping @calvinspark\n") + message = traceback.format_exc() + OkayDialog.ask(message, 50, True) + + +def tsk_guide_action(): + """Action to perform when the 'Tell me what to do next' button is pressed.""" + text = "" + if KeyFileManager().installed_key: + text += "Security key is installed.\n\n" + text += "If you are selling your device, run TSK Uninstaller.\n\n" + text += f"Otherwise, go to the Reboot Menu and install {RECOMMENDED_OP_USER}/{RECOMMENDED_OP_BRANCH}." + + else: + if is_cache_dir_new(): + text += "Congratulations on your new comma!\n\n" + else: + text += "Security key is not installed.\n\n" + + text += "If you know your key, run TSK Keyboard to install it.\n\n" + text += "Otherwise, " + if not is_in_car(): + text += "go to your car and " + text += "run TSK Extractor." + + OkayDialog.ask(text) + + +def tsk_uninstaller_action(): + print("TSK Uninstaller button pressed") + should_delete = False + + key_manager = KeyFileManager() + key = key_manager.installed_key + if key: + question = f"Key installed: {key}\n\n" \ + "Uninstall?" + should_delete = YesNoDialog.ask(question) + else: + nope = "Key not installed.\n\n" \ + "Nothing to do." + OkayDialog.ask(nope) + + if not should_delete: + print("Not deleting keys") + return + + key_manager.uninstall_key() + diff --git a/tsk/tools_menu/extractor.py b/tsk/tools_menu/extractor.py new file mode 100644 index 00000000..567c6559 --- /dev/null +++ b/tsk/tools_menu/extractor.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 +import struct +import time +from subprocess import check_output, CalledProcessError + +from Crypto.Cipher import AES +from tqdm import tqdm + +from opendbc.car.isotp import isotp_send +from opendbc.car.structs import CarParams +from opendbc.car.uds import UdsClient, ACCESS_TYPE, SESSION_TYPE, DATA_IDENTIFIER_TYPE, SERVICE_TYPE, \ + ROUTINE_CONTROL_TYPE, InvalidServiceIdError, MessageTimeoutError, NegativeResponseError +from panda import Panda +from tsk.common.env import is_agnos + + +class NotAGNOSError(Exception): + def __str__(self) -> str: + return "Can't run TSK Extractor outside of a comma device." + + +class BoarddNotRunningError(Exception): + pass + + +class RetryError(Exception): + def __init__(self, message: str): + self.message: str = message + + def __str__(self) -> str: + return f"{self.message}\n\nTry again. If the problem persists, turn off the car, put it back into 'Not Ready to Drive' mode, and then try again." + + +def format_version_for_error_display(version1, version2=None, length=8): + version_str = "" + + version1_str = str(version1) + if version1_str.startswith("b'"): + version1_str = version1_str[2:] + + version_str = version1_str[:length] + + if version2 and version1 != version2: + version2_str = str(version2) + if version2_str.startswith("b'"): + version2_str = version2_str[2:] + + version_str += ", " + version2_str[:length] + + return version_str + + +class TSKExtractor: + ADDR = 0x7a1 + DEBUG = False + BUS = 0 + + SEED_KEY_SECRET = b'\xf0\x5f\x36\xb7\xd7\x8c\x03\xe2\x4a\xb4\xfa\xef\x2a\x57\xd0\x44' + + # These are the key and IV used to encrypt the payload in build_payload.py + DID_201_KEY = b'\x00' * 16 + DID_202_IV = b'\x00' * 16 + + # Confirmed working on the following versions + APPLICATION_VERSIONS = { + b'\x018965B4209000\x00\x00\x00\x00': b'\x01!!!!!!!!!!!!!!!!', # 2021 RAV4 Prime + b'\x018965B4233100\x00\x00\x00\x00': b'\x01!!!!!!!!!!!!!!!!', # 2023 RAV4 Prime + b'\x018965B4509100\x00\x00\x00\x00': b'\x01!!!!!!!!!!!!!!!!', # 2021 Sienna + } + + KEY_STRUCT_SIZE = 0x20 + CHECKSUM_OFFSET = 0x1d + SECOC_KEY_SIZE = 0x10 + SECOC_KEY_OFFSET = 0x0c + + @classmethod + def _get_key_struct(cls, data, key_no): + return data[key_no * cls.KEY_STRUCT_SIZE: (key_no + 1) * cls.KEY_STRUCT_SIZE] + + @classmethod + def _verify_checksum(cls, key_struct): + checksum = sum(key_struct[:cls.CHECKSUM_OFFSET]) + checksum = ~checksum & 0xff + return checksum == key_struct[cls.CHECKSUM_OFFSET] + + @classmethod + def _get_secoc_key(cls, key_struct): + return key_struct[cls.SECOC_KEY_OFFSET:cls.SECOC_KEY_OFFSET + cls.SECOC_KEY_SIZE] + + @classmethod + def hack(cls): + """Initializes the ECU connection and checks if boardd is running.""" + if not is_agnos(): + raise NotAGNOSError + + try: + check_output(["pidof", "boardd"]) + # This shouldn't happen since we never started boardd + raise BoarddNotRunningError("boardd is running, kill openpilot and run again") + except CalledProcessError as e: + if e.returncode != 1: # 1 == no process found (boardd not running) + raise e + except FileNotFoundError: + pass + + panda = Panda() + panda.set_safety_mode(CarParams.SafetyModel.elm327) + + uds_client = UdsClient(panda, cls.ADDR, cls.ADDR + 8, cls.BUS, timeout=0.1, response_pending_timeout=0.1) + + print("Getting application versions...") + + try: + app_version = uds_client.read_data_by_identifier(DATA_IDENTIFIER_TYPE.APPLICATION_SOFTWARE_IDENTIFICATION) + print(f" - APPLICATION_SOFTWARE_IDENTIFICATION (application): {str(app_version)}") + except (AssertionError, InvalidServiceIdError, MessageTimeoutError, NegativeResponseError): + raise RetryError("Car not detected") + + if app_version not in cls.APPLICATION_VERSIONS: + print(f"Unexpected application version (ignored): {str(app_version)}") + + # Mandatory flow of diagnostic sessions + try: + uds_client.diagnostic_session_control(SESSION_TYPE.DEFAULT) + uds_client.diagnostic_session_control(SESSION_TYPE.EXTENDED_DIAGNOSTIC) + uds_client.diagnostic_session_control(SESSION_TYPE.PROGRAMMING) + uds_client.diagnostic_session_control(SESSION_TYPE.DEFAULT) + uds_client.diagnostic_session_control(SESSION_TYPE.EXTENDED_DIAGNOSTIC) + except (InvalidServiceIdError, MessageTimeoutError, NegativeResponseError): + raise RetryError("Car not in 'Not Ready To Drive' mode") + + # Get bootloader version + try: + bl_version = uds_client.read_data_by_identifier(DATA_IDENTIFIER_TYPE.APPLICATION_SOFTWARE_IDENTIFICATION) + except (AssertionError, InvalidServiceIdError, MessageTimeoutError, NegativeResponseError): + raise RetryError(f"Can't read bootloader version ({format_version_for_error_display(app_version)})") + print(f" - APPLICATION_SOFTWARE_IDENTIFICATION (bootloader) {str(bl_version)}") + + try: + if bl_version != cls.APPLICATION_VERSIONS[app_version]: + print(f"Unexpected bootloader version (ignored): {str(bl_version)}") + except KeyError as e: # In case app_version is not found at all + print(f"Unexpected bootloader version (ignored): {str(e)}") + + # Go back to programming session + try: + uds_client.diagnostic_session_control(SESSION_TYPE.PROGRAMMING) + except (InvalidServiceIdError, MessageTimeoutError, NegativeResponseError): + raise RetryError("Can't enter programming session for reading bootloader version") + + # Security Access - Request Seed + try: + seed_payload = b"\x00" * 16 + seed = uds_client.security_access(ACCESS_TYPE.REQUEST_SEED, data_record=seed_payload) + + key = AES.new(cls.SEED_KEY_SECRET, AES.MODE_ECB).decrypt(seed_payload) + key = AES.new(key, AES.MODE_ECB).encrypt(seed) + + print("\nSecurity Access...") + + print(" - SEED:", seed.hex()) + print(" - KEY:", key.hex()) + + # Security Access - Send Key + uds_client.security_access(ACCESS_TYPE.SEND_KEY, key) + print(" - Key OK!") + + except (InvalidServiceIdError, MessageTimeoutError, NegativeResponseError): + raise RetryError("Security Access failed") + + # Security Access - Send Key + print("\nPreparing to upload payload...") + + try: + # Write something to DID 203, not sure why but needed for state machine + uds_client.write_data_by_identifier(0x203, b"\x00" * 5) + + # Write KEY and IV to DID 201/202, prerequisite for request download + print(" - Write data by identifier 0x201", cls.DID_201_KEY.hex()) + uds_client.write_data_by_identifier(0x201, cls.DID_201_KEY) + + print(" - Write data by identifier 0x202", cls.DID_202_IV.hex()) + uds_client.write_data_by_identifier(0x202, cls.DID_202_IV) + + # Request download to RAM + data = b"\x01" # [1] Format + data += b"\x46" # [2] 4 size bytes, 6 address bytes + data += b"\x01" # [3] memoryIdentifier + data += b"\x00" # [4] + data += struct.pack('!I', 0xfebf0000) # [5] Address + data += struct.pack('!I', 0x1000) # [9] Size + + print("\nUpload payload...") + + print(" - Request download") + resp = uds_client._uds_request(SERVICE_TYPE.REQUEST_DOWNLOAD, data=data) + + # Upload payload + payload = open("/data/openpilot/tsk/tools_menu/payload.bin", "rb").read() + assert len(payload) == 0x1000 + chunk_size = 0x400 + for i in range(len(payload) // chunk_size): + print(f" - Transfer data {i}") + uds_client.transfer_data(i + 1, payload[i * chunk_size:(i + 1) * chunk_size]) + + uds_client.request_transfer_exit() + + print("\nVerify payload...") + + # Routine control 0x10f0 + # [0] 0x31 (routine control) + # [1] 0x01 (start) + # [2] 0x10f0 (routine identifier) + # [4] 0x45 (format, 4 size bytes, 5 address bytes) + # [5] 0x0 + # [6] mem addr + # [10] mem addr + data = b"\x45\x00" + data += struct.pack('!I', 0xfebf0000) + data += struct.pack('!I', 0x1000) + + uds_client.routine_control(ROUTINE_CONTROL_TYPE.START, 0x10f0, data) + print(" - Routine control 0x10f0 OK!") + + except (InvalidServiceIdError, MessageTimeoutError, NegativeResponseError): + raise RetryError("Payload upload failed") + + print("\nTrigger payload...") + + # Now we trigger the payload by trying to erase + # [0] 0x31 (routine control) + # [1] 0x01 (start) + # [2] 0xff00 (routine identifier) + # [4] 0x45 (format, 4 size bytes, 5 address bytes) + # [5] 0x0 + # [6] mem addr + # [10] mem addr + data = b"\x45\x00" + data += struct.pack('!I', 0xe0000) + data += struct.pack('!I', 0x8000) + + # Manually send so we don't get stuck waiting for the response + erase = b"\x31\x01\xff\x00" + data + isotp_send(panda, erase, cls.ADDR, bus=cls.BUS) + + print("\nDumping keys...") + start = 0xfebe6e34 + end = 0xfebe6ff4 + + start_time = time.time() + timeout = 30 + + extracted = b"" + + with open(f'data_{start:08x}_{end:08x}.bin', 'wb') as f: + with tqdm(total=end - start) as pbar: + while start < end: + + current_time = time.time() + if current_time - start_time > timeout: + raise RetryError("Key dumping timed out") + + for addr, *_, data, bus in panda.can_recv(): + if bus != cls.BUS: + continue + + if data == b"\x03\x7f\x31\x78\x00\x00\x00\x00": # Skip response pending + continue + + if addr != cls.ADDR + 8: + continue + + if cls.DEBUG: + print(f"{data.hex()}") + + ptr = struct.unpack("> 8) == start & 0xffffff # Check lower 24 bits of address + + extracted += data[4:] + f.write(data[4:]) + f.flush() + + start += 4 + pbar.update(4) + + start_time = time.time() + + key_1_ok = cls._verify_checksum(cls._get_key_struct(extracted, 1)) + key_4_ok = cls._verify_checksum(cls._get_key_struct(extracted, 4)) + + if not key_1_ok or not key_4_ok: + raise RetryError(f"SecOC key checksum verification failed ({format_version_for_error_display(app_version, bl_version)})") + + key_1 = cls._get_secoc_key(cls._get_key_struct(extracted, 1)) + key_4 = cls._get_secoc_key(cls._get_key_struct(extracted, 4)) + + print("\nECU_MASTER_KEY ", key_1.hex()) + print("SecOC Key (KEY_4)", key_4.hex()) + + return key_4.hex() + + @classmethod + def run(cls): + try: + secoc_key = cls.hack() + except (BoarddNotRunningError, RetryError): + raise + except Exception as e: + e.add_note("\n\n!!!! Unexpected error. Please take a photo, post it on #toyota-security, and ping @calvinspark\n") + raise + + print("SecOC key extracted successfully") + print("!!!! Take a photo of this screen") + return secoc_key diff --git a/tsk/tools_menu/keyboard.py b/tsk/tools_menu/keyboard.py new file mode 100644 index 00000000..61f96f29 --- /dev/null +++ b/tsk/tools_menu/keyboard.py @@ -0,0 +1,216 @@ +# tsk/tools_menu/keyboard.py +import pyray as rl + +from openpilot.system.ui.lib.application import gui_app +from tsk.common.key_file_manager import KeyFileManager + + +class KeyboardDialog: + """A full-screen keyboard.""" + + def __init__(self): + self.key_file_manager = KeyFileManager() + self.key_status_text = "Key not installed" + self.input_text = "" + self.max_input_length = 32 + self.show_install_button = False + self.install_success = False # Added success flag + + # Font and Color Definitions + self.font_size = 100 + self.key_status_font_size = 90 # Added key status font size + self.keyboard_bg_color = rl.Color(51, 51, 51, 255) # Solid dark gray + self.keyboard_border_color = rl.BLACK + self.keyboard_border_thickness = 4 + self.x_button_text = " X " # X with spaces + self.x_button_text_color = rl.Color(150, 150, 150, 255) # Brighter gray + self.input_box_bg_color = rl.BLACK # Black background + self.input_box_border_color = rl.WHITE # White border + self.input_text_color_1 = rl.Color(120, 120, 120, 255) # Darker light gray + self.input_text_color_2 = rl.DARKGRAY + + # Calculate Input Box Dimensions + widest_char = max("0123456789abcdef", key=lambda c: rl.measure_text_ex(gui_app.font(), c, self.font_size, 0).x) + self.char_width = rl.measure_text_ex(gui_app.font(), widest_char, self.font_size, 0).x + self.input_box_width = self.char_width * self.max_input_length + + # Calculate "X" Button Dimensions + x_text_size = rl.measure_text_ex(gui_app.font(), self.x_button_text, self.font_size, 0) + self.x_button_width = int(x_text_size.x * 1.2) # Add some padding + self.x_button_height = int(x_text_size.y * 1.2) # Add some padding + + # Define Keyboard Layout and Dimensions + self.keyboard_layout = ["1234567890", "abcdef<"] + self.keyboard_button_height = 200 + self.keyboard_spacing = 0 + self.keyboard_button_width_row1 = gui_app.width / len(self.keyboard_layout[0]) + self.keyboard_button_width_row2 = gui_app.width / len(self.keyboard_layout[1]) + + def update_key_status(self): + """Updates the key status text.""" + key = self.key_file_manager.installed_key + self.key_status_text = f"Key installed: {key}" if key else "Key not installed" + + def draw_x_button(self, rect: rl.Rectangle, text: str) -> bool: + """Draws the "X" button and handles click detection.""" + is_pressed = rl.check_collision_point_rec(rl.get_mouse_position(), rect) and rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT) + + # Draw button rectangle + rl.draw_rectangle_rec(rect, self.keyboard_bg_color) + + # Draw text + text_size = rl.measure_text_ex(gui_app.font(), text, self.font_size, 0) + text_x = rect.x + (rect.width - text_size.x) / 2 + text_y = rect.y + (rect.height - text_size.y) / 2 + rl.draw_text_ex(gui_app.font(), text, rl.Vector2(text_x, text_y), self.font_size, 0, self.x_button_text_color) + + return is_pressed + + def draw_keyboard(self, rect: rl.Rectangle) -> None: + """Draws the on-screen keyboard.""" + start_x = rect.x + start_y = rect.y + + for row_index, row in enumerate(self.keyboard_layout): + button_width = self.keyboard_button_width_row1 if row_index == 0 else self.keyboard_button_width_row2 + for key_index, key in enumerate(row): + button_x = start_x + key_index * (button_width + self.keyboard_spacing) + button_y = start_y + row_index * (self.keyboard_button_height + self.keyboard_spacing) + button_rect = rl.Rectangle(button_x, button_y, button_width, self.keyboard_button_height) + + is_pressed = rl.check_collision_point_rec(rl.get_mouse_position(), button_rect) and rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT) + + # Draw button rectangle + rl.draw_rectangle_rec(button_rect, self.keyboard_bg_color) + rl.draw_rectangle_lines_ex(button_rect, self.keyboard_border_thickness, self.keyboard_border_color) + + # Draw text + text_size = rl.measure_text_ex(gui_app.font(), key, self.font_size, 0) + text_x = button_x + (button_width - text_size.x) / 2 + text_y = button_y + (self.keyboard_button_height - text_size.y) / 2 + rl.draw_text_ex(gui_app.font(), key, rl.Vector2(text_x, text_y), self.font_size, 0, rl.LIGHTGRAY) + + if is_pressed: + if key == "<": + self.input_text = self.input_text[:-1] + else: + if len(self.input_text) < self.max_input_length: + self.input_text += key + self.show_install_button = len(self.input_text) == self.max_input_length + self.install_success = False # Reset success flag when input changes + + @staticmethod + def ask(): + """Displays the TSK Keyboard dialog.""" + dialog_open = True + dialog = KeyboardDialog() + + # Get the current key and set it as the default text + installed_key = dialog.key_file_manager.installed_key + if installed_key: + dialog.input_text = installed_key + dialog.show_install_button = len(dialog.input_text) == dialog.max_input_length + + dialog.update_key_status() # Initial key status update + + def render_dialog(): + nonlocal dialog_open + + # Calculate vertical centering + keyboard_height = 2 * dialog.keyboard_button_height + available_height = gui_app.height - keyboard_height + total_content_height = 0 + + # Key Status Label + dialog.update_key_status() + key_status_text_size = rl.measure_text_ex(gui_app.font(), dialog.key_status_text, dialog.key_status_font_size, 0) + total_content_height += key_status_text_size.y + + # Input Box + input_box_height = dialog.font_size * 1.5 + total_content_height += input_box_height + + # Remaining Characters Label / Install Button / Success Label + total_content_height += dialog.font_size # Approximate height + + vertical_offset = (available_height - total_content_height) / 2 + + # Key Status Label + key_status_text_x = (gui_app.width - key_status_text_size.x) / 2 + key_status_text_y = 20 + vertical_offset # Apply vertical offset + rl.draw_text_ex(gui_app.font(), dialog.key_status_text, rl.Vector2(key_status_text_x, key_status_text_y), dialog.key_status_font_size, 0, rl.LIGHTGRAY) # Light gray + + # Input Box + input_box_x = (gui_app.width - dialog.input_box_width) / 2 + input_box_y = key_status_text_y + key_status_text_size.y + 20 # Apply vertical offset + rl.draw_rectangle(int(input_box_x), int(input_box_y), int(dialog.input_box_width), int(input_box_height), dialog.input_box_bg_color) + rl.draw_rectangle_lines(int(input_box_x), int(input_box_y), int(dialog.input_box_width), int(input_box_height), dialog.input_box_border_color) + + # Draw input text with color cycling + input_text_x = input_box_x + 5 + input_text_y = input_box_y + (input_box_height - rl.measure_text_ex(gui_app.font(), "A", dialog.font_size, 0).y) / 2 + x_offset = 0 + for i, char in enumerate(dialog.input_text): + color = dialog.input_text_color_1 if (i // 4) % 2 == 0 else dialog.input_text_color_2 + char_width = rl.measure_text_ex(gui_app.font(), char, dialog.font_size, 0).x + rl.draw_text_ex(gui_app.font(), char, rl.Vector2(input_text_x + x_offset, input_text_y), dialog.font_size, 0, color) + x_offset += char_width + + # Remaining Characters Label / Install Button / Success Label + remaining_chars = dialog.max_input_length - len(dialog.input_text) + remaining_text_y = input_box_y + input_box_height + 10 # Apply vertical offset + + if dialog.install_success: + # Success Label + success_text = "Success!" + success_text_size = rl.measure_text_ex(gui_app.font(), success_text, dialog.font_size, 0) + success_text_x = (gui_app.width - success_text_size.x) / 2 + rl.draw_text_ex(gui_app.font(), success_text, rl.Vector2(success_text_x, remaining_text_y), dialog.font_size, 0, rl.GREEN) + + elif dialog.show_install_button: + # Install Button + install_text = "Install this key" + install_text_size = rl.measure_text_ex(gui_app.font(), install_text, dialog.font_size, 0) + install_button_width = install_text_size.x + 40 # Add some padding + install_button_height = install_text_size.y + 20 # Add some padding + install_button_x = (gui_app.width - install_button_width) / 2 + install_button_rect = rl.Rectangle(install_button_x, remaining_text_y, install_button_width, install_button_height) + if rl.check_collision_point_rec(rl.get_mouse_position(), install_button_rect) and rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT): + # Install key + dialog.key_file_manager.install_key(dialog.input_text) + dialog.install_success = True + dialog.show_install_button = False + + rl.draw_rectangle_rec(install_button_rect, dialog.keyboard_bg_color) # Keyboard background color + install_text_x = install_button_x + (install_button_width - install_text_size.x) / 2 + install_text_y = remaining_text_y + (install_button_height - install_text_size.y) / 2 + rl.draw_text_ex(gui_app.font(), install_text, rl.Vector2(install_text_x, install_text_y), dialog.font_size, 0, rl.LIGHTGRAY) # Light gray text + + else: + # Remaining Characters Label + remaining_text = f"{remaining_chars} characters left" + remaining_text_size = rl.measure_text_ex(gui_app.font(), remaining_text, dialog.font_size, 0) + remaining_text_x = (gui_app.width - remaining_text_size.x) / 2 + rl.draw_text_ex(gui_app.font(), remaining_text, rl.Vector2(remaining_text_x, remaining_text_y), dialog.font_size, 0, rl.DARKGRAY) + + # "X" Button (Top Right - All the way to the edge) + button_x = gui_app.width - dialog.x_button_width + button_y = 0 + if dialog.draw_x_button(rl.Rectangle(button_x, button_y, dialog.x_button_width, dialog.x_button_height), dialog.x_button_text): + dialog_open = False + + # Keyboard + keyboard_x = 0 + keyboard_y = gui_app.height - 2 * dialog.keyboard_button_height + keyboard_width = gui_app.width + keyboard_height = 2 * dialog.keyboard_button_height + dialog.draw_keyboard(rl.Rectangle(keyboard_x, keyboard_y, keyboard_width, keyboard_height)) + + # Main loop + while dialog_open and not rl.window_should_close(): + rl.begin_drawing() + rl.clear_background(rl.BLACK) + + render_dialog() + + rl.end_drawing() diff --git a/tsk/tools_menu/payload.bin b/tsk/tools_menu/payload.bin new file mode 100644 index 0000000000000000000000000000000000000000..87076964fff42a6ee9cec0199d5d02a304abfa17 GIT binary patch literal 4096 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 literal 0 HcmV?d00001 diff --git a/tsk/tools_menu/ui.py b/tsk/tools_menu/ui.py new file mode 100644 index 00000000..860b1d63 --- /dev/null +++ b/tsk/tools_menu/ui.py @@ -0,0 +1,65 @@ +# tsk/tools_menu/ui.py +import pyray as rl + +from openpilot.system.ui.lib.application import gui_app +from tsk.tools_menu.actions import tsk_extractor_action, tsk_guide_action, tsk_uninstaller_action # Import actions +from tsk.tools_menu.keyboard import KeyboardDialog +from tsk.ui.button import Button +from tsk.ui.header import Header +from tsk.ui.layout import Layout, Theme # Import Theme + + +class ToolsMenuUI: + """Renders the Tools Menu with buttons for TSK actions.""" + + def __init__(self): + """Initializes the Tools Menu.""" + self.header = Header() + header_height = self.header._calculate_height() + key_status_height = Theme.key_status_font_size * 1.5 # Approximate height + combined_header_height = header_height + key_status_height + + rect = rl.Rectangle(0, 0, gui_app.width, gui_app.height) # Dummy rectangle for initial calculations + button_height = Layout.calculate_button_dimensions(rect.height, combined_header_height) + + # --- Calculate Positions for the Three Main Buttons --- + start_x, start_y = Layout.calculate_button_positions(rect, combined_header_height, 3) + + button1_x = start_x + button2_x = start_x + (600 + 80) + button3_x = start_x + 2 * (600 + 80) + + button_y = start_y + + # --- Create the Three Main Buttons --- + self.extractor_button = Button(tsk_extractor_action, + [{"text": "TSK Extractor", "x_offset": 55, "y_offset": (button_height / 2) - 45}], + 600, button_height, button1_x, button_y, 90) + self.keyboard_button = Button(KeyboardDialog.ask, + [{"text": "TSK Keyboard", "x_offset": 45, "y_offset": (button_height / 2) - 45}], 600, + button_height, button2_x, button_y, 90) + self.uninstaller_button = Button(tsk_uninstaller_action, [ + {"text": "TSK Uninstaller", "x_offset": 25, "y_offset": (button_height / 2) - 45}], 600, button_height, button3_x, + button_y, 90) + + # --- Create the "Tell me what to do next" Button --- + guide_button_width = (3 * 600) + (2 * 80) + guide_button_height = 200 + guide_button_x = start_x + guide_button_y = button_y + button_height + 80 + + self.guide_button = Button(tsk_guide_action, [ + { + "text": "Tell me what to do next", + "x_offset": (guide_button_width - rl.measure_text_ex(gui_app.font(), "Tell me what to do next", 90, 1.0).x) / 2, + "y_offset": 60, + } + ], guide_button_width, guide_button_height, guide_button_x, guide_button_y, 90) + + def render(self, rect: rl.Rectangle) -> None: + """Renders the Tools Menu.""" + + self.extractor_button.render() + self.keyboard_button.render() + self.uninstaller_button.render() + self.guide_button.render() diff --git a/tsk/ui/__init__.py b/tsk/ui/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tsk/ui/button.py b/tsk/ui/button.py new file mode 100644 index 00000000..59bef756 --- /dev/null +++ b/tsk/ui/button.py @@ -0,0 +1,59 @@ +# tsk/ui/button.py +from typing import Callable, List, Dict, Any + +import pyray as rl + +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.button import gui_button +from tsk.ui.layout import Theme # Import Theme + + +class Button: + """Represents a button with text labels and an action.""" + + def __init__(self, action: Callable[[], None], labels: List[Dict[str, Any]], width: int, height: int, x: int, y: int, + font_size: int): + """ + Initializes a Button object. + + Args: + key: The key associated with the button. + action: The function to be called when the button is pressed. + labels: A list of dictionaries containing text and offset information for the button labels. + width: The width of the button. + height: The height of the button. + x: The x-coordinate of the button. + y: The y-coordinate of the button. + font_size: The font size of the button labels. + """ + self.action: Callable[[], None] = action + self.labels: List[Dict[str, Any]] = labels + self.width: int = width + self.height: int = height + self.x: int = x + self.y: int = y + self.font_size: int = font_size + self.last_pressed_time: float = 0.0 + self.debounce_delay: float = 0.2 # seconds + + def render(self) -> None: + """Renders the button.""" + current_time = rl.get_time() + if gui_button(rl.Rectangle(self.x, self.y, self.width, self.height), ""): + if current_time - self.last_pressed_time > self.debounce_delay: + self.action() + self.last_pressed_time = current_time + + self._draw_labels() + + def _draw_labels(self) -> None: + """Draws the labels on the button.""" + for label_data in self.labels: + rl.draw_text_ex( + gui_app.font(), + label_data["text"], + rl.Vector2(self.x + label_data["x_offset"], self.y + label_data["y_offset"]), + self.font_size, + 1.0, + Theme.brighten_color(rl.Color(100, 100, 100, 255), Theme.brighten_amount), # Use the brightened color here + ) diff --git a/tsk/ui/dialog.py b/tsk/ui/dialog.py new file mode 100644 index 00000000..c7a2c4e0 --- /dev/null +++ b/tsk/ui/dialog.py @@ -0,0 +1,178 @@ +# tsk/ui/dialog.py +import pyray as rl + +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.button import gui_button, DEFAULT_BUTTON_FONT_SIZE +from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel + + +class BaseDialog: + """Base class for full-screen dialogs with a scrollable text area.""" + + BORDER_SIZE = 20 + BUTTON_HEIGHT = 80 + BUTTON_WIDTH = 310 + BUTTON_SPACING = 20 + FONT_SIZE = 100 + LINE_HEIGHT = FONT_SIZE * 1.1 + TEXT_PADDING = 10 + + def __init__(self, body_text: str, font_size: int = FONT_SIZE, scroll_to_bottom: bool = False): + self.body_text = body_text + self.font_size = font_size + self.scroll_to_bottom = scroll_to_bottom + self.LINE_HEIGHT = self.font_size * 1.1 + self.textarea_rect = rl.Rectangle( + self.BORDER_SIZE, + self.BORDER_SIZE, + gui_app.width - 2 * self.BORDER_SIZE, + gui_app.height - 3 * self.BORDER_SIZE - self.BUTTON_HEIGHT # Account for buttons and spacing + ) + self.wrapped_lines = self._wrap_text(self.body_text, self.font_size, self.textarea_rect.width - 2 * self.TEXT_PADDING) + self.content_height = len(self.wrapped_lines) * self.LINE_HEIGHT + self.content_rect = rl.Rectangle(0, 0, self.textarea_rect.width - 2 * self.TEXT_PADDING, self.content_height) + self.scroll_panel = GuiScrollPanel(show_vertical_scroll_bar=True) + self.scroll_offset = rl.Vector2(0, 0) # Store the scroll offset + self.initial_scroll_applied = False # Flag to track initial scroll application + + def render_text_area(self): + """Renders the scrollable text area.""" + + scroll = self.scroll_panel.handle_scroll(self.textarea_rect, self.content_rect) + self.scroll_offset = scroll # Update the scroll offset after handling user input + + # Apply initial scroll to bottom after the first render + if self.scroll_to_bottom and not self.initial_scroll_applied: + self.scroll_offset.y = min(0, self.textarea_rect.height - self.content_height - 2 * self.TEXT_PADDING) + self.initial_scroll_applied = True + + rl.begin_scissor_mode(int(self.textarea_rect.x), int(self.textarea_rect.y), int(self.textarea_rect.width), int(self.textarea_rect.height)) + y_offset = 0 + for line in self.wrapped_lines: + position = rl.Vector2(self.textarea_rect.x + self.TEXT_PADDING + self.scroll_offset.x, self.textarea_rect.y + self.TEXT_PADDING + self.scroll_offset.y + y_offset) + if position.y + self.LINE_HEIGHT < self.textarea_rect.y + self.TEXT_PADDING or position.y > self.textarea_rect.y + self.textarea_rect.height - self.TEXT_PADDING: + y_offset += self.LINE_HEIGHT + continue + rl.draw_text_ex(gui_app.font(), line.strip(), position, self.font_size, 0, rl.WHITE) + y_offset += self.LINE_HEIGHT + rl.end_scissor_mode() + + def _wrap_text(self, text, font_size, max_width): + lines = [] + font = gui_app.font() + + # Split the text by newline characters + for block in text.splitlines(): + if not block: # Handle empty lines (consecutive newlines) + lines.append("") # Add an empty line + continue + + current_line = "" + for word in block.split(): + test_line = current_line + word + " " + if rl.measure_text_ex(font, test_line, font_size, 0).x <= max_width: + current_line = test_line + else: + lines.append(current_line) + current_line = word + " " + if current_line: + lines.append(current_line) + + return lines + + +class OkayDialog(BaseDialog): + """A full-screen dialog with a scrollable text area and an Okay button.""" + + @staticmethod + def ask(body_text: str, font_size: int = BaseDialog.FONT_SIZE, scroll_to_bottom: bool = False, okay_text: str = "Okay") -> None: + """Displays a full-screen Okay dialog.""" + okay_pressed = False + + dialog = OkayDialog(body_text, font_size, scroll_to_bottom) + + def render_dialog(): + nonlocal okay_pressed # Allow modification of the okay_pressed variable + + dialog.render_text_area() + + # Calculate the available height for the button area + button_area_height = gui_app.height - dialog.textarea_rect.height - dialog.textarea_rect.y + + # Button position (centered horizontally, vertically centered in the button area) + button_x = (gui_app.width - BaseDialog.BUTTON_WIDTH) / 2 + button_y = dialog.textarea_rect.y + dialog.textarea_rect.height + (button_area_height - BaseDialog.BUTTON_HEIGHT) / 2 + + # Okay Button + if gui_button(rl.Rectangle(button_x, button_y, BaseDialog.BUTTON_WIDTH, BaseDialog.BUTTON_HEIGHT), okay_text): + okay_pressed = True + + # Main loop + while not okay_pressed and not rl.window_should_close(): + rl.begin_drawing() + rl.clear_background(rl.BLACK) + + render_dialog() + + rl.end_drawing() + + +class YesNoDialog(BaseDialog): + """A full-screen dialog with a scrollable text area and Yes/No buttons.""" + + @staticmethod + def ask(body_text: str, font_size: int = BaseDialog.FONT_SIZE, scroll_to_bottom: bool = False, yes_text: str = "Yes", no_text: str = "No") -> bool | None: + """Displays a full-screen Yes/No dialog and returns a boolean based on the user's choice.""" + result = None # Use None to indicate the dialog is still active + + dialog = YesNoDialog(body_text, font_size, scroll_to_bottom) + + def render_dialog(): + nonlocal result # Allow modification of the result variable + + dialog.render_text_area() + + button_top = gui_app.height - BaseDialog.BORDER_SIZE - BaseDialog.BUTTON_HEIGHT + no_button_x = BaseDialog.BORDER_SIZE + yes_button_x = gui_app.width - BaseDialog.BORDER_SIZE - BaseDialog.BUTTON_WIDTH + + no_button_rect = rl.Rectangle(no_button_x, button_top, BaseDialog.BUTTON_WIDTH, BaseDialog.BUTTON_HEIGHT) + yes_button_rect = rl.Rectangle(yes_button_x, button_top, BaseDialog.BUTTON_WIDTH, BaseDialog.BUTTON_HEIGHT) + + if draw_custom_button(no_button_rect, no_text, rl.Color(100, 20, 20, 255)): + result = False + if draw_custom_button(yes_button_rect, yes_text, rl.Color(20, 100, 20, 255)): + result = True + + # Main loop + while result is None and not rl.window_should_close(): + rl.begin_drawing() + rl.clear_background(rl.BLACK) + + render_dialog() + + rl.end_drawing() + + return result + + +def draw_custom_button(rect: rl.Rectangle, text: str, color: rl.Color) -> bool: + """Draws a custom button with specified color and handles click detection.""" + mouse_pos = rl.get_mouse_position() + is_hovering = rl.check_collision_point_rec(mouse_pos, rect) + is_pressed = is_hovering and rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT) + result = False + + rl.draw_rectangle_rec(rect, color) + + # Draw button text (centered) + font = gui_app.font() + text_size = rl.measure_text_ex(font, text, DEFAULT_BUTTON_FONT_SIZE, 0) + text_x = rect.x + (rect.width - text_size.x) / 2 + text_y = rect.y + (rect.height - text_size.y) / 2 + rl.draw_text_ex(font, text, rl.Vector2(text_x, text_y), DEFAULT_BUTTON_FONT_SIZE, 0, rl.WHITE) + + if is_pressed: + result = True + + return result diff --git a/tsk/ui/header.py b/tsk/ui/header.py new file mode 100644 index 00000000..4e46963d --- /dev/null +++ b/tsk/ui/header.py @@ -0,0 +1,119 @@ +# tsk/ui/header.py +from typing import Optional + +import pyray as rl + +from openpilot.system.ui.lib.application import gui_app +from tsk.common.key_file_manager import KeyFileManager +from tsk.ui.layout import Theme # Import Theme + + +class Header: + """Renders the top navigation bar with title and key status.""" + + def __init__(self): + """Initializes the Header.""" + self.key_manager = KeyFileManager() + + def _draw_title(self, rect: rl.Rectangle, current_menu: int) -> None: + """Draws the title strip.""" + title_text_color = Theme.brighten_color(Theme.title_bg_color, Theme.brighten_amount) + + menu_name = Theme.menu_names.get(current_menu, "Unknown Menu") + full_title_text = "TSK Manager: " + menu_name + full_title_text_size = rl.measure_text_ex(gui_app.font(), full_title_text, Theme.title_font_size, 1.0) + + title_text_prefix_size = rl.measure_text_ex(gui_app.font(), "TSK Manager: ", Theme.title_font_size, 1.0) + menu_name_x = gui_app.width * Theme.title_prefix_x_offset_percent + title_text_prefix_size.x + Theme.title_x_offset + text_y = (self._calculate_height() - full_title_text_size.y) / 2 + + rl.draw_text_ex(gui_app.font(), "TSK Manager: ", rl.Vector2(gui_app.width * Theme.title_prefix_x_offset_percent + Theme.title_x_offset, text_y), + Theme.title_font_size, 1.0, title_text_color) + + rl.draw_text_ex(gui_app.font(), menu_name, rl.Vector2(menu_name_x, text_y), Theme.title_font_size, 1.0, title_text_color) + + def _draw_navigation_buttons(self, rect: rl.Rectangle, current_menu: int) -> Optional[int]: + """Draws the navigation buttons and handles button clicks.""" + nav_button_width = self._calculate_nav_button_width() + + left_button_x = 0 + left_button_rect = rl.Rectangle(left_button_x, 0, nav_button_width, rect.height) + right_button_x = gui_app.width - nav_button_width + right_button_rect = rl.Rectangle(right_button_x, 0, nav_button_width, rect.height) + + new_menu = None + + def handle_button(button_rect: rl.Rectangle, text: str, target_menu: int) -> Optional[int]: + """Handles drawing, input, and logic for a single navigation button.""" + mouse_pos = rl.get_mouse_position() + is_hovering = rl.check_collision_point_rec(mouse_pos, button_rect) + is_pressed = is_hovering and rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT) + + rl.draw_rectangle_rec(button_rect, Theme.button_color) + + lines = text.splitlines() + total_text_height = len(lines) * rl.measure_text_ex(gui_app.font(), "A", Theme.nav_button_font_size, 1.0).y + start_y = rect.y + (rect.height - total_text_height) / 2 + + for i, line in enumerate(lines): + text_size = rl.measure_text_ex(gui_app.font(), line, Theme.nav_button_font_size, 1.0) + text_x = button_rect.x + (button_rect.width - text_size.x) / 2 + text_y = start_y + i * text_size.y + font_color = Theme.brighten_color(rl.Color(100, 100, 100, 255), Theme.brighten_amount) # Use the same color as other buttons + rl.draw_text_ex(gui_app.font(), line, rl.Vector2(text_x, text_y), Theme.nav_button_font_size, 1.0, font_color) + + if is_pressed: + return target_menu + return None + if current_menu == Theme.menu_reboot: + new_menu = handle_button(left_button_rect, Theme.nav_button_text_left, Theme.menu_tools) + + # --- Handle Right Button --- + if current_menu == Theme.menu_tools: + right_menu = handle_button(right_button_rect, Theme.nav_button_text_right, Theme.menu_reboot) + if new_menu is None: + new_menu = right_menu + + return new_menu + + def _draw_key_status(self, rect: rl.Rectangle) -> None: + """Draws the key status label.""" + key_status_text = f"Key installed: {self.key_manager.installed_key}" if self.key_manager.installed_key else "Key not installed" + status_text_x = (gui_app.width - rl.measure_text_ex(gui_app.font(), key_status_text, Theme.key_status_font_size, 1.0).x) / 2 + status_text_y = rect.y + (rect.height - rl.measure_text_ex(gui_app.font(), key_status_text, Theme.key_status_font_size, 1.0).y) / 2 # Use rect.y + rl.draw_text_ex(gui_app.font(), key_status_text, rl.Vector2(status_text_x, status_text_y), Theme.key_status_font_size, 1.0, Theme.key_text_color) + + def _calculate_height(self) -> float: + """Calculates the height of the header.""" + title_text_prefix = "TSK Manager: " + return rl.measure_text_ex(gui_app.font(), title_text_prefix, Theme.title_font_size, 1.0).y * 1.5 + + def _calculate_nav_button_width(self) -> float: + """Calculates the width of the navigation buttons.""" + return max(rl.measure_text_ex(gui_app.font(), Theme.nav_button_text_left, Theme.nav_button_font_size, 1.0).x, + rl.measure_text_ex(gui_app.font(), Theme.nav_button_text_right, Theme.nav_button_font_size, 1.0).x) + 10 + + def render(self, rect: rl.Rectangle, current_menu: int) -> Optional[int]: + """Renders the top strip with the title and menu navigation.""" + + title_height = self._calculate_height() + key_status_height = Theme.key_status_font_size * 1.5 # Approximate height + + # --- Draw Title Strip --- + title_rect = rl.Rectangle(0, 0, gui_app.width, title_height) + rl.draw_rectangle_rec(title_rect, Theme.title_bg_color) + self._draw_title(rect, current_menu) + + # --- Draw Key Status Strip --- + key_status_y = title_height + key_status_rect = rl.Rectangle(0, key_status_y, gui_app.width, key_status_height) + rl.draw_rectangle_rec(key_status_rect, Theme.nav_bg_color) # Use nav_bg_color for key status + self._draw_key_status(key_status_rect) + + # --- Draw Navigation Buttons (Overlapping) --- + nav_rect = rl.Rectangle(0, 0, gui_app.width, title_height + key_status_height) # Overlap both + new_menu = self._draw_navigation_buttons(nav_rect, current_menu) + + if new_menu is not None: + return new_menu + return None diff --git a/tsk/ui/layout.py b/tsk/ui/layout.py new file mode 100644 index 00000000..72098b53 --- /dev/null +++ b/tsk/ui/layout.py @@ -0,0 +1,67 @@ +# tsk/ui/layout.py +import pyray as rl + +class Theme: + """Defines the visual theme and constants for the TSK Manager.""" + + # --- Colors --- + nav_bg_color = rl.Color(20, 20, 20, 255) + key_text_color = rl.Color(255, 255, 255, 255) + button_color = rl.Color(51, 51, 51, 255) + title_bg_color = rl.Color(40, 40, 40, 255) + nav_text_color = rl.Color(255, 255, 255, 255) # Added nav_text_color + + # --- Font Sizes --- + title_font_size = 90 + nav_button_font_size = 80 + key_status_font_size = 65 + + # --- Text --- + nav_button_text_left = "< Tools\n< Menu" + nav_button_text_right = "Reboot >\n Menu >" + nav_button_text_to_tools = "< Tools\n< Menu" + + # --- Layout --- + title_prefix_x_offset_percent = 0.2 + title_x_offset = 180 + brighten_amount = 0.6 + status_update_interval = 1 # seconds + + # --- Menu Identifiers --- + menu_tools = 1 + menu_reboot = 2 + + menu_names = { + menu_tools: "Tools Menu", + menu_reboot: "Reboot Menu", + } + + @staticmethod + def brighten_color(color: rl.Color, amount: float) -> rl.Color: + """Brightens a color by a given amount (0.0 to 1.0).""" + r = int(min(color.r + (255 - color.r) * amount, 255)) + g = int(min(color.g + (255 - color.g) * amount, 255)) + b = int(min(color.b + (255 - color.b) * amount, 255)) + return rl.Color(r, g, b, color.a) + + +class Layout: + """Provides layout calculation functions.""" + + @staticmethod + def calculate_button_dimensions(rect_height: int, header_height: int) -> int: + """Calculates button height based on available screen space and desired spacing.""" + available_height = rect_height - header_height + guide_button_height = 200 + num_spacers = 3 # Top, between buttons, and below guide button + remaining_height = available_height - guide_button_height - (num_spacers * 80) + button_height = remaining_height + return int(button_height) + + @staticmethod + def calculate_button_positions(rect: rl.Rectangle, header_height: int, num_buttons: int) -> tuple: + """Calculates the starting positions for a row of buttons.""" + total_width = (num_buttons * 600) + ((num_buttons - 1) * 80) + start_x = (rect.width - total_width) / 2 + rect.x + start_y = rect.y + header_height + 80 + return start_x, start_y