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