TSK Manager v0.11.0
This commit is contained in:
@@ -7,6 +7,7 @@ venv/
|
||||
.tags
|
||||
.ipynb_checkpoints
|
||||
.idea
|
||||
*.iml
|
||||
.overlay_init
|
||||
.overlay_consistent
|
||||
.sconsign.dblite
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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.
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
@@ -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
@@ -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()
|
||||
Executable
+734
@@ -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()
|
||||
Reference in New Issue
Block a user