From 209db8f3207156abb46bbdb262d739537823dcba Mon Sep 17 00:00:00 2001 From: Calvin Park Date: Sat, 15 Nov 2025 18:11:00 -0800 Subject: [PATCH] TSK Manager v0.11.0 --- .gitignore | 1 + CLAUDE.md | 81 +++ c3x | 2 + c4 | 2 + launch_chffrplus.sh | 22 + tsk/__init__.py | 0 tsk/c3/__init__.py | 0 tsk/c3/reboot_menu/__init__.py | 0 tsk/c3/reboot_menu/actions.py | 136 ++++ tsk/c3/reboot_menu/ui.py | 131 ++++ tsk/c3/tools_menu/__init__.py | 0 tsk/c3/tools_menu/actions.py | 79 +++ tsk/c3/tools_menu/keyboard.py | 244 +++++++ tsk/c3/tools_menu/payload.bin | Bin 0 -> 4096 bytes tsk/c3/tools_menu/ui.py | 120 ++++ tsk/c3/tsk_manager.py | 66 ++ tsk/c3/ui/__init__.py | 0 tsk/c3/ui/button.py | 139 ++++ tsk/c3/ui/dialog.py | 216 ++++++ tsk/c3/ui/header.py | 192 +++++ tsk/c3/ui/layout.py | 76 ++ tsk/c3/ui/measure_text.py | 18 + tsk/c3/ui/render_loop.py | 41 ++ tsk/c4/__init__.py | 0 tsk/c4/menu_0_tools/__init__.py | 0 tsk/c4/menu_0_tools/btn_0_extractor.py | 45 ++ tsk/c4/menu_0_tools/btn_1_keyboard.py | 14 + tsk/c4/menu_0_tools/btn_2_uninstaller.py | 37 + tsk/c4/menu_0_tools/btn_3_guide.py | 37 + tsk/c4/menu_1_reboot/__init__.py | 0 tsk/c4/menu_1_reboot/btn_0_recommended.py | 50 ++ tsk/c4/menu_1_reboot/btn_1_alternate.py | 49 ++ tsk/c4/menu_1_reboot/btn_2_somethingelse.py | 54 ++ tsk/c4/menu_1_reboot/btn_3_reboot.py | 38 + tsk/c4/tsk_manager.py | 197 ++++++ tsk/c4/ui.py | 323 +++++++++ tsk/common/__init__.py | 0 tsk/common/env.py | 53 ++ tsk/common/extractor.py | 315 +++++++++ tsk/common/key_file_manager.py | 137 ++++ tsk/common/payload.bin | Bin 0 -> 4096 bytes tsk/common/widget.py | 60 ++ tsk/main.py | 59 ++ tsk/prefetch.py | 734 ++++++++++++++++++++ 44 files changed, 3768 insertions(+) create mode 100644 CLAUDE.md create mode 100755 c3x create mode 100755 c4 create mode 100644 tsk/__init__.py create mode 100644 tsk/c3/__init__.py create mode 100644 tsk/c3/reboot_menu/__init__.py create mode 100644 tsk/c3/reboot_menu/actions.py create mode 100644 tsk/c3/reboot_menu/ui.py create mode 100644 tsk/c3/tools_menu/__init__.py create mode 100644 tsk/c3/tools_menu/actions.py create mode 100644 tsk/c3/tools_menu/keyboard.py create mode 100644 tsk/c3/tools_menu/payload.bin create mode 100644 tsk/c3/tools_menu/ui.py create mode 100644 tsk/c3/tsk_manager.py create mode 100644 tsk/c3/ui/__init__.py create mode 100644 tsk/c3/ui/button.py create mode 100644 tsk/c3/ui/dialog.py create mode 100644 tsk/c3/ui/header.py create mode 100644 tsk/c3/ui/layout.py create mode 100644 tsk/c3/ui/measure_text.py create mode 100644 tsk/c3/ui/render_loop.py create mode 100644 tsk/c4/__init__.py create mode 100644 tsk/c4/menu_0_tools/__init__.py create mode 100644 tsk/c4/menu_0_tools/btn_0_extractor.py create mode 100644 tsk/c4/menu_0_tools/btn_1_keyboard.py create mode 100644 tsk/c4/menu_0_tools/btn_2_uninstaller.py create mode 100644 tsk/c4/menu_0_tools/btn_3_guide.py create mode 100644 tsk/c4/menu_1_reboot/__init__.py create mode 100644 tsk/c4/menu_1_reboot/btn_0_recommended.py create mode 100644 tsk/c4/menu_1_reboot/btn_1_alternate.py create mode 100644 tsk/c4/menu_1_reboot/btn_2_somethingelse.py create mode 100644 tsk/c4/menu_1_reboot/btn_3_reboot.py create mode 100644 tsk/c4/tsk_manager.py create mode 100644 tsk/c4/ui.py create mode 100644 tsk/common/__init__.py create mode 100644 tsk/common/env.py create mode 100644 tsk/common/extractor.py create mode 100644 tsk/common/key_file_manager.py create mode 100644 tsk/common/payload.bin create mode 100644 tsk/common/widget.py create mode 100755 tsk/main.py create mode 100755 tsk/prefetch.py diff --git a/.gitignore b/.gitignore index c4022a86..e8417faf 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ venv/ .tags .ipynb_checkpoints .idea +*.iml .overlay_init .overlay_consistent .sconsign.dblite diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..ca47a7fe --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,81 @@ +# TSK Manager + +## Background + +### The Problem + +comma.ai makes DIY ADAS devices. Older devices include the comma eon; current ones are comma three (C3, aka tici), comma threeX (C3X, aka tizi), and comma four (C4, aka mici). comma discontinued support for C3. + +Not all cars work with comma. Some use FlexRay (e.g. BMW) instead of CAN/CAN-FD. Toyota added cryptographic signatures to CAN messages — comma can still read the bus but can't write because it can't generate valid signatures. + +### The Hack + +Willem, an ex-comma.ai employee, found a way to extract the security key from the EPS firmware on 2021-23 RAV4 Prime. The same hack works unmodified on 2021-23 Sienna Hybrid, and with modifications on Yaris. Details are in `/Users/calvin/GitRepos/docs/README.md`. + +Willem's hack was a Python script run over SSH. Calvin built a GUI around it — that's TSK Manager (TSKM), living in `tsk/` in this repo. + +### Calvin's Cars + +- 2023 Sienna Hybrid — C3X +- 2023 Bolt EV 2LT (no ACC) — C4 + +### Architecture + +- `tsk/main.py` dispatches based on device type (C3X vs C4) +- `tsk/c3/` — C3X code (maintenance mode, 1920x1080) +- `tsk/c4/` — C4 code (active development, 536x240) +- `tsk/common/` — shared code (extractor, key file manager, widget base) +- Run with `./c3x` or `./c4`; requires .venv + +### The Rebasing Problem + +comma devices use vertical phone LCD screens (C3/C3X are 1920x1080 portrait). comma wrote their own GUI libs to draw on the rotated screen — there's no way to use the display without going through their libs. They replaced Qt with RayLib, and rewrite these GUI libs constantly with zero backwards compatibility. TSKM has been rewritten 3+ times to keep up. + +TSKM rebases on `nightly-dev` to stay current with AGNOS (the device OS). This is necessary because: + +- AGNOS updates are ~1GB and take ~10 minutes +- The key extraction process is already stressful for users (removing camera housing, running the extractor, hoping the car still drives) +- Adding an AGNOS update mid-process is a terrible experience +- Some users have poor connectivity (rural US) + +The goal: user installs the TSKM branch at home, AGNOS updates while on Wi-Fi, then they drive to do the extraction with everything already current. + +SunnyPilot's SunnyLink integration is a potential long-term path to avoid the rebasing entirely. + +### SSH / Device Tips + +- tmux detach on comma devices: backtick (`` ` ``) then `d` + +### comma Release Branches + +Each device family has its own release branch: + +- C3X (tizi): `release-tizi` +- C4 (mici): `release-mici` +- C3 (tici): `release-tici` (no longer supported) +- `release3` is an older legacy device-agnostic branch + +### Versioned Branches + +Each release is tagged as a branch (e.g. `tskm-0.10.4`, `tskm-0.10.2`, `tskm-0.9.8`). These were meant to be frozen fallbacks, but comma's changes can break even old branches. + +--- + +## Journal + +When the user says "update the journal", write a summary of what was done in the current session into the journal below. + +### 2025-11-15 +Released TSK Manager v0.10.4 (latest commit on `tskm` branch). + +### 2026-04-09 +Current status: **fixed and verified end-to-end in car**. +- Latest `tskm` branch had a Panda error; older `tskm-0.10.2` also broken (three stripes black/gray/white screen). +- Diagnosed: `RuntimeError: CAN packet version mismatch` — the panda firmware is stale because TSKM kills boardd/pandad before they can flash it. The panda's `can_version` (firmware) doesn't match `CAN_PACKET_VERSION` (library). Different tskm versions show different library values: older tskm hardcoded `4`, current tskm uses `compute_version_hash()` which produces `1974202998`. +- Fix: added `panda.flash()` to `TSKExtractor.hack()` right after `panda = Panda()` in `tsk/common/extractor.py`. The `up_to_date()` guard makes it safe to call every run (no-ops in 0.0s if current). C3X uses the same `tsk.common.extractor`, so the fix covers both devices. +- Flash takes ~2.8s, no network required (firmware bundled locally). Reversible — when user installs nightly-dev/sunnypilot afterwards, pandad's signature check will reflash to that branch's version. +- End-to-end test passed: install release3 → panda flashed to release3 firmware (`can_version=4`) → install pre-fix tskm → got mismatch error as expected → install post-fix tskm → `panda.flash()` ran, extractor proceeded all the way to writing SecOC key to `/data/params/d/SecOCKey`. +- Bonus fix: `launch_chffrplus.sh` had an active `bash # Debug` line between `python3 tsk/main.py` and `sudo reboot`, which left the device in a debug shell after the user clicked "Install nightly-dev" instead of auto-rebooting. Commented it out so the install→reboot flow works end-to-end. + +### 2026-04-10 +Two users reported successful key extraction using `calvinpark/tskm` — fix confirmed in the wild. Plan: squash the tskm branch to a single commit and push to `optskug/tskm` to ship it. diff --git a/c3x b/c3x new file mode 100755 index 00000000..6e8882c4 --- /dev/null +++ b/c3x @@ -0,0 +1,2 @@ +#!/bin/sh +BIG=1 SCALE=1 .venv/bin/python tsk/main.py diff --git a/c4 b/c4 new file mode 100755 index 00000000..5ab26234 --- /dev/null +++ b/c4 @@ -0,0 +1,2 @@ +#!/bin/sh +SCALE=0.5 .venv/bin/python tsk/main.py diff --git a/launch_chffrplus.sh b/launch_chffrplus.sh index 5e7b4fa0..e5d0e5da 100755 --- a/launch_chffrplus.sh +++ b/launch_chffrplus.sh @@ -78,6 +78,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/c3/__init__.py b/tsk/c3/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tsk/c3/reboot_menu/__init__.py b/tsk/c3/reboot_menu/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tsk/c3/reboot_menu/actions.py b/tsk/c3/reboot_menu/actions.py new file mode 100644 index 00000000..41bf22f5 --- /dev/null +++ b/tsk/c3/reboot_menu/actions.py @@ -0,0 +1,136 @@ +# tsk/c3/reboot_menu/actions.py +import os +import shutil +import sys # Import the sys module + +from tsk.c3.ui.dialog import YesNoDialog +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 + + +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/c3/reboot_menu/ui.py b/tsk/c3/reboot_menu/ui.py new file mode 100644 index 00000000..1d91b70c --- /dev/null +++ b/tsk/c3/reboot_menu/ui.py @@ -0,0 +1,131 @@ +# tsk/c3/reboot_menu/ui.py +""" +Reboot Menu UI for TSK Manager. + +Pure Widget architecture - NO platform detection needed. +""" + +import pyray as rl + +from openpilot.system.ui.lib.application import gui_app +from tsk.c3.reboot_menu.actions import Rebooter +from tsk.c3.ui.button import TSKButton +from tsk.c3.ui.layout import Layout, Theme +from tsk.common.env import RECOMMENDED_OP_USER, RECOMMENDED_OP_BRANCH, ALTERNATE_OP_USER, ALTERNATE_OP_BRANCH +from tsk.common.widget import TSKWidget + + +class RebootMenuUI(TSKWidget): + """ + Reboot Menu widget with buttons for reboot actions. + + NO platform detection - pure Widget architecture. + """ + + def __init__(self): + super().__init__() + self.rebooter = Rebooter() + + # Buttons will be created on first render when we have the actual rect + self._buttons_created = False + self.recommended_button = None + self.alternate_button = None + self.bail_button = None + self.retry_button = None + + def _create_buttons(self, rect: rl.Rectangle, header_height: float): + """Create buttons with proper positioning based on available space.""" + # rect is already the menu area (header subtracted), so pass 0 for header_height + button_height = Layout.calculate_button_dimensions(rect.height, 0) + start_x, start_y = Layout.calculate_button_positions(rect, 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 reboot option buttons + self.recommended_button = TSKButton( + labels=[ + {"text": "Install", "x_offset": 70, "y_offset": 80}, + {"text": f"{RECOMMENDED_OP_USER}/", "x_offset": 70, "y_offset": 180}, + {"text": f"{RECOMMENDED_OP_BRANCH}", "x_offset": 70, "y_offset": 280}, + ], + click_callback=self.rebooter.recommended_action, + font_size=72, + width=600, + height=button_height + ) + self._recommended_rect = rl.Rectangle(button1_x, button_y, 600, button_height) + + self.alternate_button = TSKButton( + labels=[ + {"text": "Install", "x_offset": 70, "y_offset": 80}, + {"text": f"{ALTERNATE_OP_USER}/", "x_offset": 70, "y_offset": 180}, + {"text": f"{ALTERNATE_OP_BRANCH}", "x_offset": 70, "y_offset": 280}, + ], + click_callback=self.rebooter.alternate_action, + font_size=72, + width=600, + height=button_height + ) + self._alternate_rect = rl.Rectangle(button2_x, button_y, 600, button_height) + + self.bail_button = TSKButton( + labels=[ + {"text": "Install a", "x_offset": 70, "y_offset": 80}, + {"text": "different", "x_offset": 70, "y_offset": 180}, + {"text": "fork/branch", "x_offset": 70, "y_offset": 280}, + ], + click_callback=self.rebooter.bail_action, + font_size=72, + width=600, + height=button_height + ) + self._bail_rect = rl.Rectangle(button3_x, button_y, 600, button_height) + + # Create the "Retry" button + retry_button_width = 3 * 600 + 2 * 80 + retry_button_height = 200 + retry_button_x = (rect.width - retry_button_width) / 2 + rect.x + retry_button_y = button_y + button_height + 80 + + # Calculate centered text offset for retry button + retry_text = "Reboot to try again" + text_size = rl.measure_text_ex(gui_app.font(), retry_text, 90, 1.0) + retry_x_offset = (retry_button_width - text_size.x) / 2 + + self.retry_button = TSKButton( + labels=[{"text": retry_text, "x_offset": retry_x_offset, "y_offset": 60}], + click_callback=self.rebooter.retry_action, + font_size=72, + width=retry_button_width, + height=retry_button_height + ) + self._retry_rect = rl.Rectangle(retry_button_x, retry_button_y, retry_button_width, retry_button_height) + + self._buttons_created = True + + def render_with_header_height(self, rect: rl.Rectangle, header_height: float): + """Render the Reboot Menu with the actual header height.""" + # Create buttons on first render + if not self._buttons_created: + self._create_buttons(rect, header_height) + + # Render all buttons + if self.recommended_button: + self.recommended_button.render(self._recommended_rect) + if self.alternate_button: + self.alternate_button.render(self._alternate_rect) + if self.bail_button: + self.bail_button.render(self._bail_rect) + if self.retry_button: + self.retry_button.render(self._retry_rect) + + return None + + def _render(self, rect: rl.Rectangle): + """Render the Reboot Menu (fallback if called directly).""" + # Estimate header height if not provided + header_height = Theme.title_font_size * 1.5 + Theme.key_status_font_size * 1.5 + return self.render_with_header_height(rect, header_height) diff --git a/tsk/c3/tools_menu/__init__.py b/tsk/c3/tools_menu/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tsk/c3/tools_menu/actions.py b/tsk/c3/tools_menu/actions.py new file mode 100644 index 00000000..ecd216b6 --- /dev/null +++ b/tsk/c3/tools_menu/actions.py @@ -0,0 +1,79 @@ +# tsk/c3/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.common.extractor import NotAGNOSError, BoarddNotRunningError, RetryError, TSKExtractor +from tsk.c3.ui.dialog import OkayDialog +from tsk.c3.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, 70) + except NotAGNOSError as e: + message = str(e) + OkayDialog.ask(message, 70) + except (BoarddNotRunningError, RetryError) as e: + message = f"Can't talk to the car: {e}" + OkayDialog.ask(message, 70, 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/c3/tools_menu/keyboard.py b/tsk/c3/tools_menu/keyboard.py new file mode 100644 index 00000000..ab9338c0 --- /dev/null +++ b/tsk/c3/tools_menu/keyboard.py @@ -0,0 +1,244 @@ +# tsk/c3/tools_menu/keyboard.py +""" +TSK Keyboard Dialog for entering hex keys. + +Pure Widget architecture - NO platform detection needed. +""" + +import pyray as rl + +from openpilot.system.ui.lib.application import gui_app +from tsk.common.key_file_manager import KeyFileManager +from tsk.c3.ui.button import TSKButton +from tsk.c3.ui.measure_text import measure_text +from tsk.c3.ui.render_loop import render_loop + + +class KeyboardDialog: + """ + Full-screen keyboard dialog for entering hex keys. + + NO platform detection - pure Widget architecture. + """ + + 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 + self.dialog_open = True + + # Font and Color Definitions + self.font_size = 70 + self.key_status_font_size = 70 + self.keyboard_bg_color = rl.Color(51, 51, 51, 255) + self.keyboard_border_color = rl.BLACK + self.keyboard_border_thickness = 4 + self.x_button_text = " X " + self.x_button_text_color = rl.Color(150, 150, 150, 255) + self.input_box_bg_color = rl.BLACK + self.input_box_border_color = rl.WHITE + self.input_text_color_1 = rl.Color(120, 120, 120, 255) + self.input_text_color_2 = rl.DARKGRAY + + # Calculate Input Box Dimensions + widest_char = max("0123456789abcdef", key=lambda c: measure_text(c, self.font_size).x) + self.char_width = measure_text(widest_char, self.font_size).x + self.char_spacing = 5 + self.input_box_width = (self.char_width + self.char_spacing) * self.max_input_length + + # Calculate "X" Button Dimensions + x_text_size = measure_text(self.x_button_text, self.font_size) + self.x_button_width = int(x_text_size.x * 1.2) + self.x_button_height = int(x_text_size.y * 1.2) + + # 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]) + + # Create Widget buttons + self._create_widgets() + + def _create_widgets(self): + """Create Widget buttons.""" + # X button + self._x_button = TSKButton( + labels=self.x_button_text, + click_callback=self._on_x_click, + font_size=self.font_size, + width=self.x_button_width, + height=self.x_button_height + ) + + # Install button + self._install_button = TSKButton( + labels=[{"text": "Install this key", "x_offset": 50, "y_offset": 15}], + click_callback=self._on_install_click, + font_size=self.font_size + ) + + # Create keyboard key widgets + self._key_buttons = {} + for row_index, row in enumerate(self.keyboard_layout): + for key_index, key in enumerate(row): + key_id = f"{row_index}_{key_index}" + self._key_buttons[key_id] = TSKButton( + labels=key, + click_callback=lambda k=key: self._on_key_click(k), + font_size=self.font_size, + width=self.keyboard_button_width_row1 if row_index == 0 else self.keyboard_button_width_row2, + height=self.keyboard_button_height + ) + + def _on_x_click(self): + """Handle X button click.""" + self.dialog_open = False + + def _on_install_click(self): + """Handle install button click.""" + self.key_file_manager.install_key(self.input_text) + self.install_success = True + self.show_install_button = False + + def _on_key_click(self, key): + """Handle keyboard key click.""" + 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 + + def update_key_status(self): + """Update 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_keyboard(self, rect: rl.Rectangle) -> None: + """Draw the keyboard and handle key input.""" + 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) + + # Render button + key_id = f"{row_index}_{key_index}" + self._key_buttons[key_id].render(button_rect) + + # Draw border + rl.draw_rectangle_lines_ex(button_rect, self.keyboard_border_thickness, self.keyboard_border_color) + + def render_dialog(self): + """Render the keyboard dialog.""" + # Calculate vertical centering + keyboard_height = 2 * self.keyboard_button_height + available_height = gui_app.height - keyboard_height + total_content_height = 0 + + # Key Status Label + self.update_key_status() + key_status_text_size = measure_text(self.key_status_text, self.key_status_font_size) + total_content_height += key_status_text_size.y + + # Input Box + input_box_height = self.font_size * 1.5 + total_content_height += input_box_height + + # Remaining Characters Label / Install Button / Success Label + total_content_height += self.font_size + + 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 + rl.draw_text_ex(gui_app.font(), self.key_status_text, rl.Vector2(key_status_text_x, key_status_text_y), self.key_status_font_size, 0, rl.LIGHTGRAY) + + # Input Box + input_box_x = (gui_app.width - self.input_box_width) / 2 + input_box_y = key_status_text_y + key_status_text_size.y + 20 + rl.draw_rectangle(int(input_box_x), int(input_box_y), int(self.input_box_width), int(input_box_height), self.input_box_bg_color) + rl.draw_rectangle_lines(int(input_box_x), int(input_box_y), int(self.input_box_width), int(input_box_height), self.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 - measure_text("A", self.font_size).y) / 2 + x_offset = 0 + for i, char in enumerate(self.input_text): + color = self.input_text_color_1 if (i // 4) % 2 == 0 else self.input_text_color_2 + char_width = measure_text(char, self.font_size).x + rl.draw_text_ex(gui_app.font(), char, rl.Vector2(input_text_x + x_offset, input_text_y), self.font_size, 0, color) + x_offset += char_width + self.char_spacing + + # Remaining Characters Label / Install Button / Success Label + remaining_chars = self.max_input_length - len(self.input_text) + remaining_text_y = input_box_y + input_box_height + 10 + + if self.install_success: + # Success Label + success_text = "Success!" + success_text_size = measure_text(success_text, self.font_size) + 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), self.font_size, 0, rl.GREEN) + + elif self.show_install_button: + # Install Button + install_text = "Install this key" + install_text_size = measure_text(install_text, self.font_size) + install_button_width = install_text_size.x + 200 + install_button_height = install_text_size.y + 50 + 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) + + # Render install button + self._install_button.render(install_button_rect) + + else: + # Remaining Characters Label + remaining_text = f"{remaining_chars} characters left" + remaining_text_size = measure_text(remaining_text, self.font_size) + 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), self.font_size, 0, rl.DARKGRAY) + + # "X" Button (Top Right) + button_x = gui_app.width - self.x_button_width + button_y = 0 + x_button_rect = rl.Rectangle(button_x, button_y, self.x_button_width, self.x_button_height) + self._x_button.render(x_button_rect) + + # Keyboard + keyboard_x = 0 + keyboard_y = gui_app.height - 2 * self.keyboard_button_height + keyboard_width = gui_app.width + keyboard_height = 2 * self.keyboard_button_height + self.draw_keyboard(rl.Rectangle(keyboard_x, keyboard_y, keyboard_width, keyboard_height)) + + @staticmethod + def ask(): + """Display the TSK Keyboard dialog.""" + 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() + + for _ in render_loop(): + if not dialog.dialog_open or rl.window_should_close(): + break + + dialog.render_dialog() diff --git a/tsk/c3/tools_menu/payload.bin b/tsk/c3/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/c3/tools_menu/ui.py b/tsk/c3/tools_menu/ui.py new file mode 100644 index 00000000..2e43d4a0 --- /dev/null +++ b/tsk/c3/tools_menu/ui.py @@ -0,0 +1,120 @@ +# tsk/c3/tools_menu/ui.py +""" +Tools Menu UI for TSK Manager. + +Pure Widget architecture - NO platform detection needed. +""" + +import pyray as rl + +from openpilot.system.ui.lib.application import gui_app +from tsk.c3.tools_menu.actions import tsk_extractor_action, tsk_uninstaller_action, tsk_guide_action +from tsk.c3.tools_menu.keyboard import KeyboardDialog +from tsk.c3.ui.button import TSKButton +from tsk.c3.ui.layout import Layout, Theme +from tsk.common.widget import TSKWidget + + +class ToolsMenuUI(TSKWidget): + """ + Tools Menu widget with buttons for TSK tools. + + NO platform detection - pure Widget architecture. + """ + + def __init__(self): + super().__init__() + + # Buttons will be created on first render when we have the actual rect + self._buttons_created = False + self.extractor_button = None + self.keyboard_button = None + self.uninstaller_button = None + self.guide_button = None + + def _create_buttons(self, rect: rl.Rectangle, header_height: float): + """Create buttons with proper positioning based on available space.""" + # rect is already the menu area (header subtracted), so pass 0 for header_height + button_height = Layout.calculate_button_dimensions(rect.height, 0) + start_x, start_y = Layout.calculate_button_positions(rect, 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 with custom label positioning + self.extractor_button = TSKButton( + labels=[{"text": "TSK Extractor", "x_offset": 55, "y_offset": (button_height / 2) - 45}], + click_callback=tsk_extractor_action, + font_size=72, + width=600, + height=button_height + ) + self._extractor_rect = rl.Rectangle(button1_x, button_y, 600, button_height) + + self.keyboard_button = TSKButton( + labels=[{"text": "TSK Keyboard", "x_offset": 45, "y_offset": (button_height / 2) - 45}], + click_callback=KeyboardDialog.ask, + font_size=72, + width=600, + height=button_height + ) + self._keyboard_rect = rl.Rectangle(button2_x, button_y, 600, button_height) + + self.uninstaller_button = TSKButton( + labels=[{"text": "TSK Uninstaller", "x_offset": 25, "y_offset": (button_height / 2) - 45}], + click_callback=tsk_uninstaller_action, + font_size=72, + width=600, + height=button_height + ) + self._uninstaller_rect = rl.Rectangle(button3_x, button_y, 600, button_height) + + # Create the "Tell me what to do next" guide button + guide_button_width = 3 * 600 + 2 * 80 + guide_button_height = 200 + guide_button_x = (rect.width - guide_button_width) / 2 + rect.x + guide_button_y = button_y + button_height + 80 + + # Calculate centered text offset for guide button + guide_text = "Tell me what to do next" + text_size = rl.measure_text_ex(gui_app.font(), guide_text, 72, 1.0) + # This is so hand-crafted it might as well be a magic number. Oh well. + guide_x_offset = ((guide_button_width - text_size.x) / 2) - 80 + + self.guide_button = TSKButton( + labels=[{"text": guide_text, "x_offset": guide_x_offset, "y_offset": 60}], + click_callback=tsk_guide_action, + font_size=72, + width=guide_button_width, + height=guide_button_height + ) + self._guide_rect = rl.Rectangle(guide_button_x, guide_button_y, guide_button_width, guide_button_height) + + self._buttons_created = True + + def render_with_header_height(self, rect: rl.Rectangle, header_height: float): + """Render the Tools Menu with the actual header height.""" + # Create buttons on first render + if not self._buttons_created: + self._create_buttons(rect, header_height) + + # Render all buttons + if self.extractor_button: + self.extractor_button.render(self._extractor_rect) + if self.keyboard_button: + self.keyboard_button.render(self._keyboard_rect) + if self.uninstaller_button: + self.uninstaller_button.render(self._uninstaller_rect) + if self.guide_button: + self.guide_button.render(self._guide_rect) + + return None + + def _render(self, rect: rl.Rectangle): + """Render the Tools Menu (fallback if called directly).""" + # Estimate header height if not provided + header_height = Theme.title_font_size * 1.5 + Theme.key_status_font_size * 1.5 + return self.render_with_header_height(rect, header_height) diff --git a/tsk/c3/tsk_manager.py b/tsk/c3/tsk_manager.py new file mode 100644 index 00000000..a29ed783 --- /dev/null +++ b/tsk/c3/tsk_manager.py @@ -0,0 +1,66 @@ +# tsk/c3/tsk_manager.py +""" +TSK Manager main application. +""" + +import pyray as rl + +from tsk.c3.reboot_menu.ui import RebootMenuUI +from tsk.c3.tools_menu.ui import ToolsMenuUI +from tsk.c3.ui.header import TSKHeader +from tsk.c3.ui.layout import Theme +from tsk.common.widget import TSKWidget + + +class TSKManager(TSKWidget): + """ + Main TSK Manager application widget for C3X devices. + + This is the top-level widget that manages: + - Header with navigation + - Menu switching + - Overall layout + + For C3X devices only (tici/tizi). + """ + + def __init__(self): + super().__init__() + self._current_menu = Theme.menu_tools + + # Create child widgets + self.header = TSKHeader() + self.tools_menu = ToolsMenuUI() + self.reboot_menu = RebootMenuUI() + + def _render(self, rect: rl.Rectangle): + """Render the TSK Manager UI.""" + # Clear background + rl.clear_background(rl.BLACK) + + # Render header + header_height = self.header.get_height() + header_rect = rl.Rectangle(rect.x, rect.y, rect.width, header_height) + + # Update header's current menu + self.header.set_current_menu(self._current_menu) + + # Render header and get navigation result + nav_result = self.header.render(header_rect) + if nav_result is not None: + self._current_menu = nav_result + + # Render current menu + menu_rect = rl.Rectangle( + rect.x, + rect.y + header_height, + rect.width, + rect.height - header_height + ) + + if self._current_menu == Theme.menu_tools: + self.tools_menu.render_with_header_height(menu_rect, header_height) + elif self._current_menu == Theme.menu_reboot: + self.reboot_menu.render_with_header_height(menu_rect, header_height) + + return True diff --git a/tsk/c3/ui/__init__.py b/tsk/c3/ui/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tsk/c3/ui/button.py b/tsk/c3/ui/button.py new file mode 100644 index 00000000..07c0048b --- /dev/null +++ b/tsk/c3/ui/button.py @@ -0,0 +1,139 @@ +# tsk/c3/ui/button.py +""" +TSK Button widget. + +Provides TSK-styled buttons with automatic event handling through the Widget system. +NO platform detection needed - Widget handles everything cross-platform. +""" + +from typing import Callable, List, Dict, Any, Optional + +import pyray as rl + +from openpilot.system.ui.lib.application import gui_app +from tsk.c3.ui.measure_text import measure_text +from tsk.common.widget import TSKWidget + + +class TSKButton(TSKWidget): + """ + TSK-styled button widget. + + Features: + - TSK-specific styling (dark background, brightened text) + - Multi-label support with custom positioning + - Multi-line text support + - Automatic event handling via Widget system + + NO platform detection - pure Widget architecture. + """ + + def __init__( + self, + labels: List[Dict[str, Any]] | str, + click_callback: Optional[Callable[[], None]] = None, + font_size: int = 80, + width: int = 600, + height: int = 200, + multi_line: bool = False, + background_color: Optional[rl.Color] = None, # ADDED: Custom background color + ): + """ + Initialize TSKButton. + + Args: + labels: Either a list of label dicts with 'text', 'x_offset', 'y_offset', + or a simple string for single-line text (will be auto-centered) + click_callback: Function to call when button is clicked + font_size: Font size for button text + width: Button width (used for layout calculations) + height: Button height (used for layout calculations) + multi_line: If True and labels is a string, split on \\n for multi-line + background_color: Custom background color (defaults to gray if not provided) + """ + super().__init__() + + # Store original labels specification + self._labels_spec = labels + self._multi_line = multi_line + self._font_size = font_size + self._init_width = width + self._init_height = height + + # Use custom background color if provided, otherwise default gray + if background_color is not None: + self._background_color = background_color + else: + # Use gui_button-style colors (lighter background, white text) + self._background_color = rl.Color(75, 75, 75, 255) # Lighter gray to match gui_button + + self._text_color = rl.Color(240, 240, 240, 255) # Near-white text + + # Set click callback + if click_callback: + self.set_click_callback(click_callback) + + def _calculate_labels(self, width: float, height: float) -> List[Dict[str, Any]]: + """Calculate label positions based on actual button size.""" + if isinstance(self._labels_spec, str): + if self._multi_line and '\\n' in self._labels_spec: + # Multi-line text: split and stack vertically + lines = self._labels_spec.split('\\n') + labels = [] + line_height = self._font_size * 1.2 + total_height = len(lines) * line_height + start_y = (height - total_height) / 2 + + for i, line in enumerate(lines): + text_size = measure_text(line, self._font_size) + x_offset = (width - text_size.x) / 2 + y_offset = start_y + (i * line_height) + labels.append({ + 'text': line, + 'x_offset': x_offset, + 'y_offset': y_offset + }) + return labels + else: + # Single line: center it + text_size = measure_text(self._labels_spec, self._font_size) + x_offset = (width - text_size.x) / 2 + y_offset = (height - text_size.y) / 2 + return [{ + 'text': self._labels_spec, + 'x_offset': x_offset, + 'y_offset': y_offset + }] + else: + # Custom label positioning - return as-is + return self._labels_spec + + def _render(self, rect: rl.Rectangle): + """ + Render the TSK button. + + Widget system automatically handles: + - Mouse/touch events + - Press state + - Click callbacks + + We just need to draw the button appearance. + """ + # Draw button background with rounded corners + rl.draw_rectangle_rounded(rect, 0.1, 10, self._background_color) + + # Calculate labels based on actual rect size + labels = self._calculate_labels(rect.width, rect.height) + + # Draw all labels + for label_data in labels: + rl.draw_text_ex( + gui_app.font(), + label_data['text'], + rl.Vector2(rect.x + label_data['x_offset'], rect.y + label_data['y_offset']), + self._font_size, + 1.0, + self._text_color + ) + + return None diff --git a/tsk/c3/ui/dialog.py b/tsk/c3/ui/dialog.py new file mode 100644 index 00000000..dc6eb0b0 --- /dev/null +++ b/tsk/c3/ui/dialog.py @@ -0,0 +1,216 @@ +# tsk/c3/ui/dialog.py +""" +TSK Dialog widgets for full-screen dialogs with scrollable text. + +Pure Widget architecture - NO platform detection needed. +""" + +from typing import Optional + +import pyray as rl + +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel +from tsk.c3.ui.button import TSKButton +from tsk.c3.ui.measure_text import measure_text +from tsk.c3.ui.render_loop import render_loop +from tsk.common.widget import TSKWidget + +# Default font size for dialog buttons +DEFAULT_BUTTON_FONT_SIZE = 50 + + +class BaseDialog(TSKWidget): + """ + Base class for full-screen dialogs with scrollable text area. + + Provides common functionality for all dialog types: + - Scrollable text area + - Text wrapping + - Layout management + + NO platform detection - pure Widget architecture. + """ + + BORDER_SIZE = 20 + BUTTON_HEIGHT = 80 + BUTTON_WIDTH = 310 + BUTTON_SPACING = 20 + FONT_SIZE = 70 + 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): + super().__init__() + 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 + ) + 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() + self.scroll_offset = rl.Vector2(0, 0) + self.initial_scroll_applied = False + + def render_text_area(self): + """Render the scrollable text area.""" + scroll_y = self.scroll_panel.update(self.textarea_rect, self.content_rect) + scroll = rl.Vector2(0, scroll_y) + self.scroll_offset = scroll + + # 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): + """Wrap text to fit within 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("") + continue + + current_line = "" + for word in block.split(): + test_line = current_line + word + " " + if measure_text(test_line, font_size).x <= max_width: + current_line = test_line + else: + lines.append(current_line) + current_line = word + " " + if current_line: + lines.append(current_line) + + return lines + + def _render(self, rect: rl.Rectangle): + """Base render - subclasses should override.""" + pass + + +class OkayDialog(BaseDialog): + """ + Full-screen dialog with scrollable text and an Okay button. + + NO platform detection - pure Widget architecture. + """ + + def __init__(self, body_text: str, font_size: int = BaseDialog.FONT_SIZE, scroll_to_bottom: bool = False, okay_text: str = "Okay"): + super().__init__(body_text, font_size, scroll_to_bottom) + self.okay_text = okay_text + self.okay_clicked = False + + # Create okay button with green background + self._okay_button = TSKButton( + labels=[{"text": okay_text, "x_offset": 90, "y_offset": 10}], + click_callback=self._on_okay, + font_size=DEFAULT_BUTTON_FONT_SIZE, + width=self.BUTTON_WIDTH, + height=self.BUTTON_HEIGHT, + background_color=rl.Color(20, 100, 20, 255) # Green background + ) + + def _on_okay(self): + """Handle okay button click.""" + self.okay_clicked = True + + @staticmethod + def ask(body_text: str, font_size: int = BaseDialog.FONT_SIZE, scroll_to_bottom: bool = False, okay_text: str = "Okay") -> None: + """Display a full-screen Okay dialog.""" + dialog = OkayDialog(body_text, font_size, scroll_to_bottom, okay_text) + + for _ in render_loop(): + if dialog.okay_clicked or rl.window_should_close(): + break + + dialog.render_text_area() + + # Calculate button position + button_area_height = gui_app.height - dialog.textarea_rect.height - dialog.textarea_rect.y + 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 + button_rect = rl.Rectangle(button_x, button_y, BaseDialog.BUTTON_WIDTH, BaseDialog.BUTTON_HEIGHT) + + # Render button (background color is handled by TSKButton now) + dialog._okay_button.render(button_rect) + + +class YesNoDialog(BaseDialog): + """ + Full-screen dialog with scrollable text and Yes/No buttons. + + NO platform detection - pure Widget architecture. + """ + + def __init__(self, body_text: str, font_size: int = BaseDialog.FONT_SIZE, scroll_to_bottom: bool = False, yes_text: str = "Yes", no_text: str = "No"): + super().__init__(body_text, font_size, scroll_to_bottom) + self.yes_text = yes_text + self.no_text = no_text + self.result = None + + # Create yes/no buttons with colored backgrounds + self._yes_button = TSKButton( + labels=[{"text": yes_text, "x_offset": 110, "y_offset": 10}], + click_callback=lambda: self._set_result(True), + font_size=DEFAULT_BUTTON_FONT_SIZE, + width=self.BUTTON_WIDTH, + height=self.BUTTON_HEIGHT, + background_color=rl.Color(20, 100, 20, 255) # Green background + ) + self._no_button = TSKButton( + labels=[{"text": no_text, "x_offset": 120, "y_offset": 10}], + click_callback=lambda: self._set_result(False), + font_size=DEFAULT_BUTTON_FONT_SIZE, + width=self.BUTTON_WIDTH, + height=self.BUTTON_HEIGHT, + background_color=rl.Color(100, 20, 20, 255) # Red background + ) + + def _set_result(self, value: bool): + """Handle button clicks.""" + self.result = value + + @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") -> Optional[bool]: + """Display a full-screen Yes/No dialog and return user's choice.""" + dialog = YesNoDialog(body_text, font_size, scroll_to_bottom, yes_text, no_text) + + for _ in render_loop(): + if dialog.result is not None or rl.window_should_close(): + break + + dialog.render_text_area() + + # Calculate button positions + button_top = gui_app.height - BaseDialog.BORDER_SIZE - BaseDialog.BUTTON_HEIGHT + no_button_rect = rl.Rectangle(BaseDialog.BORDER_SIZE, button_top, BaseDialog.BUTTON_WIDTH, BaseDialog.BUTTON_HEIGHT) + yes_button_rect = rl.Rectangle(gui_app.width - BaseDialog.BORDER_SIZE - BaseDialog.BUTTON_WIDTH, button_top, BaseDialog.BUTTON_WIDTH, BaseDialog.BUTTON_HEIGHT) + + # Render buttons (background colors are handled by TSKButton now) + dialog._no_button.render(no_button_rect) + dialog._yes_button.render(yes_button_rect) + + return dialog.result diff --git a/tsk/c3/ui/header.py b/tsk/c3/ui/header.py new file mode 100644 index 00000000..88873fe5 --- /dev/null +++ b/tsk/c3/ui/header.py @@ -0,0 +1,192 @@ +# tsk/c3/ui/header.py +""" +TSK Header widget with navigation and status display. + +Pure Widget architecture - NO platform detection needed. +""" + +from typing import Optional + +import pyray as rl + +from openpilot.system.ui.lib.application import gui_app +from tsk.c3.ui.button import TSKButton +from tsk.c3.ui.measure_text import measure_text +from tsk.c3.ui.layout import Theme +from tsk.common.key_file_manager import KeyFileManager +from tsk.common.widget import TSKWidget + + +class TSKHeader(TSKWidget): + """ + Header widget for TSK Manager. + + Displays: + - Title with current menu name + - Navigation buttons (left/right based on current menu) + - Key installation status + + NO platform detection needed - Widget handles everything. + """ + + def __init__(self): + super().__init__() + self.key_manager = KeyFileManager() + self._current_menu = Theme.menu_tools + self._nav_result = None + + # Create navigation buttons (TSKButton handles events automatically) + self._nav_left = TSKButton( + labels=[ + {"text": "< Tools", "x_offset": 20, "y_offset": 30}, + {"text": "< Menu", "x_offset": 20, "y_offset": 100}, + ], + click_callback=lambda: self._set_nav(Theme.menu_tools), + font_size=Theme.nav_button_font_size, + multi_line=True + ) + + self._nav_right = TSKButton( + labels=[ + {"text": "Reboot >", "x_offset": 43, "y_offset": 30}, + {"text": "Menu >", "x_offset": 90, "y_offset": 100}, + ], + click_callback=lambda: self._set_nav(Theme.menu_reboot), + font_size=Theme.nav_button_font_size, + multi_line=True + ) + + def _set_nav(self, menu_id: int): + """Set navigation result when button is clicked.""" + self._nav_result = menu_id + + def get_height(self) -> float: + """Calculate the total height of the header.""" + title_height = measure_text("TSK Manager: ", Theme.title_font_size).y * 1.5 + key_status_height = Theme.key_status_font_size * 1.5 + return title_height + key_status_height + + def set_current_menu(self, menu_id: int): + """Update the current menu for display.""" + self._current_menu = menu_id + + def _render(self, rect: rl.Rectangle) -> Optional[int]: + """ + Render the header. + + Returns: + New menu ID if navigation button was clicked, None otherwise + """ + title_height = measure_text("TSK Manager: ", Theme.title_font_size).y * 1.5 + key_status_height = Theme.key_status_font_size * 1.5 + + # Draw title strip + title_rect = rl.Rectangle(rect.x, rect.y, rect.width, title_height) + rl.draw_rectangle_rec(title_rect, Theme.title_bg_color) + self._draw_title(title_rect) + + # Draw key status strip + key_status_y = rect.y + title_height + key_status_rect = rl.Rectangle( + rect.x, + key_status_y, + rect.width, + key_status_height + ) + rl.draw_rectangle_rec(key_status_rect, Theme.key_bg_color) + self._draw_key_status(key_status_rect) + + # Draw navigation buttons + self._nav_result = None + self._draw_navigation_buttons(rect, title_height, key_status_height) + + return self._nav_result + + def _draw_title(self, rect: rl.Rectangle): + """Draw the title text.""" + # Use white text for header title + title_text_color = Theme.brighten_color(Theme.title_bg_color, Theme.brighten_amount) + menu_name = Theme.menu_names.get(self._current_menu, "Unknown Menu") + prefix = "TSK Manager: " + + # Measure and position text + prefix_size = measure_text(prefix, Theme.title_font_size) + + prefix_x = rect.width * Theme.title_prefix_x_offset_percent + Theme.title_x_offset + menu_name_x = prefix_x + prefix_size.x + + full_text = f"{prefix}{menu_name}" + full_size = measure_text(full_text, Theme.title_font_size) + text_y = rect.y + (rect.height - full_size.y) / 2 + + # Draw prefix + rl.draw_text_ex( + gui_app.font(), + prefix, + rl.Vector2(prefix_x, text_y), + Theme.title_font_size, + 0, + title_text_color + ) + + # Draw menu name + rl.draw_text_ex( + gui_app.font(), + menu_name, + rl.Vector2(menu_name_x, text_y), + Theme.title_font_size, + 0, + title_text_color + ) + + def _draw_key_status(self, rect: rl.Rectangle): + """Draw the key installation status.""" + if self.key_manager.installed_key: + status_text = f"Key installed: {self.key_manager.installed_key}" + else: + status_text = "Key not installed" + + text_size = measure_text(status_text, Theme.key_status_font_size) + + # Center text + 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(), + status_text, + rl.Vector2(text_x, text_y), + Theme.key_status_font_size, + 0, + Theme.key_text_color + ) + + def _draw_navigation_buttons( + self, + rect: rl.Rectangle, + title_height: float, + key_status_height: float + ): + """Draw navigation buttons overlapping the header.""" + # Calculate button dimensions + button_height = title_height + key_status_height + + # Calculate button width based on text + left_text_size = measure_text(Theme.nav_button_text_left.replace('\\n', ' '), Theme.nav_button_font_size) + right_text_size = measure_text(Theme.nav_button_text_right.replace('\\n', ' '), Theme.nav_button_font_size) + button_width = max(left_text_size.x, right_text_size.x) + 70 + + # Show appropriate button based on current menu + if self._current_menu == Theme.menu_reboot: + # Show left button (back to tools) + left_rect = rl.Rectangle(rect.x, rect.y, button_width, button_height) + self._nav_left.render(left_rect) + elif self._current_menu == Theme.menu_tools: + # Show right button (go to reboot) + right_rect = rl.Rectangle( + rect.x + rect.width - button_width, + rect.y, + button_width, + button_height + ) + self._nav_right.render(right_rect) diff --git a/tsk/c3/ui/layout.py b/tsk/c3/ui/layout.py new file mode 100644 index 00000000..c843f589 --- /dev/null +++ b/tsk/c3/ui/layout.py @@ -0,0 +1,76 @@ +# tsk/c3/ui/layout.py +import pyray as rl + +class Theme: + """Defines the visual theme and constants for the TSK Manager.""" + + # --- Colors --- + key_text_color = rl.Color(255, 255, 255, 255) + key_bg_color = rl.Color(40, 40, 40, 255) + button_color = rl.Color(50, 50, 50, 255) + title_bg_color = rl.Color(60, 60, 60, 255) + nav_text_color = rl.Color(255, 255, 255, 255) + + # --- Font Sizes --- + title_font_size = 70 + nav_button_font_size = 60 + key_status_font_size = 50 + + # --- 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, num_buttons: int) -> tuple: + """ + Calculates the starting positions for a row of buttons. + + Args: + rect: The rectangle area for the menu (already positioned after header) + num_buttons: Number of buttons to position horizontally + + Returns: + Tuple of (start_x, start_y) for the first button position + """ + total_width = (num_buttons * 600) + ((num_buttons - 1) * 80) + start_x = (rect.width - total_width) / 2 + rect.x + start_y = rect.y + 80 # Top spacer + return start_x, start_y diff --git a/tsk/c3/ui/measure_text.py b/tsk/c3/ui/measure_text.py new file mode 100644 index 00000000..6d89291a --- /dev/null +++ b/tsk/c3/ui/measure_text.py @@ -0,0 +1,18 @@ +# tsk/c3/ui/measure_text.py +""" +Text measurement that matches actual rendered size. + +openpilot patches rl.draw_text_ex to multiply font_size by FONT_SCALE, +but rl.measure_text_ex is NOT patched. This causes centering calculations +to underreport text width, pushing centered text to the right. + +This function measures at the scaled size so centering is correct. +""" + +import pyray as rl + +from openpilot.system.ui.lib.application import gui_app, FONT_SCALE + + +def measure_text(text, font_size): + return rl.measure_text_ex(gui_app.font(), text, font_size * FONT_SCALE, 0) diff --git a/tsk/c3/ui/render_loop.py b/tsk/c3/ui/render_loop.py new file mode 100644 index 00000000..a58ed769 --- /dev/null +++ b/tsk/c3/ui/render_loop.py @@ -0,0 +1,41 @@ +# tsk/c3/ui/render_loop.py +""" +Forked render loop for C3X, decoupled from gui_app.render(). + +WHY THIS EXISTS: + gui_app.render() has been the #1 source of breakage across openpilot + versions (120 commits between 2025-01 and 2026-03). Key breaking changes: + - v0.10.x: Added modal overlay yield (should_render_main) + - v0.11.x: Added rl_push_matrix/rl_scalef for SCALE support, + removed modal overlay in favor of nav stack + C3X dialogs use blocking render loops (OkayDialog.ask(), YesNoDialog.ask()) + that call gui_app.render() inside the main gui_app.render() loop. This + nesting applies the scale matrix twice, shrinking the UI to 1/16th size. + + This function provides a minimal render loop using pure raylib calls. + It handles mouse events for the Widget system but does NOT apply scale + matrices, making it safe to nest (dialogs inside main loop). + +USAGE: + from tsk.c3.ui.render_loop import render_loop + for _ in render_loop(): + widget.render(rect) +""" + +import pyray as rl + +from openpilot.system.ui.lib.application import gui_app, PC + + +def render_loop(): + while not rl.window_should_close(): + if PC: + gui_app._mouse._handle_mouse_event() + gui_app._mouse_events = gui_app._mouse.get_events() + if gui_app._mouse_events: + gui_app._last_mouse_event = gui_app._mouse_events[-1] + + rl.begin_drawing() + rl.clear_background(rl.BLACK) + yield True + rl.end_drawing() diff --git a/tsk/c4/__init__.py b/tsk/c4/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tsk/c4/menu_0_tools/__init__.py b/tsk/c4/menu_0_tools/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tsk/c4/menu_0_tools/btn_0_extractor.py b/tsk/c4/menu_0_tools/btn_0_extractor.py new file mode 100644 index 00000000..563c048a --- /dev/null +++ b/tsk/c4/menu_0_tools/btn_0_extractor.py @@ -0,0 +1,45 @@ +import traceback + +from openpilot.system.ui.lib.application import gui_app +from tsk.common.extractor import TSKExtractor, NotAGNOSError, BoarddNotRunningError, RetryError +from tsk.c4.ui import ScalableBigButton, Layout, ScrollableBigDialog +from tsk.common.key_file_manager import KeyFileManager + + +class Extractor(ScalableBigButton): + def __init__(self): + super().__init__( + "TSK Extractor", + click_callback=self.click, + font_size=Layout.tools_row_button_font_size, + ) + + @staticmethod + def click(): + 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 your key." + dialog = ScrollableBigDialog(description=message) + gui_app.push_widget(dialog) + except NotAGNOSError as e: + message = str(e) + dialog = ScrollableBigDialog(description=message) + gui_app.push_widget(dialog) + except (BoarddNotRunningError, RetryError) as e: + message = f"Can't talk to the car: {e}" + dialog = ScrollableBigDialog(description=message) + gui_app.push_widget(dialog) + 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() + dialog = ScrollableBigDialog( + description=message, + desc_font_size=20, + scroll_to_bottom=True, + ) + gui_app.push_widget(dialog) diff --git a/tsk/c4/menu_0_tools/btn_1_keyboard.py b/tsk/c4/menu_0_tools/btn_1_keyboard.py new file mode 100644 index 00000000..09a0f4d8 --- /dev/null +++ b/tsk/c4/menu_0_tools/btn_1_keyboard.py @@ -0,0 +1,14 @@ +from tsk.c4.ui import ScalableBigButton, Layout + + +class Keyboard(ScalableBigButton): + def __init__(self): + super().__init__( + "TSK Keyboard", + click_callback=self.click, + font_size=Layout.tools_row_button_font_size, + ) + + @staticmethod + def click(): + print("TSK Keyboard clicked") diff --git a/tsk/c4/menu_0_tools/btn_2_uninstaller.py b/tsk/c4/menu_0_tools/btn_2_uninstaller.py new file mode 100644 index 00000000..0080378f --- /dev/null +++ b/tsk/c4/menu_0_tools/btn_2_uninstaller.py @@ -0,0 +1,37 @@ +from openpilot.system.ui.lib.application import gui_app +from tsk.c4.ui import ScalableBigButton, Layout, ScrollableBigDialog, ScrollableConfirmDialog +from tsk.common.key_file_manager import KeyFileManager + + +class Uninstaller(ScalableBigButton): + def __init__(self): + super().__init__( + "TSK Uninstaller", + click_callback=self.click, + font_size=Layout.tools_row_button_font_size, + ) + + @staticmethod + def click(): + key_manager = KeyFileManager() + key = key_manager.installed_key + + if not key: + message = "Key not installed.\n\n" \ + "Nothing to do." + dialog = ScrollableBigDialog(description=message) + gui_app.push_widget(dialog) + return + + message = f"Key installed: {key}\n\n" \ + "Uninstall?" + dialog = ScrollableConfirmDialog( + description=message, + confirm_callback=Uninstaller._do_reboot + ) + gui_app.push_widget(dialog) + + @staticmethod + def _do_reboot(): + key_manager = KeyFileManager() + key_manager.uninstall_key() diff --git a/tsk/c4/menu_0_tools/btn_3_guide.py b/tsk/c4/menu_0_tools/btn_3_guide.py new file mode 100644 index 00000000..fec6e8ab --- /dev/null +++ b/tsk/c4/menu_0_tools/btn_3_guide.py @@ -0,0 +1,37 @@ +from openpilot.system.ui.lib.application import gui_app +from tsk.c4.ui import ScalableBigButton, Layout, ScrollableBigDialog +from tsk.common.env import RECOMMENDED_OP_USER, RECOMMENDED_OP_BRANCH, is_in_car, is_cache_dir_new +from tsk.common.key_file_manager import KeyFileManager + + +class Guide(ScalableBigButton): + def __init__(self): + super().__init__( + "Tell me what to do next", + click_callback=self.click, + font_size=Layout.tools_row_button_font_size, + ) + + @staticmethod + def click(): + """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." + + dialog = ScrollableBigDialog(description=text) + gui_app.push_widget(dialog) diff --git a/tsk/c4/menu_1_reboot/__init__.py b/tsk/c4/menu_1_reboot/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tsk/c4/menu_1_reboot/btn_0_recommended.py b/tsk/c4/menu_1_reboot/btn_0_recommended.py new file mode 100644 index 00000000..80c98efb --- /dev/null +++ b/tsk/c4/menu_1_reboot/btn_0_recommended.py @@ -0,0 +1,50 @@ +import shutil +import sys + +from openpilot.system.ui.lib.application import gui_app +from tsk.c4.ui import ScalableBigButton, Layout, ScrollableConfirmDialog +from tsk.common.env import OPENPILOT_DIR, RECOMMENDED_OP_DIR, ALTERNATE_OP_DIR, RECOMMENDED_OP_USER, \ + RECOMMENDED_OP_BRANCH +from tsk.common.key_file_manager import KeyFileManager + + +class Recommended(ScalableBigButton): + def __init__(self): + super().__init__( + f"Install {RECOMMENDED_OP_USER}/ {RECOMMENDED_OP_BRANCH}", + click_callback=self.click, + font_size=Layout.reboot_row_button_font_size, + ) + + @staticmethod + def click(): + # Build confirmation message + key = KeyFileManager().installed_key + if key: + message = f"Key installed: {key}\n\n" + else: + message = "!!!! Key not installed.\n" \ + "!!!! Comma can't drive your car.\n\n" + message += f"Reboot and install {RECOMMENDED_OP_USER}/{RECOMMENDED_OP_BRANCH}?" + + dialog = ScrollableConfirmDialog( + description=message, + confirm_callback=Recommended._do_reboot + ) + gui_app.push_widget(dialog) + + @staticmethod + def _do_reboot(): + # Remove /data/openpilot since it won't be used + shutil.rmtree(OPENPILOT_DIR, ignore_errors=True) + print(f"Removed {OPENPILOT_DIR}") + + # Remove /data/tsk-alternate since it won't be used + shutil.rmtree(ALTERNATE_OP_DIR, ignore_errors=True) + print(f"Removed {ALTERNATE_OP_DIR}") + + # Move /data/tsk-alternate to /data/openpilot + shutil.move(RECOMMENDED_OP_DIR, OPENPILOT_DIR) + print(f"Moved {RECOMMENDED_OP_DIR} to {OPENPILOT_DIR}") + + sys.exit(0) diff --git a/tsk/c4/menu_1_reboot/btn_1_alternate.py b/tsk/c4/menu_1_reboot/btn_1_alternate.py new file mode 100644 index 00000000..5318107d --- /dev/null +++ b/tsk/c4/menu_1_reboot/btn_1_alternate.py @@ -0,0 +1,49 @@ +import shutil +import sys + +from openpilot.system.ui.lib.application import gui_app +from tsk.c4.ui import ScalableBigButton, Layout, ScrollableConfirmDialog +from tsk.common.env import OPENPILOT_DIR, RECOMMENDED_OP_DIR, ALTERNATE_OP_DIR, ALTERNATE_OP_USER, ALTERNATE_OP_BRANCH +from tsk.common.key_file_manager import KeyFileManager + + +class Alternate(ScalableBigButton): + def __init__(self): + super().__init__( + f"Install {ALTERNATE_OP_USER}/ {ALTERNATE_OP_BRANCH}", + click_callback=self.click, + font_size=Layout.reboot_row_button_font_size, + ) + + @staticmethod + def click(): + # Build confirmation message + key = KeyFileManager().installed_key + if key: + message = f"Key installed: {key}\n\n" + else: + message = "!!!! Key not installed.\n" \ + "!!!! Comma can't drive your car.\n\n" + message += f"Reboot and install {ALTERNATE_OP_USER}/{ALTERNATE_OP_BRANCH}?" + + dialog = ScrollableConfirmDialog( + description=message, + confirm_callback=Alternate._do_reboot + ) + gui_app.push_widget(dialog) + + @staticmethod + def _do_reboot(): + # Remove /data/openpilot since it won't be used + shutil.rmtree(OPENPILOT_DIR, ignore_errors=True) + print(f"Removed {OPENPILOT_DIR}") + + # Remove /data/tsk-recommended since it won't be used + shutil.rmtree(RECOMMENDED_OP_DIR, ignore_errors=True) + print(f"Removed {RECOMMENDED_OP_DIR}") + + # Move /data/tsk-alternate to /data/openpilot + shutil.move(ALTERNATE_OP_DIR, OPENPILOT_DIR) + print(f"Moved {ALTERNATE_OP_DIR} to {OPENPILOT_DIR}") + + sys.exit(0) diff --git a/tsk/c4/menu_1_reboot/btn_2_somethingelse.py b/tsk/c4/menu_1_reboot/btn_2_somethingelse.py new file mode 100644 index 00000000..6855641a --- /dev/null +++ b/tsk/c4/menu_1_reboot/btn_2_somethingelse.py @@ -0,0 +1,54 @@ +import os +import shutil +import sys + +from openpilot.system.ui.lib.application import gui_app +from tsk.c4.ui import ScalableBigButton, Layout, ScrollableConfirmDialog +from tsk.common.env import is_agnos, RECOMMENDED_OP_DIR, ALTERNATE_OP_DIR, CONTINUE_FILE +from tsk.common.key_file_manager import KeyFileManager + + +class SomethingElse(ScalableBigButton): + def __init__(self): + super().__init__( + "Install a different fork/branch", + click_callback=self.click, + font_size=Layout.reboot_row_button_font_size, + ) + + @staticmethod + def click(): + # Build confirmation message + key = KeyFileManager().installed_key + if key: + message = f"Key installed: {key}\n\n" + else: + message = "!!!! Key not installed.\n" \ + "!!!! Comma can't drive your car.\n\n" + message += "Reboot and install a different fork/branch?" + + dialog = ScrollableConfirmDialog( + description=message, + confirm_callback=SomethingElse._do_reboot + ) + gui_app.push_widget(dialog) + + @staticmethod + def _do_reboot(): + # Remove /data/tsk-recommended since it won't be used + shutil.rmtree(RECOMMENDED_OP_DIR, ignore_errors=True) + print(f"Removed {RECOMMENDED_OP_DIR}") + + # Remove /data/tsk-alternate since it won't be used + 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 is_agnos(): + if os.path.exists(CONTINUE_FILE): + os.remove(CONTINUE_FILE) + print(f"Removed {CONTINUE_FILE}") + + sys.exit(0) diff --git a/tsk/c4/menu_1_reboot/btn_3_reboot.py b/tsk/c4/menu_1_reboot/btn_3_reboot.py new file mode 100644 index 00000000..0899df3d --- /dev/null +++ b/tsk/c4/menu_1_reboot/btn_3_reboot.py @@ -0,0 +1,38 @@ +import sys + +from openpilot.system.ui.lib.application import gui_app +from tsk.c4.ui import ScalableBigButton, Layout, ScrollableConfirmDialog +from tsk.common.key_file_manager import KeyFileManager + + +class Reboot(ScalableBigButton): + def __init__(self): + super().__init__( + "Reboot to try again", + click_callback=self.click, + font_size=Layout.reboot_row_button_font_size, + ) + + @staticmethod + def click(): + """Action to perform when the 'Reboot to try again' button is pressed.""" + # Build confirmation message + key = KeyFileManager().installed_key + if key: + message = f"Key installed: {key}\n\n" + else: + message = "!!!! Key not installed.\n\n" + message += "Reboot without changing anything?" + + dialog = ScrollableConfirmDialog( + description=message, + confirm_callback=Reboot._do_reboot + ) + gui_app.push_widget(dialog) + + @staticmethod + def _do_reboot(): + """Actually perform the reboot.""" + print("Reboot confirmed - exiting to trigger reboot") + # Do nothing - just exit, which triggers a reboot + sys.exit(0) diff --git a/tsk/c4/tsk_manager.py b/tsk/c4/tsk_manager.py new file mode 100644 index 00000000..07133821 --- /dev/null +++ b/tsk/c4/tsk_manager.py @@ -0,0 +1,197 @@ +# tsk/c4/tsk_manager.py +""" +TSK Manager main application for C4 (mici) device. + +Features: +- Fixed top banner showing key installation status (clickable) +- Vertical scroller with two horizontal scrollers (one for each row of buttons) + +Uses custom button with BigButton graphics that scales properly. +""" + +import pyray as rl + +from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.widgets import Widget +from tsk.common.widget import Scroller +from tsk.c4.menu_0_tools.btn_0_extractor import Extractor +from tsk.c4.menu_0_tools.btn_1_keyboard import Keyboard +from tsk.c4.menu_0_tools.btn_2_uninstaller import Uninstaller +from tsk.c4.menu_0_tools.btn_3_guide import Guide +from tsk.c4.menu_1_reboot.btn_0_recommended import Recommended +from tsk.c4.menu_1_reboot.btn_1_alternate import Alternate +from tsk.c4.menu_1_reboot.btn_2_somethingelse import SomethingElse +from tsk.c4.menu_1_reboot.btn_3_reboot import Reboot +from tsk.c4.ui import Layout, ScrollableBigDialog +from tsk.common.key_file_manager import KeyFileManager +from tsk.common.widget import TSKWidget + + +class KeyStatusBanner(Widget): + """ + Top banner showing key installation status. + Acts as a button - click to see details. + """ + def __init__(self, key_manager: KeyFileManager): + super().__init__() + self._key_manager = key_manager + self.set_rect(rl.Rectangle(0, 0, gui_app.width, Layout.banner_height)) + + def _get_status_text(self) -> str: + """Get the current key status text.""" + if self._key_manager.installed_key: + return "Key installed" + else: + return "Key not installed" + + def _handle_mouse_release(self, mouse_pos): + """Handle click on the banner.""" + if self._key_manager.installed_key: + # Show the installed key + self._show_installed_key_dialog() + else: + # Show instruction to run TSK Extractor + self._show_no_key_dialog() + return True + + def _show_no_key_dialog(self): + """Show dialog when no key is installed.""" + dialog = ScrollableBigDialog( + description="Run TSK Extractor to get your key" + ) + gui_app.push_widget(dialog) + + def _show_installed_key_dialog(self): + """Show dialog displaying the installed key.""" + exploded_key = ( + self._key_manager.installed_key[0:4] + ' ' + + self._key_manager.installed_key[4:8] + ' ' + + self._key_manager.installed_key[8:12] + ' ' + + self._key_manager.installed_key[12:16] + '\n' + + self._key_manager.installed_key[16:20] + ' ' + + self._key_manager.installed_key[20:24] + ' ' + + self._key_manager.installed_key[24:28] + ' ' + + self._key_manager.installed_key[28:32] + ) + dialog = ScrollableBigDialog( + title='Installed Key', + description=exploded_key + ) + gui_app.push_widget(dialog) + + def _render(self, rect: rl.Rectangle): + """Render the key status banner.""" + # Background color: dark gray, slightly lighter when pressed + bg_color = rl.Color(60, 60, 60, 255) if self.is_pressed else rl.Color(50, 50, 50, 255) + rl.draw_rectangle_rec(rect, bg_color) + + # Draw status text (centered) + status_text = self._get_status_text() + font = gui_app.font(FontWeight.MEDIUM) + font_size = 28 + text_size = rl.measure_text_ex(font, status_text, font_size, 0) + + text_x = rect.x + (rect.width - text_size.x) / 2 + text_y = rect.y + (rect.height - text_size.y) / 2 + + # Color: green if key installed, yellow if not + text_color = rl.Color(100, 255, 100, 255) if self._key_manager.installed_key else rl.Color(255, 200, 0, 255) + + rl.draw_text_ex(font, status_text, rl.Vector2(text_x, text_y), font_size, 0, text_color) + + # Draw a subtle bottom border + rl.draw_line(int(rect.x), int(rect.y + rect.height - 1), + int(rect.x + rect.width), int(rect.y + rect.height - 1), + rl.Color(80, 80, 80, 255)) + + return True + + +class TSKManager(TSKWidget): + """ + TSK Manager for C4 (mici) device. + + Layout: + - Fixed top banner: Key status (clickable) + - Vertical scroller with two horizontal scrollers (one for each row) + """ + + def __init__(self): + super().__init__() + + # Initialize key manager + self.key_manager = KeyFileManager() + + # Create top banner (fixed, not scrollable) + self.key_banner = KeyStatusBanner(self.key_manager) + + # Create two horizontal scrollers + self.tools_scroller = Scroller( + [ + Extractor(), + # Keyboard(), + Uninstaller(), + Guide(), + ], + horizontal=True, + snap_items=False, + spacing=Layout.scroller_spacing, + pad=Layout.scroller_padding, + scroll_indicator=False + ) + self.tools_scroller.set_rect(rl.Rectangle(0, 0, gui_app.width, Layout.button_height)) + + self.reboot_scroller = Scroller( + [ + Recommended(), + Alternate(), + SomethingElse(), + Reboot(), + ], + horizontal=True, + snap_items=False, + spacing=Layout.scroller_spacing, + pad=Layout.scroller_padding, + scroll_indicator=False + ) + self.reboot_scroller.set_rect(rl.Rectangle(0, 0, gui_app.width, Layout.button_height)) + + # Create vertical scroller with the two horizontal scrollers + self.vertical_scroller = Scroller( + [self.tools_scroller, self.reboot_scroller], + horizontal=False, + snap_items=True, + spacing=Layout.scroller_spacing, + pad=Layout.scroller_padding + ) + # Propagate enabled state so children stop processing input + # when a dialog is pushed on top (push_widget disables us) + self.key_banner.set_enabled(lambda: self.enabled) + self.vertical_scroller.set_enabled(lambda: self.enabled) + self.tools_scroller.set_enabled(lambda: self.enabled) + self.reboot_scroller.set_enabled(lambda: self.enabled) + + def _render(self, rect: rl.Rectangle): + """Render the C4 GUI.""" + + # Enable scissor mode for the scroller area to clip overflow + scroller_rect = rl.Rectangle( + rect.x, + rect.y + Layout.banner_height, + rect.width, + rect.height - Layout.banner_height + ) + + rl.begin_scissor_mode(int(scroller_rect.x), int(scroller_rect.y), + int(scroller_rect.width), int(scroller_rect.height)) + + # Render the vertical scroller + self.vertical_scroller.render(scroller_rect) + + rl.end_scissor_mode() + + # Render the banner AFTER the scroller so it draws on top + banner_rect = rl.Rectangle(rect.x, rect.y, rect.width, Layout.banner_height) + self.key_banner.render(banner_rect) + + return True diff --git a/tsk/c4/ui.py b/tsk/c4/ui.py new file mode 100644 index 00000000..eef9698d --- /dev/null +++ b/tsk/c4/ui.py @@ -0,0 +1,323 @@ +from typing import Callable + +import pyray as rl + +from openpilot.common.filter_simple import BounceFilter +from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialogBase +from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.system.ui.lib.wrap_text import wrap_text +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.label import UnifiedLabel +from tsk.common.widget import Scroller + + +class Layout: + # Button dimensions + button_width = 240 + button_height = 140 + + # Font sizes + tools_row_button_font_size = 38 + reboot_row_button_font_size = 35 + + # Scroller configuration + scroller_spacing = 10 + scroller_padding = 10 + + # Banner + banner_height = 50 + + +class ScalableBigButton(Widget): + """ + Button that uses BigButton graphics but scales to any size. + """ + def __init__(self, + text: str, + click_callback: Callable = None, + font_size: int = Layout.tools_row_button_font_size, + text_offset: tuple[int, int] = (15, 15), + button_width = Layout.button_width, + button_height = Layout.button_height, + ): + super().__init__() + self._text = text + self._font_size = font_size + self._text_offset = text_offset # (x, y) offset from top-left of button + self.set_click_callback(click_callback) + + # Load BigButton textures + self._txt_default_bg = gui_app.texture("icons_mici/buttons/button_rectangle.png", 402, 180) + self._txt_pressed_bg = gui_app.texture("icons_mici/buttons/button_rectangle_pressed.png", 402, 180) + + # Scale animation + self._scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps) + + # Label for text + self._label = UnifiedLabel( + text, + font_size=font_size, + font_weight=FontWeight.BOLD, + text_color=rl.WHITE, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, + wrap_text=True + ) + + self.set_rect(rl.Rectangle(0, 0, button_width, button_height)) + + def _render(self, rect: rl.Rectangle): + """Render the button with scaled BigButton graphics.""" + # Choose texture based on press state + txt_bg = self._txt_pressed_bg if self.is_pressed else self._txt_default_bg + + # Scale animation + scale = self._scale_filter.update(1.07 if self.is_pressed else 1.0) + + # Calculate scaled position (center the scaled button) + scaled_width = rect.width * scale + scaled_height = rect.height * scale + btn_x = rect.x + (rect.width - scaled_width) / 2 + btn_y = rect.y + (rect.height - scaled_height) / 2 + + # Draw background texture scaled to button size + source_rect = rl.Rectangle(0, 0, self._txt_default_bg.width, self._txt_default_bg.height) + dest_rect = rl.Rectangle(btn_x, btn_y, scaled_width, scaled_height) + rl.draw_texture_pro(txt_bg, source_rect, dest_rect, rl.Vector2(0, 0), 0, rl.WHITE) + + # Draw text at specified offset from button top-left + text_x = rect.x + self._text_offset[0] + text_y = rect.y + self._text_offset[1] + label_rect = rl.Rectangle(text_x, text_y, int(rect.width - self._text_offset[0] * 2), int(rect.height - self._text_offset[1])) + self._label.render(label_rect) + + return True + + +class ScrollableBigDialog(BigDialogBase): + """ + A BigDialog variant that supports scrolling for long text content. + + Features: + - Optional title (no space wasted if title is empty string or None) + - Configurable alignment for title and description (left or center) + - Configurable font sizes for title and description + - Scrollable description area + + Usage: + dialog = ScrollableBigDialog( + title="Title", + description="Very long text that needs scrolling...", + title_font_size=45, + title_alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, + desc_font_size=25, + desc_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT + ) + gui_app.push_widget(dialog) + """ + PADDING = 20 + + def __init__(self, + title: str = "", + description: str = "", + title_font_size: int = 45, + title_alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_CENTER, + desc_font_size: int = 45, + desc_alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT, + scroll_to_bottom: bool = False): + super().__init__() + self._title = title + self._description = description + self._title_font_size = title_font_size + self._title_alignment = title_alignment + self._desc_font_size = desc_font_size + self._desc_alignment = desc_alignment + self._scroll_to_bottom = scroll_to_bottom + self._initial_scroll_done = False + + # Create label for description text + max_width = self._rect.width - self.PADDING * 2 + + self._desc_label = UnifiedLabel( + description, + font_size=desc_font_size, + text_color=rl.WHITE, + font_weight=FontWeight.MEDIUM, + alignment=desc_alignment + ) + + # Create scroller for the description with scroll indicator + self._scroller = Scroller( + [self._desc_label], + horizontal=False, + snap_items=False, + spacing=0, + pad=10 + ) + def _bottom_reserved_height(self) -> float: + """Height reserved at the bottom for subclass widgets (e.g., slider). Override in subclasses.""" + return 0 + + def _back_enabled(self) -> bool: + """Only allow swipe-to-dismiss when scroller is at the top.""" + return self._scroller.scroll_panel.get_offset() >= -20 + + def _render(self, _): + super()._render(_) + + # Calculate available width + max_width = self._rect.width - self.PADDING * 2 + + # Draw title (if provided and not empty) + title_bottom = self._rect.y + self.PADDING + if self._title: + title_wrapped = '\n'.join(wrap_text(gui_app.font(FontWeight.BOLD), self._title, self._title_font_size, int(max_width))) + title_size = measure_text_cached(gui_app.font(FontWeight.BOLD), title_wrapped, self._title_font_size) + title_rect = rl.Rectangle( + int(self._rect.x + self.PADDING), + int(self._rect.y + self.PADDING), + int(max_width), + int(title_size.y) + ) + + from openpilot.system.ui.widgets.label import gui_label + gui_label(title_rect, title_wrapped, self._title_font_size, font_weight=FontWeight.BOLD, + alignment=self._title_alignment) + + title_bottom = title_rect.y + title_rect.height + self.PADDING + + # Scroller uses full width so wide items (e.g., slider) aren't clipped. + # Text padding is handled separately via set_max_width. + scroller_rect = rl.Rectangle( + int(self._rect.x), + int(title_bottom), + int(self._rect.width), + int(self._rect.y + self._rect.height - title_bottom - self.PADDING - self._bottom_reserved_height()) + ) + + # Update label width for proper text wrapping + self._desc_label.set_max_width(int(max_width)) + + # Check if content is scrollable + content_height = self._desc_label.get_content_height(int(max_width)) + is_scrollable = content_height > scroller_rect.height + + # Disable scrolling if content fits in viewport + self._scroller.set_scrolling_enabled(is_scrollable) + + # Scroll to bottom on first render if flag is set + if self._scroll_to_bottom and not self._initial_scroll_done and is_scrollable: + scrollable_height = content_height - scroller_rect.height + self._scroller.scroll_panel.set_offset(-scrollable_height) + self._initial_scroll_done = True + + # Render the scroller + self._scroller.render(scroller_rect) + + # Draw scroll indicator if content is scrollable + if is_scrollable: + scroll_offset = self._scroller.scroll_panel.get_offset() + scrollable_height = content_height - scroller_rect.height + scroll_progress = -scroll_offset / scrollable_height if scrollable_height > 0 else 0 + scroll_progress = max(0, min(1, scroll_progress)) + + # Draw a thin rounded scrollbar on the right edge + indicator_height = max(20, scroller_rect.height * (scroller_rect.height / content_height)) + indicator_y = scroller_rect.y + scroll_progress * (scroller_rect.height - indicator_height) + indicator_rect = rl.Rectangle(self._rect.x + self._rect.width - 12, indicator_y, 8, indicator_height) + rl.draw_rectangle_rounded(indicator_rect, 1.0, 4, rl.Color(255, 255, 255, 178)) + + return + + +# ============================================================================ +# ScrollableConfirmDialog +# ============================================================================ +# A full-screen dialog with scrollable text and a confirm button that +# appears only after the user scrolls to the bottom of the content. +# +# PURPOSE: +# Used for sensitive/destructive operations (uninstall key, reboot to a +# different fork, etc.) where the user needs to: +# 1. Read important information (e.g., which key is installed, what +# will happen). This info can be many pages long and is scrollable. +# 2. Confirm by tapping a button that only appears after reading. +# Swipe down to cancel (standard NavWidget behavior). +# +# WHY THIS EXISTS: +# openpilot's upstream BigConfirmationDialog uses a slider that's too +# large for C4's 240px screen, and causes gesture conflicts when placed +# inside a scroller. This simpler approach uses a confirm button that +# appears at the bottom-right only after the user scrolls to the bottom. +# +# BEHAVIOR: +# 1. Full screen is used for scrollable description text +# 2. Confirm button appears only when bottom of text is reached +# 3. Scrolling back up hides the button +# 4. Swipe down from top to cancel +# +# USAGE: +# dialog = ScrollableConfirmDialog( +# description="Key installed: ABCD1234...\n\nThis will remove the key.", +# confirm_text="Uninstall", +# confirm_callback=do_the_thing +# ) +# gui_app.push_widget(dialog) +# ============================================================================ +class ScrollableConfirmDialog(ScrollableBigDialog): + BUTTON_WIDTH = 100 + BUTTON_HEIGHT = 60 + BUTTON_MARGIN = 10 + BUTTON_FONT_SIZE = 35 + BUTTON_COLOR = rl.Color(20, 120, 20, 255) + BUTTON_PRESSED_COLOR = rl.Color(30, 160, 30, 255) + + def __init__(self, + confirm_text: str = "Yes", + confirm_callback: Callable | None = None, + **kwargs): + # Add padding at bottom so text scrolls past the confirm button + if 'description' in kwargs: + kwargs['description'] = kwargs['description'] + '\n' + super().__init__(**kwargs) + self._confirm_callback = confirm_callback + self._confirm_text = confirm_text + self._show_button = False + + # Create confirm button — UnifiedLabel is a concrete Widget with click handling + self._confirm_button = self._child(UnifiedLabel( + confirm_text, font_size=self.BUTTON_FONT_SIZE, font_weight=FontWeight.BOLD, + text_color=rl.WHITE, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE + )) + self._confirm_button.set_click_callback(self._on_confirm) + self._confirm_button.set_enabled(lambda: self._show_button and self.enabled and not self.is_dismissing) + + def _on_confirm(self): + self.dismiss(self._confirm_callback) + + def _render(self, _): + super()._render(_) + + # Check if content is scrolled to bottom (or fits on screen without scrolling) + scroll_offset = self._scroller.scroll_panel.get_offset() + max_width = self._rect.width - self.PADDING * 2 + content_height = self._desc_label.get_content_height(int(max_width)) + scroller_height = self._rect.height - self.PADDING * 2 + if content_height <= scroller_height: + at_bottom = True + else: + scrollable_height = content_height - scroller_height + at_bottom = -scroll_offset >= scrollable_height - 20 + + self._show_button = at_bottom + + if self._show_button: + # Draw button at bottom-right + btn_x = self._rect.x + self._rect.width - self.BUTTON_WIDTH - self.BUTTON_MARGIN + btn_y = self._rect.y + self._rect.height - self.BUTTON_HEIGHT - self.BUTTON_MARGIN + btn_rect = rl.Rectangle(btn_x, btn_y, self.BUTTON_WIDTH, self.BUTTON_HEIGHT) + + color = self.BUTTON_PRESSED_COLOR if self._confirm_button.is_pressed else self.BUTTON_COLOR + rl.draw_rectangle_rounded(btn_rect, 0.3, 10, color) + self._confirm_button.render(btn_rect) 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..e0acb51f --- /dev/null +++ b/tsk/common/env.py @@ -0,0 +1,53 @@ +# tsk/common/env.py +import os +import time + + +def is_agnos(): + return os.path.exists("/AGNOS") + + +COMMA_DATA_DIR = "/data" if is_agnos() else f"{os.path.expanduser('~')}/comma_data" + +CONTINUE_FILE = f"{COMMA_DATA_DIR}/continue.sh" +OPENPILOT_DIR = f"{COMMA_DATA_DIR}/openpilot" +PAYLOAD_PATH = "/data/openpilot/tsk/common/payload.bin" + +RECOMMENDED_OP_USER = "commaai" +RECOMMENDED_OP_BRANCH = "nightly-dev" +RECOMMENDED_OP_DIR = f"{COMMA_DATA_DIR}/tsk-recommended" +ALTERNATE_OP_USER = "sunnypilot" +ALTERNATE_OP_BRANCH = "staging" +ALTERNATE_OP_DIR = f"{COMMA_DATA_DIR}/tsk-alternate" + + +def is_calvins_comma() -> bool: + try: + with open("/persist/comma/dongle_id") as f: + content = f.read() + if "2decf199" in content or "eecdfcc" 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/extractor.py b/tsk/common/extractor.py new file mode 100644 index 00000000..2f3f153c --- /dev/null +++ b/tsk/common/extractor.py @@ -0,0 +1,315 @@ +#!/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, PAYLOAD_PATH + + +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.flash() # no-op if firmware is already up to date; required because TSKM kills pandad before it can flash + 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(PAYLOAD_PATH, "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/common/key_file_manager.py b/tsk/common/key_file_manager.py new file mode 100644 index 00000000..ad05a4e6 --- /dev/null +++ b/tsk/common/key_file_manager.py @@ -0,0 +1,137 @@ +# tsk/common/key_file_manager.py + +import os +import re +import threading +import time + +from tsk.c3.ui.layout import Theme +from tsk.common.env import is_agnos + + +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") + + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance.installed_key = cls._read_key_from_files() + threading.Thread(target=cls._instance._update_key_status_loop, daemon=True).start() + return cls._instance + + @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/common/payload.bin b/tsk/common/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/common/widget.py b/tsk/common/widget.py new file mode 100644 index 00000000..3a571bb7 --- /dev/null +++ b/tsk/common/widget.py @@ -0,0 +1,60 @@ +# tsk/common/widget.py +""" +TSK Widget abstraction layer. + +This module provides TSK-specific widgets that extend openpilot's Widget system. +By creating this abstraction layer, we insulate the TSK application from changes +in the underlying openpilot UI library. +""" + +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.scroller import _Scroller + +__all__ = ['TSKWidget', 'Scroller'] + + +# ============================================================================ +# Scroller factory function +# ============================================================================ +# This replaces the direct import of openpilot's Scroller widget. +# +# WHY: openpilot's Scroller API changes frequently between releases. +# - In v0.10.x: Scroller(items, horizontal=True, pad_start=10, pad_end=10) +# - In v0.11.x: Scroller split into Scroller (wrapper) and _Scroller (impl). +# The wrapper no longer accepts items in the constructor, removed pad_start/ +# pad_end (now just pad), and doesn't expose scroll_panel or +# set_scrolling_enabled. +# +# This function wraps _Scroller (the actual implementation) so that: +# 1. TSK code can call Scroller(items, ...) like a normal constructor +# 2. The returned object has scroll_panel, set_scrolling_enabled, etc. +# 3. When comma changes the API again, we only fix this one place +# +# USAGE: from tsk.common.widget import Scroller +# scroller = Scroller([widget1, widget2], horizontal=True, pad=10) +# scroller.scroll_panel.get_offset() +# scroller.set_scrolling_enabled(True) +# ============================================================================ +def Scroller(items, **kwargs): + return _Scroller(items, **kwargs) + + +class TSKWidget(Widget): + """ + Base class for all TSK widgets. + + This extends openpilot's Widget class and provides TSK-specific + functionality and customization. All TSK UI components should + extend this class instead of using OpenPilotWidget directly. + + Benefits: + - Isolation from openpilot UI changes + - TSK-specific features can be added here + - Consistent behavior across all TSK widgets + - Easier testing and mocking + """ + + def __init__(self): + super().__init__() + # TSK-specific initialization can go here + # For example: logging, analytics, custom state management diff --git a/tsk/main.py b/tsk/main.py new file mode 100755 index 00000000..d635bf82 --- /dev/null +++ b/tsk/main.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +# tsk/main.py +""" +TSK Manager entry point. + +Detects device type (C3X vs C4) and loads appropriate GUI. +""" + +import sys + +import pyray as rl + +from openpilot.system.ui.lib.application import gui_app +from tsk.common.env import is_calvins_comma + + +def setup_environment(): + """Perform initial environment setup, such as enabling SSH.""" + if is_calvins_comma(): + 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") + + if gui_app.big_ui(): + # C3X: use forked render loop (see tsk/c3/ui/render_loop.py for why) + from tsk.c3.tsk_manager import TSKManager + from tsk.c3.ui.render_loop import render_loop + tskm = TSKManager() + for _ in render_loop(): + tskm.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) + else: + # C4: use nav stack — dialogs are pushed as NavWidgets + from tsk.c4.tsk_manager import TSKManager + tskm = TSKManager() + gui_app.push_widget(tskm) + for _ in gui_app.render(): + pass + + rl.close_window() + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/tsk/prefetch.py b/tsk/prefetch.py new file mode 100755 index 00000000..ad5e4755 --- /dev/null +++ b/tsk/prefetch.py @@ -0,0 +1,734 @@ +#!/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 PrefetchAppC3: + """ + GUI application to track git clone operations for C3 (big screen). + + 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 + # Use render texture if scaling is enabled (matches gui_app behavior) + if gui_app._render_texture: + rl.begin_texture_mode(gui_app._render_texture) + rl.clear_background(rl.Color(0, 0, 0, 255)) + else: + 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 and scale if needed (matches gui_app behavior) + if gui_app._render_texture: + rl.end_texture_mode() + rl.begin_drawing() + rl.clear_background(rl.BLACK) + src_rect = rl.Rectangle(0, 0, float(gui_app.width), -float(gui_app.height)) + dst_rect = rl.Rectangle(0, 0, float(gui_app._scaled_width), float(gui_app._scaled_height)) + rl.draw_texture_pro(gui_app._render_texture.texture, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE) + rl.end_drawing() + + +class PrefetchAppC4: + """ + GUI application to track git clone operations for C4 (small screen). + + 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 for C4 (536x240 screen) + PROGRESS_BAR_HEIGHT = 20 + PROGRESS_BAR_WIDTH = 500 + PADDING = 5 + FONT_SIZE = 28 + TITLE_FONT_SIZE = 28 + GAP_AFTER_TITLE = 15 + GAP_BETWEEN_OPERATIONS = 40 + BAR_BG_COLOR = rl.Color(40, 40, 40, 255) + BAR_FG_COLOR = rl.Color(54, 77, 239, 255) + WHITE_TEXT_COLOR = rl.Color(255, 255, 255, 255) + GRAY_TEXT_COLOR = rl.Color(100, 100, 100, 255) + + def __init__(self): + """ + Initialize the prefetch application for C4. + + 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. + """ + 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. + + TODO: Implement C4-specific UI rendering. + For now, this is a stub that needs to be implemented. + """ + # Initialize window using gui_app + gui_app.init_window("TSK Prefetch") + + # Start clone operations + self.start_operations() + + # 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 + if all_done: + time.sleep(1) + break + + # Render the current frame + self._render_frame() + + # 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 for C4. + + Compact layout optimized for 536x240 screen. + """ + # Use render texture if scaling is enabled (matches gui_app behavior) + if gui_app._render_texture: + rl.begin_texture_mode(gui_app._render_texture) + rl.clear_background(rl.Color(0, 0, 0, 255)) + else: + rl.begin_drawing() + rl.clear_background(rl.Color(0, 0, 0, 255)) + + # 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 + ) + + # Draw progress bars for each operation + y_offset = self.PADDING + self.TITLE_FONT_SIZE + self.GAP_AFTER_TITLE + + for i, op in enumerate(self.clone_operations): + # 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 + + # 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) + + # 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) + + # 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 + ) + + # 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" + + # Truncate status text if too long for screen + max_status_width = gui_app.width - self.PADDING * 2 + status_width = rl.measure_text_ex(gui_app.font(), status_text, self.FONT_SIZE, 0).x + if status_width > max_status_width: + # Truncate with ellipsis + while status_width > max_status_width and len(status_text) > 3: + status_text = status_text[:-4] + "..." + status_width = rl.measure_text_ex(gui_app.font(), status_text, self.FONT_SIZE, 0).x + + # For the last operation, keep same spacing as other operations + # All status texts use the same PADDING distance from their progress bars + + 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 + if i < len(self.clone_operations) - 1: + # Move to next operation: skip bar height, small padding, and gap + y_offset += self.PROGRESS_BAR_HEIGHT + self.PADDING + self.GAP_BETWEEN_OPERATIONS + + # End rendering and scale if needed (matches gui_app behavior) + if gui_app._render_texture: + rl.end_texture_mode() + rl.begin_drawing() + rl.clear_background(rl.BLACK) + src_rect = rl.Rectangle(0, 0, float(gui_app.width), -float(gui_app.height)) + dst_rect = rl.Rectangle(0, 0, float(gui_app._scaled_width), float(gui_app._scaled_height)) + rl.draw_texture_pro(gui_app._render_texture.texture, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE) + rl.end_drawing() + + +# ------------------------------------------------------------------------- +# Main Entry Point +# ------------------------------------------------------------------------- + +def main(): + """ + Main function to run the prefetch application. + + Detects device type (C3 vs C4) and loads appropriate GUI. + This is the entry point when the script is executed directly. + """ + # Detect device type based on screen size + if gui_app.big_ui(): + # C3 device (big screen: 2160x1080) + app = PrefetchAppC3() + else: + # C4 device (small screen: 536x240) + app = PrefetchAppC4() + + app.run() + + # Exit with success code + sys.exit(0) + + +if __name__ == "__main__": + main()