TSK Manager v0.10.0

This commit is contained in:
Calvin Park
2025-05-29 14:07:28 -07:00
parent 9f648e0963
commit a128236026
22 changed files with 2063 additions and 0 deletions
+1
View File
@@ -7,6 +7,7 @@ venv/
.tags
.ipynb_checkpoints
.idea
*.iml
.overlay_init
.overlay_consistent
.sconsign.dblite
+22
View File
@@ -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
View File
View File
+47
View File
@@ -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
+132
View File
@@ -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
View File
@@ -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()
+476
View File
@@ -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()
View File
+136
View File
@@ -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)
+72
View File
@@ -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()
View File
+80
View File
@@ -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()
+314
View File
@@ -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
+216
View File
@@ -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.
+65
View File
@@ -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()
View File
+59
View File
@@ -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
)
+178
View File
@@ -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
+119
View File
@@ -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
+67
View File
@@ -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