TSK Manager v0.11.0

This commit is contained in:
Calvin Park
2025-11-15 18:11:00 -08:00
parent 8069b6611d
commit 209db8f320
44 changed files with 3768 additions and 0 deletions
+1
View File
@@ -7,6 +7,7 @@ venv/
.tags
.ipynb_checkpoints
.idea
*.iml
.overlay_init
.overlay_consistent
.sconsign.dblite
+81
View File
@@ -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.
Executable
+2
View File
@@ -0,0 +1,2 @@
#!/bin/sh
BIG=1 SCALE=1 .venv/bin/python tsk/main.py
Executable
+2
View File
@@ -0,0 +1,2 @@
#!/bin/sh
SCALE=0.5 .venv/bin/python tsk/main.py
+22
View File
@@ -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
View File
View File
View File
+136
View File
@@ -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)
+131
View File
@@ -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)
View File
+79
View File
@@ -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()
+244
View File
@@ -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()
Binary file not shown.
+120
View File
@@ -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)
+66
View File
@@ -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
View File
+139
View File
@@ -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
+216
View File
@@ -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
+192
View File
@@ -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)
+76
View File
@@ -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
+18
View File
@@ -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)
+41
View File
@@ -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()
View File
View File
+45
View File
@@ -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)
+14
View File
@@ -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")
+37
View File
@@ -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()
+37
View File
@@ -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)
View File
+50
View File
@@ -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)
+49
View File
@@ -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)
@@ -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)
+38
View File
@@ -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)
+197
View File
@@ -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
+323
View File
@@ -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)
View File
+53
View File
@@ -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
+315
View File
@@ -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("<I", data[:4])[0]
assert (ptr >> 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
+137
View File
@@ -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()
Binary file not shown.
+60
View File
@@ -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
Executable
+59
View File
@@ -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()
+734
View File
@@ -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()