Files
2026-01-20 19:29:46 -07:00

1083 lines
38 KiB
Python

import json
import os
import shutil
import subprocess
import threading
import time
from datetime import datetime
from pathlib import Path
from openpilot.common.params import Params
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.hardware import HARDWARE
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.widgets import Widget, DialogResult
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dialog
from openpilot.system.ui.widgets.keyboard import Keyboard
from openpilot.system.ui.widgets.list_view import ListItem, TextAction, ITEM_TEXT_VALUE_COLOR
from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.frogpilot.common.frogpilot_utilities import clean_model_name
from openpilot.frogpilot.common.frogpilot_variables import (
BACKUP_PATH,
ERROR_LOGS_PATH,
FROGPILOT_BACKUPS,
SCREEN_RECORDINGS_PATH,
TOGGLE_BACKUPS,
update_frogpilot_toggles,
)
from openpilot.frogpilot.system.ui.widgets.frogpilot_controls import (
FrogPilotButtonsControl,
FrogPilotConfirmationDialog,
)
DRIVING_DATA_PATHS = [
Path("/data/media/0/realdata"),
Path("/data/media/0/realdata_HD"),
Path("/data/media/0/realdata_konik"),
]
METER_TO_MILE = 0.000621371
MS_TO_KPH = 3.6
MS_TO_MPH = 2.23694
KEY_MAP = {
"AEBEvents": ("Total Emergency Brake Alerts", "count"),
"AOLTime": ("Time Using \"Always On Lateral\"", "timePercent"),
"CruiseSpeedTimes": ("Favorite Set Speed", "speed"),
"CurrentMonthsMeters": ("Distance Driven This Month", "distance"),
"DayTime": ("Time Driving (Daytime)", "timePercent"),
"Disengages": ("Total Disengagements", "count"),
"Engages": ("Total Engagements", "count"),
"ExperimentalModeTime": ("Time Using \"Experimental Mode\"", "timePercent"),
"FrogChirps": ("Total Frog Chirps", "count"),
"FrogHops": ("Total Frog Hops", "count"),
"FrogPilotDrives": ("Total Drives", "count"),
"FrogPilotMeters": ("Total Distance Driven", "distance"),
"FrogPilotSeconds": ("Total Driving Time", "time"),
"FrogSqueaks": ("Total Frog Squeaks", "count"),
"GoatScreams": ("Total Goat Screams", "count"),
"HighestAcceleration": ("Highest Acceleration Rate", "accel"),
"LateralTime": ("Time Using Lateral Control", "timePercent"),
"LongestDistanceWithoutOverride": ("Longest Distance Without an Override", "distance"),
"LongitudinalTime": ("Time Using Longitudinal Control", "timePercent"),
"ModelTimes": ("Driving Models:", "parent"),
"Month": ("Month", "other"),
"NightTime": ("Time Driving (Nighttime)", "timePercent"),
"Overrides": ("Total Overrides", "count"),
"OverrideTime": ("Time Overriding openpilot", "timePercent"),
"PersonalityTimes": ("Driving Personalities:", "parent"),
"RandomEvents": ("Random Events:", "parent"),
"StandstillTime": ("Time Stopped", "timePercent"),
"StopLightTime": ("Time Spent at Stoplights", "timePercent"),
"TrackedTime": ("Total Time Tracked", "time"),
"WeatherTimes": ("Time Driven (Weather):", "parent"),
}
RANDOM_EVENTS_MAP = {
"accel30": "UwUs",
"accel35": "Loch Ness Encounters",
"accel40": "Visits to 1955",
"dejaVuCurve": "Deja Vu Moments",
"firefoxSteerSaturated": "Internet Explorer Weeeeeeees",
"hal9000": "HAL 9000 Denials",
"openpilotCrashedRandomEvent": "openpilot Crashes",
"thisIsFineSteerSaturated": "This Is Fine Moments",
"toBeContinued": "To Be Continued Moments",
"vCruise69": "Noices",
"yourFrogTriedToKillMe": "Attempted Frog Murders",
"youveGotMail": "Total Mail Received",
}
IGNORED_KEYS = {"Month"}
def format_ordinal(day):
if 11 <= day <= 13:
suffix = "th"
elif day % 10 == 1:
suffix = "st"
elif day % 10 == 2:
suffix = "nd"
elif day % 10 == 3:
suffix = "rd"
else:
suffix = "th"
return f"{day}{suffix}"
def format_friendly_date(dt):
day = dt.day
return f"{dt.strftime('%B')} {format_ordinal(day)}, {dt.year} ({dt.strftime('%I:%M %p').lstrip('0')})"
def parse_recording_name(filename):
if not filename.endswith(".mp4"):
return None, None
clean_name = filename[:-4]
separator = "--" if "--" in clean_name else "_"
parts = clean_name.split(separator)
if len(parts) >= 2:
try:
date = datetime.strptime(parts[0], "%Y-%m-%d")
time_part = datetime.strptime(parts[1], "%H-%M-%S")
dt = datetime.combine(date.date(), time_part.time())
return format_friendly_date(dt), filename
except ValueError:
pass
friendly = clean_name.replace("_", " ")
return friendly, filename
def parse_backup_name(filename, mod_time=None):
friendly = filename
if filename.endswith("_auto.tar.zst") and mod_time:
parts = filename.replace(".tar.zst", "").split("_")
if len(parts) >= 3:
friendly = format_friendly_date(mod_time).rsplit(" (", 1)[0] + f" ({parts[1]})"
if friendly == filename:
friendly = filename.replace(".tar.zst", "").replace("_", " ")
return friendly, filename
def parse_toggle_backup_name(dirname):
friendly = dirname
if dirname.endswith("_auto"):
parts = dirname.replace("_auto", "").split("_")
if len(parts) >= 2:
try:
date = datetime.strptime(parts[0], "%Y-%m-%d")
time_part = datetime.strptime(parts[1], "%H-%M-%S")
dt = datetime.combine(date.date(), time_part.time())
friendly = format_friendly_date(dt)
except ValueError:
pass
if friendly == dirname:
friendly = dirname.replace("_", " ")
return friendly, dirname
class FrogPilotDataPanel(Widget):
def __init__(self):
super().__init__()
self._is_metric = False
self._params = Params()
self._show_stats = False
# State for dialogs and operations
self._pending_dialog = None
self._pending_action = None
self._pending_data = {}
# Screen recordings data
self._recordings_map = {}
self._recordings_list = []
# FrogPilot backups data
self._fp_backups_map = {}
self._fp_backups_list = []
# Toggle backups data
self._toggle_backups_map = {}
self._toggle_backups_list = []
# Keyboard for text input
self._keyboard = Keyboard()
# Delete Driving Data Button
self._delete_driving_data_control = FrogPilotButtonsControl(
"DeleteDrivingData",
"Delete Driving Data",
"<b>Delete all stored driving footage and data</b> to free up storage space or to simply just erase driving data.",
"",
button_texts=["DELETE"],
)
self._delete_driving_data_control.set_click_callback(self._on_delete_driving_data_click)
# Delete Error Logs Button
self._delete_error_logs_control = FrogPilotButtonsControl(
"DeleteErrorLogs",
"Delete Error Logs",
"<b>Delete collected error logs</b> to free up space and clear old crash records.",
"",
button_texts=["DELETE"],
)
self._delete_error_logs_control.set_click_callback(self._on_delete_error_logs_click)
# Screen Recordings Buttons
self._screen_recordings_control = FrogPilotButtonsControl(
"ScreenRecordings",
"Screen Recordings",
"<b>Delete or rename screen recordings.</b>",
"",
button_texts=["DELETE", "DELETE ALL", "RENAME"],
)
self._screen_recordings_control.set_click_callback(self._on_screen_recordings_click)
# FrogPilot Backups Buttons
self._frogpilot_backups_control = FrogPilotButtonsControl(
"FrogPilotBackups",
"FrogPilot Backups",
"<b>Create, delete, or restore FrogPilot backups.</b>",
"",
button_texts=["BACKUP", "DELETE", "DELETE ALL", "RESTORE"],
)
self._frogpilot_backups_control.set_click_callback(self._on_frogpilot_backups_click)
# Toggle Backups Buttons
self._toggle_backups_control = FrogPilotButtonsControl(
"ToggleBackups",
"Toggle Backups",
"<b>Create, delete, or restore toggle backups.</b>",
"",
button_texts=["BACKUP", "DELETE", "DELETE ALL", "RESTORE"],
)
self._toggle_backups_control.set_click_callback(self._on_toggle_backups_click)
# FrogPilot Stats Buttons
self._stats_control = FrogPilotButtonsControl(
"FrogPilotStats",
"FrogPilot Stats",
"<b>View your collected FrogPilot stats.</b>",
"",
button_texts=["RESET", "VIEW"],
)
self._stats_control.set_click_callback(self._on_stats_click)
main_items = [
self._delete_driving_data_control,
self._delete_error_logs_control,
self._screen_recordings_control,
self._frogpilot_backups_control,
self._toggle_backups_control,
self._stats_control,
]
self._main_scroller = Scroller(main_items, line_separator=True, spacing=0)
self._stats_scroller = None
self._stats_items = []
ui_state.add_offroad_transition_callback(self._on_offroad_transition)
def _on_offroad_transition(self):
self._is_metric = self._params.get_bool("IsMetric")
if self._show_stats:
self._update_stats_labels()
# ==================== DELETE DRIVING DATA ====================
def _on_delete_driving_data_click(self, button_id: int):
self._pending_action = "delete_driving_data"
gui_app.set_modal_overlay(ConfirmDialog(
"Delete all driving data and footage?",
"Delete",
"Cancel",
))
def _do_delete_driving_data(self):
def delete_thread():
self._delete_driving_data_control.set_enabled(False)
self._delete_driving_data_control.set_value("Deleting...")
for path in DRIVING_DATA_PATHS:
if not path.exists():
continue
for entry in path.iterdir():
if entry.is_dir():
try:
preserve = os.getxattr(str(entry), b"user.preserve") == b"1"
except OSError:
preserve = False
if not preserve:
shutil.rmtree(entry, ignore_errors=True)
self._delete_driving_data_control.set_value("Deleted!")
time.sleep(2.5)
self._delete_driving_data_control.set_value("")
self._delete_driving_data_control.set_enabled(True)
threading.Thread(target=delete_thread, daemon=True).start()
# ==================== DELETE ERROR LOGS ====================
def _on_delete_error_logs_click(self, button_id: int):
self._pending_action = "delete_error_logs"
gui_app.set_modal_overlay(ConfirmDialog(
"Delete all error logs?",
"Delete",
"Cancel",
))
def _do_delete_error_logs(self):
def delete_thread():
self._delete_error_logs_control.set_enabled(False)
self._delete_error_logs_control.set_value("Deleting...")
if ERROR_LOGS_PATH.exists():
shutil.rmtree(ERROR_LOGS_PATH, ignore_errors=True)
ERROR_LOGS_PATH.mkdir(parents=True, exist_ok=True)
self._delete_error_logs_control.set_value("Deleted!")
time.sleep(2.5)
self._delete_error_logs_control.set_value("")
self._delete_error_logs_control.set_enabled(True)
threading.Thread(target=delete_thread, daemon=True).start()
# ==================== SCREEN RECORDINGS ====================
def _on_screen_recordings_click(self, button_id: int):
SCREEN_RECORDINGS_PATH.mkdir(parents=True, exist_ok=True)
recordings = []
self._recordings_map = {}
if SCREEN_RECORDINGS_PATH.exists():
for f in SCREEN_RECORDINGS_PATH.iterdir():
if f.is_file() and f.suffix.lower() == ".mp4":
friendly, original = parse_recording_name(f.name)
if friendly:
recordings.append((friendly, original))
self._recordings_map[friendly] = original
recordings.sort(key=lambda x: x[1], reverse=True)
self._recordings_list = [r[0] for r in recordings]
if not self._recordings_list:
gui_app.set_modal_overlay(alert_dialog("No screen recordings found."))
return
if button_id == 0:
# DELETE single recording
self._pending_action = "recording_delete_select"
gui_app.set_modal_overlay(MultiOptionDialog(
"Choose a screen recording to delete",
self._recordings_list,
))
elif button_id == 1:
# DELETE ALL recordings
self._pending_action = "recording_delete_all"
gui_app.set_modal_overlay(ConfirmDialog(
"Delete all screen recordings?",
"Delete All",
"Cancel",
))
elif button_id == 2:
# RENAME recording
self._pending_action = "recording_rename_select"
gui_app.set_modal_overlay(MultiOptionDialog(
"Choose a screen recording to rename",
self._recordings_list,
))
def _do_delete_recording(self, selection: str):
def delete_thread():
self._screen_recordings_control.set_enabled(False)
self._screen_recordings_control.set_value("Deleting...")
self._screen_recordings_control.set_visible_button(1, False)
self._screen_recordings_control.set_visible_button(2, False)
filename = self._recordings_map.get(selection, "")
if filename:
filepath = SCREEN_RECORDINGS_PATH / filename
if filepath.exists():
filepath.unlink()
self._screen_recordings_control.set_value("Deleted!")
time.sleep(2.5)
self._screen_recordings_control.set_value("")
self._screen_recordings_control.set_enabled(True)
self._screen_recordings_control.set_visible_button(1, True)
self._screen_recordings_control.set_visible_button(2, True)
threading.Thread(target=delete_thread, daemon=True).start()
def _do_delete_all_recordings(self):
def delete_thread():
self._screen_recordings_control.set_enabled(False)
self._screen_recordings_control.set_value("Deleting...")
self._screen_recordings_control.set_visible_button(0, False)
self._screen_recordings_control.set_visible_button(2, False)
if SCREEN_RECORDINGS_PATH.exists():
shutil.rmtree(SCREEN_RECORDINGS_PATH, ignore_errors=True)
SCREEN_RECORDINGS_PATH.mkdir(parents=True, exist_ok=True)
self._screen_recordings_control.set_value("Deleted!")
time.sleep(2.5)
self._screen_recordings_control.set_value("")
self._screen_recordings_control.set_enabled(True)
self._screen_recordings_control.set_visible_button(0, True)
self._screen_recordings_control.set_visible_button(2, True)
threading.Thread(target=delete_thread, daemon=True).start()
def _do_rename_recording(self, selection: str, new_name: str):
def rename_thread():
self._screen_recordings_control.set_enabled(False)
self._screen_recordings_control.set_value("Renaming...")
self._screen_recordings_control.set_visible_button(0, False)
self._screen_recordings_control.set_visible_button(1, False)
old_filename = self._recordings_map.get(selection, "")
new_filename = new_name.replace(" ", "_") + ".mp4"
if old_filename:
old_path = SCREEN_RECORDINGS_PATH / old_filename
new_path = SCREEN_RECORDINGS_PATH / new_filename
if old_path.exists() and not new_path.exists():
old_path.rename(new_path)
self._screen_recordings_control.set_value("Renamed!")
time.sleep(2.5)
self._screen_recordings_control.set_value("")
self._screen_recordings_control.set_enabled(True)
self._screen_recordings_control.set_visible_button(0, True)
self._screen_recordings_control.set_visible_button(1, True)
threading.Thread(target=rename_thread, daemon=True).start()
# ==================== FROGPILOT BACKUPS ====================
def _on_frogpilot_backups_click(self, button_id: int):
FROGPILOT_BACKUPS.mkdir(parents=True, exist_ok=True)
backups = []
self._fp_backups_map = {}
for f in FROGPILOT_BACKUPS.iterdir():
if f.is_file() and f.name.endswith(".tar.zst") and "in_progress" not in f.name:
mod_time = datetime.fromtimestamp(f.stat().st_mtime)
friendly, original = parse_backup_name(f.name, mod_time)
backups.append((friendly, original, mod_time))
self._fp_backups_map[friendly] = original
backups.sort(key=lambda x: x[2], reverse=True)
self._fp_backups_list = [b[0] for b in backups]
if button_id == 0:
# CREATE BACKUP
self._pending_action = "fp_backup_create"
self._keyboard.reset()
self._keyboard.set_title("Name your backup", "Backup Name")
gui_app.set_modal_overlay(self._keyboard, callback=self._on_keyboard_result)
elif button_id == 1:
# DELETE backup
if not self._fp_backups_list:
gui_app.set_modal_overlay(alert_dialog("No backups found."))
return
self._pending_action = "fp_backup_delete_select"
gui_app.set_modal_overlay(MultiOptionDialog(
"Choose a backup to delete",
self._fp_backups_list,
))
elif button_id == 2:
# DELETE ALL backups
self._pending_action = "fp_backup_delete_all"
gui_app.set_modal_overlay(ConfirmDialog(
"Delete all FrogPilot backups?",
"Delete All",
"Cancel",
))
elif button_id == 3:
# RESTORE backup
if not self._fp_backups_list:
gui_app.set_modal_overlay(alert_dialog("No backups found."))
return
self._pending_action = "fp_backup_restore_select"
gui_app.set_modal_overlay(MultiOptionDialog(
"Choose a backup to restore",
self._fp_backups_list,
))
def _do_create_fp_backup(self, name: str):
def backup_thread():
self._frogpilot_backups_control.set_enabled(False)
self._frogpilot_backups_control.set_value("Backing up...")
self._frogpilot_backups_control.set_visible_button(1, False)
self._frogpilot_backups_control.set_visible_button(2, False)
self._frogpilot_backups_control.set_visible_button(3, False)
backup_name = name.replace(" ", "_") + ".tar.zst"
backup_path = FROGPILOT_BACKUPS / backup_name
subprocess.run(
f"tar --use-compress-program=zstd -cf {backup_path} /data/openpilot",
shell=True,
capture_output=True
)
self._frogpilot_backups_control.set_value("Backup created!")
time.sleep(2.5)
self._frogpilot_backups_control.set_value("")
self._frogpilot_backups_control.set_enabled(True)
self._frogpilot_backups_control.set_visible_button(1, True)
self._frogpilot_backups_control.set_visible_button(2, True)
self._frogpilot_backups_control.set_visible_button(3, True)
threading.Thread(target=backup_thread, daemon=True).start()
def _do_delete_fp_backup(self, selection: str):
def delete_thread():
self._frogpilot_backups_control.set_enabled(False)
self._frogpilot_backups_control.set_value("Deleting...")
self._frogpilot_backups_control.set_visible_button(0, False)
self._frogpilot_backups_control.set_visible_button(2, False)
self._frogpilot_backups_control.set_visible_button(3, False)
filename = self._fp_backups_map.get(selection, "")
if filename:
filepath = FROGPILOT_BACKUPS / filename
if filepath.exists():
filepath.unlink()
self._frogpilot_backups_control.set_value("Deleted!")
time.sleep(2.5)
self._frogpilot_backups_control.set_value("")
self._frogpilot_backups_control.set_enabled(True)
self._frogpilot_backups_control.set_visible_button(0, True)
self._frogpilot_backups_control.set_visible_button(2, True)
self._frogpilot_backups_control.set_visible_button(3, True)
threading.Thread(target=delete_thread, daemon=True).start()
def _do_delete_all_fp_backups(self):
def delete_thread():
self._frogpilot_backups_control.set_enabled(False)
self._frogpilot_backups_control.set_value("Deleting...")
self._frogpilot_backups_control.set_visible_button(0, False)
self._frogpilot_backups_control.set_visible_button(1, False)
self._frogpilot_backups_control.set_visible_button(3, False)
if FROGPILOT_BACKUPS.exists():
shutil.rmtree(FROGPILOT_BACKUPS, ignore_errors=True)
FROGPILOT_BACKUPS.mkdir(parents=True, exist_ok=True)
self._frogpilot_backups_control.set_value("Deleted!")
time.sleep(2.5)
self._frogpilot_backups_control.set_value("")
self._frogpilot_backups_control.set_enabled(True)
self._frogpilot_backups_control.set_visible_button(0, True)
self._frogpilot_backups_control.set_visible_button(1, True)
self._frogpilot_backups_control.set_visible_button(3, True)
threading.Thread(target=delete_thread, daemon=True).start()
def _do_restore_fp_backup(self, selection: str):
def restore_thread():
self._frogpilot_backups_control.set_enabled(False)
self._frogpilot_backups_control.set_value("Restoring...")
self._frogpilot_backups_control.set_visible_button(0, False)
self._frogpilot_backups_control.set_visible_button(1, False)
self._frogpilot_backups_control.set_visible_button(2, False)
filename = self._fp_backups_map.get(selection, "")
if filename:
backup_path = FROGPILOT_BACKUPS / filename
subprocess.run(
f"rm -rf /data/openpilot/* && tar --use-compress-program=zstd -xf {backup_path} -C /",
shell=True,
capture_output=True
)
# Create marker file for backup restore
Path("/cache/on_backup").touch()
self._frogpilot_backups_control.set_value("Restored!")
time.sleep(2.5)
self._frogpilot_backups_control.set_value("Rebooting...")
time.sleep(2.5)
HARDWARE.reboot()
threading.Thread(target=restore_thread, daemon=True).start()
# ==================== TOGGLE BACKUPS ====================
def _on_toggle_backups_click(self, button_id: int):
TOGGLE_BACKUPS.mkdir(parents=True, exist_ok=True)
backups = []
self._toggle_backups_map = {}
for d in TOGGLE_BACKUPS.iterdir():
if d.is_dir() and "in_progress" not in d.name:
friendly, original = parse_toggle_backup_name(d.name)
backups.append((friendly, original))
self._toggle_backups_map[friendly] = original
backups.sort(key=lambda x: x[1], reverse=True)
self._toggle_backups_list = [b[0] for b in backups]
if button_id == 0:
# CREATE BACKUP
self._pending_action = "toggle_backup_create"
self._keyboard.reset()
self._keyboard.set_title("Name your backup", "Backup Name")
gui_app.set_modal_overlay(self._keyboard, callback=self._on_keyboard_result)
elif button_id == 1:
# DELETE backup
if not self._toggle_backups_list:
gui_app.set_modal_overlay(alert_dialog("No backups found."))
return
self._pending_action = "toggle_backup_delete_select"
gui_app.set_modal_overlay(MultiOptionDialog(
"Choose a backup to delete",
self._toggle_backups_list,
))
elif button_id == 2:
# DELETE ALL backups
self._pending_action = "toggle_backup_delete_all"
gui_app.set_modal_overlay(ConfirmDialog(
"Delete all toggle backups?",
"Delete All",
"Cancel",
))
elif button_id == 3:
# RESTORE backup
if not self._toggle_backups_list:
gui_app.set_modal_overlay(alert_dialog("No backups found."))
return
self._pending_action = "toggle_backup_restore_select"
gui_app.set_modal_overlay(MultiOptionDialog(
"Choose a backup to restore",
self._toggle_backups_list,
))
def _do_create_toggle_backup(self, name: str):
def backup_thread():
self._toggle_backups_control.set_enabled(False)
self._toggle_backups_control.set_value("Backing up...")
self._toggle_backups_control.set_visible_button(1, False)
self._toggle_backups_control.set_visible_button(2, False)
self._toggle_backups_control.set_visible_button(3, False)
backup_name = name.replace(" ", "_")
backup_path = TOGGLE_BACKUPS / backup_name
subprocess.run(
f"cp -r /data/params/d/ {backup_path}",
shell=True,
capture_output=True
)
self._toggle_backups_control.set_value("Backup created!")
time.sleep(2.5)
self._toggle_backups_control.set_value("")
self._toggle_backups_control.set_enabled(True)
self._toggle_backups_control.set_visible_button(1, True)
self._toggle_backups_control.set_visible_button(2, True)
self._toggle_backups_control.set_visible_button(3, True)
threading.Thread(target=backup_thread, daemon=True).start()
def _do_delete_toggle_backup(self, selection: str):
def delete_thread():
self._toggle_backups_control.set_enabled(False)
self._toggle_backups_control.set_value("Deleting...")
self._toggle_backups_control.set_visible_button(0, False)
self._toggle_backups_control.set_visible_button(2, False)
self._toggle_backups_control.set_visible_button(3, False)
dirname = self._toggle_backups_map.get(selection, "")
if dirname:
dirpath = TOGGLE_BACKUPS / dirname
if dirpath.exists():
shutil.rmtree(dirpath, ignore_errors=True)
self._toggle_backups_control.set_value("Deleted!")
time.sleep(2.5)
self._toggle_backups_control.set_value("")
self._toggle_backups_control.set_enabled(True)
self._toggle_backups_control.set_visible_button(0, True)
self._toggle_backups_control.set_visible_button(2, True)
self._toggle_backups_control.set_visible_button(3, True)
threading.Thread(target=delete_thread, daemon=True).start()
def _do_delete_all_toggle_backups(self):
def delete_thread():
self._toggle_backups_control.set_enabled(False)
self._toggle_backups_control.set_value("Deleting...")
self._toggle_backups_control.set_visible_button(0, False)
self._toggle_backups_control.set_visible_button(1, False)
self._toggle_backups_control.set_visible_button(3, False)
if TOGGLE_BACKUPS.exists():
shutil.rmtree(TOGGLE_BACKUPS, ignore_errors=True)
TOGGLE_BACKUPS.mkdir(parents=True, exist_ok=True)
self._toggle_backups_control.set_value("Deleted!")
time.sleep(2.5)
self._toggle_backups_control.set_value("")
self._toggle_backups_control.set_enabled(True)
self._toggle_backups_control.set_visible_button(0, True)
self._toggle_backups_control.set_visible_button(1, True)
self._toggle_backups_control.set_visible_button(3, True)
threading.Thread(target=delete_thread, daemon=True).start()
def _do_restore_toggle_backup(self, selection: str):
def restore_thread():
self._toggle_backups_control.set_enabled(False)
self._toggle_backups_control.set_value("Restoring...")
self._toggle_backups_control.set_visible_button(0, False)
self._toggle_backups_control.set_visible_button(1, False)
self._toggle_backups_control.set_visible_button(2, False)
dirname = self._toggle_backups_map.get(selection, "")
if dirname:
backup_path = TOGGLE_BACKUPS / dirname
subprocess.run(
f"cp -r {backup_path}/* /data/params/d/",
shell=True,
capture_output=True
)
update_frogpilot_toggles()
self._toggle_backups_control.set_value("Restored!")
time.sleep(2.5)
self._toggle_backups_control.set_value("")
self._toggle_backups_control.set_enabled(True)
self._toggle_backups_control.set_visible_button(0, True)
self._toggle_backups_control.set_visible_button(1, True)
self._toggle_backups_control.set_visible_button(2, True)
threading.Thread(target=restore_thread, daemon=True).start()
# ==================== STATS ====================
def _on_stats_click(self, button_id: int):
if button_id == 0:
# RESET stats
self._pending_action = "stats_reset"
gui_app.set_modal_overlay(ConfirmDialog(
"Are you sure you want to reset all of your FrogPilot stats?",
"Reset",
"Cancel",
))
elif button_id == 1:
# VIEW stats
self._show_stats = True
self._update_stats_labels()
def _do_reset_stats(self):
self._params.remove("FrogPilotStats")
if self._show_stats:
self._update_stats_labels()
def _close_stats(self):
self._show_stats = False
# ==================== DIALOG RESULT HANDLING ====================
def _on_keyboard_result(self, result: DialogResult):
"""Callback for keyboard modal overlay."""
self.handle_dialog_result(result, self._keyboard.text)
def handle_dialog_result(self, result: DialogResult, selection: str = ""):
"""Handle dialog results from modal overlays."""
action = self._pending_action
self._pending_action = None
if result != DialogResult.CONFIRM:
return
# Delete driving data
if action == "delete_driving_data":
self._do_delete_driving_data()
# Delete error logs
elif action == "delete_error_logs":
self._do_delete_error_logs()
# Screen recordings - delete single
elif action == "recording_delete_select" and selection:
self._pending_data["recording_selection"] = selection
self._pending_action = "recording_delete_confirm"
gui_app.set_modal_overlay(ConfirmDialog(
"Delete this screen recording?",
"Delete",
"Cancel",
))
elif action == "recording_delete_confirm":
selection = self._pending_data.pop("recording_selection", "")
if selection:
self._do_delete_recording(selection)
# Screen recordings - delete all
elif action == "recording_delete_all":
self._do_delete_all_recordings()
# Screen recordings - rename
elif action == "recording_rename_select" and selection:
self._pending_data["recording_selection"] = selection
self._pending_action = "recording_rename_input"
self._keyboard.reset()
self._keyboard.set_title("Enter a new name", "Rename Screen Recording")
gui_app.set_modal_overlay(self._keyboard, callback=self._on_keyboard_result)
elif action == "recording_rename_input" and selection:
old_selection = self._pending_data.pop("recording_selection", "")
new_name = selection.strip()
if old_selection and new_name:
# Check for duplicate name
new_filename = new_name.replace(" ", "_") + ".mp4"
existing_files = [f.name for f in SCREEN_RECORDINGS_PATH.iterdir() if f.is_file()]
if new_filename in existing_files:
gui_app.set_modal_overlay(alert_dialog("Name already in use. Please choose a different name!"))
else:
self._do_rename_recording(old_selection, new_name)
# FrogPilot backups - create
elif action == "fp_backup_create" and selection:
backup_name = selection.strip().replace(" ", "_")
if backup_name:
existing_files = [f.name for f in FROGPILOT_BACKUPS.iterdir() if f.is_file()]
if backup_name + ".tar.zst" in existing_files:
gui_app.set_modal_overlay(alert_dialog("Name already in use. Please choose a different name!"))
else:
self._do_create_fp_backup(backup_name)
# FrogPilot backups - delete single
elif action == "fp_backup_delete_select" and selection:
self._pending_data["fp_backup_selection"] = selection
self._pending_action = "fp_backup_delete_confirm"
gui_app.set_modal_overlay(ConfirmDialog(
"Delete this backup?",
"Delete",
"Cancel",
))
elif action == "fp_backup_delete_confirm":
selection = self._pending_data.pop("fp_backup_selection", "")
if selection:
self._do_delete_fp_backup(selection)
# FrogPilot backups - delete all
elif action == "fp_backup_delete_all":
self._do_delete_all_fp_backups()
# FrogPilot backups - restore
elif action == "fp_backup_restore_select" and selection:
self._pending_data["fp_backup_selection"] = selection
self._pending_action = "fp_backup_restore_confirm"
gui_app.set_modal_overlay(ConfirmDialog(
"Restore this backup? This will overwrite your current installation and reboot the device.",
"Restore",
"Cancel",
))
elif action == "fp_backup_restore_confirm":
selection = self._pending_data.pop("fp_backup_selection", "")
if selection:
self._do_restore_fp_backup(selection)
# Toggle backups - create
elif action == "toggle_backup_create" and selection:
backup_name = selection.strip().replace(" ", "_")
if backup_name:
existing_dirs = [d.name for d in TOGGLE_BACKUPS.iterdir() if d.is_dir()]
if backup_name in existing_dirs:
gui_app.set_modal_overlay(alert_dialog("Name already in use. Please choose a different name!"))
else:
self._do_create_toggle_backup(backup_name)
# Toggle backups - delete single
elif action == "toggle_backup_delete_select" and selection:
self._pending_data["toggle_backup_selection"] = selection
self._pending_action = "toggle_backup_delete_confirm"
gui_app.set_modal_overlay(ConfirmDialog(
"Delete this backup?",
"Delete",
"Cancel",
))
elif action == "toggle_backup_delete_confirm":
selection = self._pending_data.pop("toggle_backup_selection", "")
if selection:
self._do_delete_toggle_backup(selection)
# Toggle backups - delete all
elif action == "toggle_backup_delete_all":
self._do_delete_all_toggle_backups()
# Toggle backups - restore
elif action == "toggle_backup_restore_select" and selection:
self._pending_data["toggle_backup_selection"] = selection
self._pending_action = "toggle_backup_restore_confirm"
gui_app.set_modal_overlay(ConfirmDialog(
"Restore this backup? This will overwrite your current settings!",
"Restore",
"Cancel",
))
elif action == "toggle_backup_restore_confirm":
selection = self._pending_data.pop("toggle_backup_selection", "")
if selection:
self._do_restore_toggle_backup(selection)
# Stats reset
elif action == "stats_reset":
self._do_reset_stats()
# ==================== STATS DISPLAY ====================
def _format_number(self, number):
return f"{number:,.0f}" if isinstance(number, float) else f"{number:,}"
def _format_distance(self, meters):
if self._is_metric:
value = meters / 1000.0
unit = "kilometer" if value == 1.0 else "kilometers"
else:
value = meters * METER_TO_MILE
unit = "mile" if value == 1.0 else "miles"
return f"{self._format_number(round(value))} {unit}"
def _format_time(self, seconds):
seconds = int(seconds)
days = seconds // 86400
hours = (seconds % 86400) // 3600
minutes = (seconds % 3600) // 60
parts = []
if days > 0:
parts.append(f"{self._format_number(days)} {'day' if days == 1 else 'days'}")
if hours > 0 or days > 0:
parts.append(f"{self._format_number(hours)} {'hour' if hours == 1 else 'hours'}")
parts.append(f"{self._format_number(minutes)} {'minute' if minutes == 1 else 'minutes'}")
return " ".join(parts)
def _update_stats_labels(self):
self._stats_items = []
try:
stats_data = self._params.get("FrogPilotStats")
stats = json.loads(stats_data) if stats_data else {}
except (json.JSONDecodeError, TypeError):
stats = {}
tracked_time = stats.get("TrackedTime", 0.0)
sorted_keys = sorted(KEY_MAP.keys(), key=lambda k: KEY_MAP[k][0].lower())
for key in sorted_keys:
if key in IGNORED_KEYS:
continue
label_text, stat_type = KEY_MAP[key]
value = stats.get(key, 0)
if key == "AEBEvents":
total_events = stats.get("TotalEvents", {})
count = total_events.get("stockAeb", 0) + total_events.get("fcw", 0)
trimmed = label_text.replace("Total ", "", 1) if label_text.startswith("Total ") else label_text
display = f"{self._format_number(count)} {trimmed}"
self._stats_items.append(ListItem(title=label_text, action_item=TextAction(display, color=ITEM_TEXT_VALUE_COLOR)))
elif key == "CruiseSpeedTimes" and isinstance(value, dict):
max_time = -1
best_speed = ""
for speed_key, time_val in value.items():
if time_val > max_time:
best_speed = speed_key
max_time = time_val
if best_speed:
speed_val = float(best_speed)
if self._is_metric:
display_speed = f"{round(speed_val * MS_TO_KPH)} km/h"
else:
display_speed = f"{round(speed_val * MS_TO_MPH)} mph"
display = f"{display_speed} ({self._format_time(max_time)})"
self._stats_items.append(ListItem(title=label_text, action_item=TextAction(display, color=ITEM_TEXT_VALUE_COLOR)))
elif stat_type == "parent" and isinstance(value, dict):
self._stats_items.append(ListItem(title=label_text))
if key == "RandomEvents":
sub_keys = sorted(RANDOM_EVENTS_MAP.keys(), key=lambda k: RANDOM_EVENTS_MAP.get(k, k).lower())
else:
sub_keys = sorted(value.keys(), key=lambda k: k.lower())
for subkey in sub_keys:
if subkey == "Unknown":
continue
if key == "ModelTimes":
display_subkey = clean_model_name(subkey)
elif key == "RandomEvents":
display_subkey = RANDOM_EVENTS_MAP.get(subkey, subkey)
elif key == "WeatherTimes":
display_subkey = subkey.capitalize()
else:
display_subkey = subkey
if key.endswith("Times"):
subvalue = self._format_time(value.get(subkey, 0))
else:
subvalue = self._format_number(value.get(subkey, 0))
self._stats_items.append(ListItem(title=f" {display_subkey}", action_item=TextAction(subvalue, color=ITEM_TEXT_VALUE_COLOR)))
else:
if stat_type == "accel":
display = f"{value:.2f} m/s²"
elif stat_type == "count":
trimmed = label_text.replace("Total ", "", 1) if label_text.startswith("Total ") else label_text
display = f"{self._format_number(int(value))} {trimmed}"
elif stat_type == "distance":
display = self._format_distance(float(value))
elif stat_type in ("time", "timePercent"):
display = self._format_time(float(value))
else:
display = str(value) if value else "0"
self._stats_items.append(ListItem(title=label_text, action_item=TextAction(display, color=ITEM_TEXT_VALUE_COLOR)))
if stat_type == "timePercent" and tracked_time > 0:
percent = int((float(value) * 100.0) / tracked_time)
self._stats_items.append(ListItem(title=f"% of {label_text}", action_item=TextAction(f"{self._format_number(percent)}%", color=ITEM_TEXT_VALUE_COLOR)))
self._stats_scroller = Scroller(self._stats_items, line_separator=True, spacing=0)
# ==================== LIFECYCLE ====================
def show_event(self):
super().show_event()
self._main_scroller.show_event()
self._is_metric = self._params.get_bool("IsMetric")
self._show_stats = False
def hide_event(self):
super().hide_event()
self._show_stats = False
def _render(self, rect):
if self._show_stats and self._stats_scroller:
self._stats_scroller.render(rect)
else:
self._main_scroller.render(rect)