Files
2026-07-21 11:57:51 -04:00

1374 lines
56 KiB
Python

from __future__ import annotations
from dataclasses import dataclass, replace
from collections.abc import Callable
import json
import math
import shutil
import threading
import time
import pyray as rl
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.starpilot.assets.model_manager import (
CANCEL_DOWNLOAD_PARAM,
DOWNLOAD_PROGRESS_PARAM,
MODEL_DOWNLOAD_ALL_PARAM,
MODEL_DOWNLOAD_PARAM,
ModelManager,
canonical_model_key,
is_builtin_model_key,
model_key_aliases,
)
from openpilot.starpilot.common.starpilot_variables import MODELS_PATH, update_starpilot_toggles
from openpilot.system.ui.lib.application import FontWeight, MouseEvent, MousePos, gui_app
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2
from openpilot.system.ui.widgets import DialogResult, Widget
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dialog
from openpilot.system.ui.widgets.label import gui_label
from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog
from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import _SettingsPage
from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import (
AETHER_LIST_METRICS,
AetherButton,
AetherInteractiveMixin,
AetherChip,
AetherListColors,
AetherScrollbar,
DEFAULT_PANEL_STYLE,
point_hits,
draw_action_rail,
draw_action_pill,
draw_busy_ring,
draw_download_icon,
draw_empty_state_card,
draw_heart_icon,
draw_list_panel_shell,
draw_list_group_shell,
build_list_panel_frame,
draw_list_row_shell,
draw_list_scroll_fades,
draw_section_header,
draw_settings_list_row,
draw_settings_panel_header,
draw_status_led,
draw_overflow_dots,
draw_rounded_fill,
draw_rounded_stroke,
init_list_panel,
draw_interactive_rect,
resolve_interactive_target,
wrap_text,
with_alpha,
SECTION_GAP,
SECTION_HEADER_HEIGHT,
SECTION_HEADER_GAP,
ROW_HEIGHT,
)
UTILITY_ROW_HEIGHT = AETHER_LIST_METRICS.utility_row_height
ROW_RADIUS = AETHER_LIST_METRICS.row_radius
ACTION_WIDTH = AETHER_LIST_METRICS.action_width
BUTTON_HEIGHT = AETHER_LIST_METRICS.header_button_height
FADE_HEIGHT = AETHER_LIST_METRICS.fade_height
DRIVING_MODEL_METRICS = replace(AETHER_LIST_METRICS, header_height=0)
CONFIRM_TIMEOUT_SECONDS = 3.0
TRANSITION_SECONDS = 0.24
PANEL_STYLE = DEFAULT_PANEL_STYLE
BANNER_HEIGHT = ROW_HEIGHT
HEADER_BAR_HEIGHT = 107.0
MANAGEMENT_STRIP_HEIGHT = 56.0
_SORT_MODES = ("alphabetical", "date", "date_oldest", "favorites", "community_picks")
_SORT_LABELS = {
"alphabetical": "Alphabetical",
"date": "Date (Newest)",
"date_oldest": "Date (Oldest)",
"favorites": "Favorites + Downloaded",
"community_picks": "Community Picks",
}
_SORT_PILLS = ("alphabetical", "date", "favorites", "community_picks")
@dataclass
class ModelCatalogEntry:
key: str
name: str
series: str
version: str
released: str
builtin: bool
installed: bool
partial: bool
community_favorite: bool
user_favorite: bool
def _clean_model_name(name: str) -> str:
return str(name or "").replace("_default", "").replace("(Default)", "").strip()
def _ease(current: float, target: float, tau: float = 0.085) -> float:
dt = max(rl.get_frame_time(), 1 / max(gui_app.target_fps, 1))
return current + (target - current) * (1 - math.exp(-dt / tau))
class DrivingModelManagerView(AetherInteractiveMixin, Widget):
def __init__(self, controller: "StarPilotDrivingModelLayout"):
super().__init__()
self._controller = controller
self._scroll_panel = GuiScrollPanel2(horizontal=False)
self._scrollbar = AetherScrollbar()
self._content_height = 0.0
self._scroll_offset = 0.0
self._confirm_key: str | None = None
self._confirm_until = 0.0
self._transition_starts: dict[str, tuple[float, float]] = {}
self._known_install_state: dict[str, bool] = {}
self._active_download_key: str | None = None
self._shell_rect = rl.Rectangle(0, 0, 0, 0)
self._scroll_rect = rl.Rectangle(0, 0, 0, 0)
self._metric_font = gui_app.font(FontWeight.BOLD)
self._primary_header_button = self._child(
AetherButton(
lambda: self._controller.primary_header_button_state()[0],
lambda: self._controller.cancel_active_download() if self._controller._is_download_active() else self._controller.download_all_missing(),
enabled=lambda: self._controller.primary_header_button_state()[1],
emphasized=True,
accent_color=rl.Color(139, 92, 246, 92),
)
)
self._secondary_header_button = self._child(
AetherButton(
lambda: self._controller.secondary_header_button_state()[0],
self._controller.refresh_manifest,
enabled=lambda: self._controller.secondary_header_button_state()[1],
emphasized=False,
)
)
self._random_model_button = self._child(
AetherButton(
lambda: self._controller.random_model_button_label(),
self._controller.toggle_model_randomizer,
emphasized=False,
font_size=28,
)
)
self._primary_header_button.set_touch_valid_callback(lambda: self._scroll_panel.is_touch_valid())
self._secondary_header_button.set_touch_valid_callback(lambda: self._scroll_panel.is_touch_valid())
self._random_model_button.set_touch_valid_callback(lambda: self._scroll_panel.is_touch_valid())
def _clear_ephemeral_state(self):
self._pressed_target = None
self._can_click = True
self._confirm_key = None
self._confirm_until = 0.0
def show_event(self):
super().show_event()
self._clear_ephemeral_state()
def hide_event(self):
super().hide_event()
self._clear_ephemeral_state()
def _update_state(self):
super()._update_state()
if self._confirm_key is not None and time.monotonic() >= self._confirm_until:
self._confirm_key = None
self._random_model_button.set_emphasized(self._controller._params.get_bool("ModelRandomizer"))
progress = self._controller.download_progress_text()
active_key = canonical_model_key(self._controller._params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or "")
if active_key:
self._active_download_key = active_key
elif self._controller._params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM):
parsed_key = self._controller._model_key_for_progress(progress)
if parsed_key:
self._active_download_key = parsed_key
elif not self._controller._is_download_active():
self._active_download_key = None
latest_state = {key: entry.installed for key, entry in self._controller._catalog_entries.items()}
for key, installed in latest_state.items():
previous = self._known_install_state.get(key)
if previous is None:
self._known_install_state[key] = installed
continue
if previous != installed:
direction = 1.0 if installed else -1.0
self._transition_starts[key] = (time.monotonic(), direction)
self._known_install_state[key] = installed
for key in list(self._transition_starts.keys()):
started_at, _direction = self._transition_starts[key]
if time.monotonic() - started_at >= TRANSITION_SECONDS:
self._transition_starts.pop(key, None)
def _target_at(self, mouse_pos: MousePos) -> str | None:
for prefix in ("menu:", "action:", "utility:", "row:"):
for target_id, rect in self._interactive_rects.items():
if target_id.startswith(prefix):
pad_y = 6 if prefix == "menu:" else 0
if point_hits(mouse_pos, rect, self._scroll_rect, pad_x=6, pad_y=pad_y):
return target_id
for target_id, rect in self._interactive_rects.items():
if target_id.startswith("sortopt:") or target_id.startswith("mgmt:"):
if point_hits(mouse_pos, rect, self._shell_rect, pad_x=6, pad_y=6):
return target_id
return None
def _activate_target(self, target: str | None):
if not target:
return
if target.startswith("row:"):
self._confirm_key = None
model_key = target.split(":", 1)[1]
self._controller.select_model(model_key)
return
if target.startswith("action:"):
model_key = target.split(":", 1)[1]
entry = self._controller._catalog_entries.get(model_key)
if entry is None:
return
if not entry.installed:
self._confirm_key = None
self._controller.start_download(model_key)
return
if not self._controller.is_model_removable(model_key):
return
# Toggle the options menu open/closed
if self._confirm_key == model_key:
self._confirm_key = None
else:
self._confirm_key = model_key
self._confirm_until = time.monotonic() + CONFIRM_TIMEOUT_SECONDS
return
if target.startswith("menu:"):
parts = target.split(":", 2)
model_key = parts[1] if len(parts) > 1 else ""
action = parts[2] if len(parts) > 2 else ""
self._confirm_key = None
if action == "delete":
self._controller.delete_model(model_key)
elif action == "favorite":
self._controller.toggle_favorite(model_key)
return
if target.startswith("utility:"):
action = target.split(":", 1)[1]
if action == "blacklist":
self._controller._on_blacklist_clicked()
elif action == "ratings":
self._controller._on_scores_clicked()
return
if target.startswith("mgmt:"):
action = target.split(":", 1)[1]
if action == "blacklist":
self._controller._on_blacklist_clicked()
elif action == "ratings":
self._controller._on_scores_clicked()
return
if target.startswith("sortopt:"):
mode = target.split(":", 1)[1]
if mode == "date":
current = self._controller._get_sort_mode()
mode = "date_oldest" if current == "date" else "date"
self._controller._params.put("ModelSortMode", mode)
return
def _render(self, rect: rl.Rectangle):
self.set_rect(rect)
self._interactive_rects.clear()
frame, scroll_rect, content_width = init_list_panel(rect, PANEL_STYLE, metrics=DRIVING_MODEL_METRICS)
self._shell_rect = frame.shell
current_entry = self._controller.current_entry()
use_banner = current_entry is not None
if use_banner:
banner_rect = rl.Rectangle(scroll_rect.x, scroll_rect.y, scroll_rect.width, BANNER_HEIGHT)
scroll_rect = rl.Rectangle(scroll_rect.x, scroll_rect.y + BANNER_HEIGHT,
scroll_rect.width, scroll_rect.height - BANNER_HEIGHT)
self._draw_current_banner(banner_rect, current_entry)
header_y = scroll_rect.y
self._draw_relocated_header(scroll_rect.x, header_y, content_width)
scroll_rect = rl.Rectangle(scroll_rect.x, scroll_rect.y + HEADER_BAR_HEIGHT,
scroll_rect.width, scroll_rect.height - HEADER_BAR_HEIGHT)
randomizer_on = self._controller._params.get_bool("ModelRandomizer")
mgmt_y = scroll_rect.y
self._draw_sort_strip(scroll_rect.x, mgmt_y, content_width, randomizer_on)
scroll_rect = rl.Rectangle(scroll_rect.x, scroll_rect.y + MANAGEMENT_STRIP_HEIGHT,
scroll_rect.width, scroll_rect.height - MANAGEMENT_STRIP_HEIGHT)
self._scroll_rect = scroll_rect
self._draw_header(frame.header)
self._content_height = self._measure_content_height(content_width)
self._scroll_panel.set_enabled(lambda: not self._controller._is_download_active())
self._scroll_offset = self._scroll_panel.update(scroll_rect, max(self._content_height, scroll_rect.height))
rl.begin_scissor_mode(int(scroll_rect.x), int(scroll_rect.y), int(scroll_rect.width), int(scroll_rect.height))
self._draw_scroll_content(scroll_rect, content_width)
rl.end_scissor_mode()
if self._content_height > scroll_rect.height:
self._draw_scrollbar(scroll_rect)
draw_list_scroll_fades(scroll_rect, self._content_height, self._scroll_offset, AetherListColors.PANEL_BG, fade_height=FADE_HEIGHT)
def _draw_header(self, rect: rl.Rectangle):
pass
def _draw_current_banner(self, rect: rl.Rectangle, entry: ModelCatalogEntry):
draw_list_row_shell(
rect,
current=True,
hovered=False,
pressed=False,
is_last=False,
alpha=255,
row_bg=AetherListColors.ROW_BG,
row_border=AetherListColors.ROW_BORDER,
row_separator=AetherListColors.ROW_SEPARATOR,
row_hover=AetherListColors.ROW_HOVER,
current_bg=AetherListColors.CURRENT_BG,
current_border=AetherListColors.CURRENT_BORDER,
row_radius=ROW_RADIUS,
separator_inset=22,
)
info_rect = rl.Rectangle(rect.x + 24, rect.y + 18, rect.width - ACTION_WIDTH - 42, rect.height - 36)
self._draw_model_info(info_rect, entry, current=True)
chip_rect = rl.Rectangle(rect.x + rect.width - ACTION_WIDTH + 35, rect.y + (rect.height - 61) / 2, ACTION_WIDTH - 70, 61)
AetherChip(tr("Current"), PANEL_STYLE.current_fill, PANEL_STYLE.current_border, AetherListColors.HEADER, font_size=26).render(chip_rect)
def _draw_relocated_header(self, x: float, y: float, width: float):
# Buttons placed horizontally
btn_gap = float(AETHER_LIST_METRICS.header_button_gap)
btn_w = (width - 16.0 - btn_gap * 2) / 3.0
primary_rect = rl.Rectangle(x + 8, y, btn_w, BUTTON_HEIGHT)
secondary_rect = rl.Rectangle(x + 8 + btn_w + btn_gap, y, btn_w, BUTTON_HEIGHT)
random_rect = rl.Rectangle(x + 8 + (btn_w + btn_gap) * 2, y, btn_w, BUTTON_HEIGHT)
self._primary_header_button.render(primary_rect)
self._secondary_header_button.render(secondary_rect)
self._random_model_button.render(random_rect)
# LED indicator
led_x = int(random_rect.x + random_rect.width - 38)
led_y = int(random_rect.y + random_rect.height / 2)
randomizer_on = self._controller._params.get_bool("ModelRandomizer")
draw_status_led(rl.Vector2(led_x, led_y), randomizer_on)
def _draw_sort_strip(self, x: float, y: float, width: float, randomizer_on: bool):
pill_h = 48.0
pill_y = y + (MANAGEMENT_STRIP_HEIGHT - pill_h) / 2
left = x + 16
gap = 8.0
usable = width - 32.0
if randomizer_on:
blacklisted = [m.strip() for m in (self._controller._params.get("BlacklistedModels", encoding="utf-8") or "").split(",") if m.strip()]
bl_label = tr(f"Blacklist: {len(blacklisted)} blocked") if blacklisted else tr("Blacklist")
bl_w = (usable - gap) / 2
rt_w = (usable - gap) / 2
bl_pill = rl.Rectangle(left, pill_y, bl_w, pill_h)
draw_action_pill(bl_pill, bl_label,
with_alpha(AetherListColors.PRIMARY, 18),
with_alpha(AetherListColors.PRIMARY, 50),
AetherListColors.HEADER, font_size=28, roundness=0.35)
self._interactive_rects["mgmt:blacklist"] = bl_pill
rt_pill = rl.Rectangle(left + bl_w + gap, pill_y, rt_w, pill_h)
draw_action_pill(rt_pill, tr("Ratings"),
with_alpha(AetherListColors.PRIMARY, 18),
with_alpha(AetherListColors.PRIMARY, 50),
AetherListColors.HEADER, font_size=28, roundness=0.35)
self._interactive_rects["mgmt:ratings"] = rt_pill
return
sort_mode = self._controller._get_sort_mode()
n = len(_SORT_PILLS)
seg_w = (usable - gap * (n - 1)) / n
for i, mode in enumerate(_SORT_PILLS):
seg_x = left + i * (seg_w + gap)
seg_rect = rl.Rectangle(seg_x, pill_y, seg_w, pill_h)
if mode == "date":
is_active = sort_mode in ("date", "date_oldest")
label = tr("Date (Oldest)") if sort_mode == "date_oldest" else tr("Date (Newest)")
else:
is_active = (mode == sort_mode)
label = tr(_SORT_LABELS[mode])
pressed = self._pressed_target == f"sortopt:{mode}"
if pressed and is_active:
fill = with_alpha(AetherListColors.PRIMARY, 80)
elif pressed:
fill = rl.Color(255, 255, 255, 18)
elif is_active:
fill = with_alpha(AetherListColors.PRIMARY, 28)
else:
fill = rl.Color(255, 255, 255, 8)
border = with_alpha(AetherListColors.PRIMARY, 80) if is_active else rl.Color(255, 255, 255, 24)
draw_action_pill(seg_rect, label, fill, border, AetherListColors.HEADER, font_size=28, roundness=0.3)
self._interactive_rects[f"sortopt:{mode}"] = seg_rect
def _measure_content_height(self, width: float) -> float:
sections = self._build_sections(width)
if not sections:
return 260.0
return max(sum(height for _key, height in sections) - SECTION_GAP, 0.0)
def _build_sections(self, width: float) -> list[tuple[str, float]]:
sections: list[tuple[str, float]] = []
sort_mode = self._controller._get_sort_mode()
if sort_mode == "favorites":
fav = self._controller.favorites_entries()
if fav:
count = len(fav)
if self._controller.current_entry() is not None and self._controller.current_entry().user_favorite:
count = max(count - 1, 0)
sections.append(("favorites", SECTION_HEADER_HEIGHT + SECTION_HEADER_GAP + count * ROW_HEIGHT))
fav_keys = {e.key for e in fav}
other_installed = [e for e in self._controller.installed_entries() if e.key not in fav_keys]
if other_installed:
count = len(other_installed)
if self._controller.current_entry() is not None and not self._controller.current_entry().user_favorite:
count = max(count - 1, 0)
if count > 0:
sections.append(("downloaded", SECTION_HEADER_HEIGHT + SECTION_HEADER_GAP + count * ROW_HEIGHT))
elif sort_mode == "community_picks":
community = self._controller.community_picks_entries()
if community:
count = len(community)
if self._controller.current_entry() is not None and self._controller.current_entry().community_favorite:
count = max(count - 1, 0)
sections.append(("community_picks", SECTION_HEADER_HEIGHT + SECTION_HEADER_GAP + count * ROW_HEIGHT))
else:
installed = self._controller.installed_entries()
available = self._controller.available_entries()
if self._controller._params.get_bool("ModelRandomizer"):
available = []
if installed:
installed_count = len(installed)
if self._controller.current_entry() is not None:
installed_count = max(installed_count - 1, 0)
sections.append(("installed", SECTION_HEADER_HEIGHT + SECTION_HEADER_GAP + installed_count * ROW_HEIGHT))
if available:
sections.append(("available", SECTION_HEADER_HEIGHT + SECTION_HEADER_GAP + len(available) * ROW_HEIGHT))
if not sections:
sections.append(("empty", 240))
return [(key, height + SECTION_GAP) for key, height in sections]
def _draw_scroll_content(self, rect: rl.Rectangle, width: float):
sort_mode = self._controller._get_sort_mode()
y = rect.y + self._scroll_offset
if sort_mode == "favorites":
fav_entries = self._controller.favorites_entries()
if self._controller.current_entry() is not None:
fav_entries = [e for e in fav_entries if not self._controller.is_current_model(e.key)]
fav_keys = {e.key for e in self._controller.favorites_entries()}
other_installed = [e for e in self._controller.installed_entries() if e.key not in fav_keys]
if self._controller.current_entry() is not None:
other_installed = [e for e in other_installed if not self._controller.is_current_model(e.key)]
if fav_entries or other_installed:
if fav_entries:
y = self._draw_model_section(rect.x, y, width, tr("Favorites"), fav_entries)
y += SECTION_GAP
if other_installed:
y = self._draw_model_section(rect.x, y, width, tr("Downloaded"), other_installed)
y += SECTION_GAP
else:
self._draw_empty_state(rl.Rectangle(rect.x, y + 36, width, 200))
return
if sort_mode == "community_picks":
entries = self._controller.community_picks_entries()
if self._controller.current_entry() is not None:
entries = [e for e in entries if not self._controller.is_current_model(e.key)]
if entries:
y = self._draw_model_section(rect.x, y, width, tr("Community Picks"), entries)
else:
self._draw_empty_state(rl.Rectangle(rect.x, y + 36, width, 200))
return
installed = self._controller.installed_entries()
if self._controller.current_entry() is not None:
installed = [e for e in installed if not self._controller.is_current_model(e.key)]
available = self._controller.available_entries()
if self._controller._params.get_bool("ModelRandomizer"):
available = []
if not installed and not available:
self._draw_empty_state(rl.Rectangle(rect.x, y + 36, width, 200))
return
if installed:
y = self._draw_model_section(rect.x, y, width, tr("On Device"), installed)
y += SECTION_GAP
if available:
y = self._draw_model_section(rect.x, y, width, tr("Available to Download"), available)
y += SECTION_GAP
def _draw_empty_state(self, rect: rl.Rectangle):
draw_empty_state_card(
rl.Rectangle(rect.x, rect.y, rect.width, rect.height),
self._controller.empty_state_title(),
self._controller.empty_state_body(),
title_size=46,
body_size=36,
body_inset_x=48,
title_top_padding=42,
body_height=72,
style=PANEL_STYLE,
)
def _draw_model_section(self, x: float, y: float, width: float, title: str, entries: list[ModelCatalogEntry]) -> float:
draw_section_header(rl.Rectangle(x, y, width, SECTION_HEADER_HEIGHT), title, style=PANEL_STYLE)
y += SECTION_HEADER_HEIGHT + SECTION_HEADER_GAP
group_rect = rl.Rectangle(x, y, width, len(entries) * ROW_HEIGHT)
draw_list_group_shell(group_rect, style=PANEL_STYLE)
for index, entry in enumerate(entries):
row_rect = rl.Rectangle(x, y + index * ROW_HEIGHT, width, ROW_HEIGHT)
self._draw_model_row(row_rect, entry, is_last=index == len(entries) - 1)
return y + len(entries) * ROW_HEIGHT
def _draw_model_row(self, rect: rl.Rectangle, entry: ModelCatalogEntry, is_last: bool):
mouse_pos = gui_app.last_mouse_event.pos
row_hovered = bool(point_hits(mouse_pos, rect, self._scroll_rect, pad_x=6, pad_y=0))
target_key = f"row:{entry.key}"
pressed = self._pressed_target == target_key
current = self._controller.is_current_model(entry.key)
downloading = self._controller.is_entry_actively_downloading(entry.key, self._active_download_key)
removable = self._controller.is_model_removable(entry.key)
is_menu_open = (self._confirm_key == entry.key)
alpha, offset_y, scale = self._row_transition_style(entry.key)
draw_rect = rl.Rectangle(
rect.x + (rect.width * (1 - scale) / 2), rect.y + offset_y + (rect.height * (1 - scale) / 2), rect.width * scale, rect.height * scale
)
draw_list_row_shell(
draw_rect,
current=current,
hovered=row_hovered,
pressed=pressed,
is_last=is_last,
alpha=alpha,
row_bg=AetherListColors.ROW_BG,
row_border=AetherListColors.ROW_BORDER,
row_separator=AetherListColors.ROW_SEPARATOR,
row_hover=AetherListColors.ROW_HOVER,
current_bg=AetherListColors.CURRENT_BG,
current_border=AetherListColors.CURRENT_BORDER,
row_radius=ROW_RADIUS,
separator_inset=22,
)
action_rect = draw_action_rail(draw_rect, ACTION_WIDTH, current=current, alpha=alpha, fill=AetherListColors.ACTION_BG, separator=AetherListColors.ACTION_SEPARATOR, inset_y=18)
info_rect = rl.Rectangle(draw_rect.x + 24, draw_rect.y + 18, draw_rect.width - ACTION_WIDTH - 42, draw_rect.height - 36)
row_touchable = entry.installed and not self._controller._params.get_bool("ModelRandomizer")
if row_touchable:
self._interactive_rects[f"row:{entry.key}"] = draw_rect
self._draw_model_info(info_rect, entry, current)
if entry.installed:
randomizer_on = self._controller._params.get_bool("ModelRandomizer")
if current and not randomizer_on:
self._draw_current_action(action_rect)
elif not removable:
self._draw_protected_action(action_rect)
else:
self._interactive_rects[f"action:{entry.key}"] = action_rect
self._draw_menu_action(action_rect, is_menu_open, entry)
else:
self._interactive_rects[f"action:{entry.key}"] = action_rect
if downloading:
self._draw_downloading_action(action_rect, self._controller.download_progress_text())
else:
self._draw_download_action(action_rect)
def _draw_model_info(self, rect: rl.Rectangle, entry: ModelCatalogEntry, current: bool):
heart_offset = 0
if entry.user_favorite:
heart_color = rl.Color(210, 100, 130, 230)
heart_center = rl.Vector2(rect.x + 22, rect.y + 25)
draw_heart_icon(heart_center, heart_color)
heart_offset = 49
title_rect = rl.Rectangle(rect.x + heart_offset, rect.y, rect.width - heart_offset, 49)
gui_label(title_rect, entry.name, 49, AetherListColors.HEADER, FontWeight.MEDIUM)
meta_parts = [part for part in (entry.series, entry.released) if part]
meta_rect = rl.Rectangle(rect.x, rect.y + 61, rect.width, 35)
gui_label(meta_rect, "".join(meta_parts), 32, AetherListColors.SUBTEXT, FontWeight.NORMAL)
badge_parts: list[str] = []
if self._controller._params.get_bool("ModelRandomizer"):
badge_parts.append(tr("In Pool"))
elif current:
badge_parts.append(tr("Active"))
elif entry.builtin:
badge_parts.append(tr("Built-in"))
if entry.partial:
badge_parts.append(tr("Incomplete"))
if entry.user_favorite:
badge_parts.append(tr("Saved"))
elif entry.community_favorite:
badge_parts.append(tr("Popular"))
if badge_parts:
badge_rect = rl.Rectangle(rect.x, rect.y + 113, rect.width, 32)
badge_color = AetherListColors.WARNING if entry.partial else AetherListColors.MUTED
gui_label(badge_rect, "".join(badge_parts), 30, badge_color, FontWeight.MEDIUM)
def _draw_download_action(self, rect: rl.Rectangle):
center_x = rect.x + rect.width / 2
center_y = rect.y + rect.height / 2 - 8
draw_download_icon(rl.Vector2(center_x, center_y), AetherListColors.HEADER)
gui_label(
rl.Rectangle(rect.x + 16, rect.y + rect.height - 58, rect.width - 32, 32),
tr("Download"),
26,
AetherListColors.SUBTEXT,
FontWeight.MEDIUM,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
)
def _draw_downloading_action(self, rect: rl.Rectangle, progress_text: str):
center = rl.Vector2(rect.x + rect.width / 2, rect.y + rect.height / 2 - 8)
phase = (time.monotonic() * 240.0) % 360.0
draw_busy_ring(center, phase, PANEL_STYLE.accent)
label = progress_text if progress_text else tr("Downloading")
gui_label(
rl.Rectangle(rect.x + 16, rect.y + rect.height - 58, rect.width - 32, 32),
label,
28,
AetherListColors.SUBTEXT,
FontWeight.MEDIUM,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
)
def _draw_menu_action(self, rect: rl.Rectangle, is_open: bool, entry: ModelCatalogEntry):
if not is_open:
# Three-dot menu indicator
center_x = rect.x + rect.width / 2
center_y = rect.y + rect.height / 2 - 10
draw_overflow_dots(rl.Vector2(center_x, center_y), rl.Color(AetherListColors.HEADER.r, AetherListColors.HEADER.g, AetherListColors.HEADER.b, min(AetherListColors.HEADER.a, 200)))
gui_label(
rl.Rectangle(rect.x + 16, rect.y + rect.height - 55, rect.width - 32, 32),
tr("Options"),
26,
AetherListColors.SUBTEXT,
FontWeight.MEDIUM,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
)
else:
# Expanded sub-button menu
btn_h = 64
gap = 12
total_h = btn_h * 2 + gap
start_y = rect.y + (rect.height - total_h) / 2
delete_rect = rl.Rectangle(rect.x + 14, start_y, rect.width - 20, btn_h)
fav_rect = rl.Rectangle(rect.x + 14, start_y + btn_h + gap, rect.width - 20, btn_h)
self._interactive_rects[f"menu:{entry.key}:delete"] = delete_rect
self._interactive_rects[f"menu:{entry.key}:favorite"] = fav_rect
# Delete button
draw_action_pill(delete_rect, tr("Delete"), AetherListColors.DANGER_SOFT, rl.Color(AetherListColors.DANGER.r, AetherListColors.DANGER.g, AetherListColors.DANGER.b, min(AetherListColors.DANGER.a, 70)), AetherListColors.DANGER)
# Favorite toggle button
is_fav = entry.user_favorite
fav_fill = rl.Color(210, 100, 130, 44) if is_fav else rl.Color(PANEL_STYLE.accent.r, PANEL_STYLE.accent.g, PANEL_STYLE.accent.b, 26)
fav_border = rl.Color((210 if is_fav else PANEL_STYLE.accent.r), (100 if is_fav else PANEL_STYLE.accent.g), (130 if is_fav else PANEL_STYLE.accent.b), min((255 if is_fav else PANEL_STYLE.accent.a), 70))
fav_text_color = rl.Color(210, 100, 130, 255) if is_fav else PANEL_STYLE.accent
fav_label = tr("Unfavorite") if is_fav else tr("Favorite")
draw_action_pill(fav_rect, fav_label, fav_fill, fav_border, fav_text_color)
def _draw_current_action(self, rect: rl.Rectangle):
chip_rect = rl.Rectangle(rect.x + 35, rect.y + (rect.height - 61) / 2, rect.width - 70, 61)
AetherChip(tr("Current"), PANEL_STYLE.current_fill, PANEL_STYLE.current_border, AetherListColors.HEADER, font_size=26).render(chip_rect)
def _draw_protected_action(self, rect: rl.Rectangle):
chip_rect = rl.Rectangle(rect.x + 29, rect.y + (rect.height - 61) / 2, rect.width - 58, 61)
AetherChip(tr("Protected"), rl.Color(255, 255, 255, 10), AetherListColors.MUTED, AetherListColors.SUBTEXT, font_size=26).render(chip_rect)
def _draw_utility_section(self, x: float, y: float, width: float, rows: list[dict]):
content_w = width
draw_section_header(rl.Rectangle(x, y, content_w, SECTION_HEADER_HEIGHT), tr("Automation and Tuning"), style=PANEL_STYLE)
y += SECTION_HEADER_HEIGHT + SECTION_HEADER_GAP
container_rect = rl.Rectangle(x, y, content_w, len(rows) * UTILITY_ROW_HEIGHT)
draw_list_group_shell(container_rect, style=PANEL_STYLE)
for index, row in enumerate(rows):
row_rect = rl.Rectangle(x, y + index * UTILITY_ROW_HEIGHT, content_w, UTILITY_ROW_HEIGHT)
self._draw_utility_row(row_rect, row, is_last=index == len(rows) - 1)
def _draw_utility_row(self, rect: rl.Rectangle, row: dict, is_last: bool):
mouse_pos = gui_app.last_mouse_event.pos
hovered = bool(point_hits(mouse_pos, rect, self._scroll_rect, pad_x=6, pad_y=0))
pressed = self._pressed_target == f"utility:{row['id']}"
self._interactive_rects[f"utility:{row['id']}"] = rect
draw_settings_list_row(
rect,
title=row["title"],
subtitle=row.get("subtitle", ""),
value="" if row["type"] == "toggle" else row["value"],
toggle_value=bool(row["value"]) if row["type"] == "toggle" else None,
hovered=hovered,
pressed=pressed,
is_last=is_last,
style=PANEL_STYLE,
)
def _draw_scrollbar(self, rect: rl.Rectangle):
self._scrollbar.render(rect, self._content_height, self._scroll_offset)
def _row_transition_style(self, key: str) -> tuple[int, float, float]:
if key not in self._transition_starts:
return 255, 0.0, 1.0
started_at, direction = self._transition_starts[key]
elapsed = min(max((time.monotonic() - started_at) / TRANSITION_SECONDS, 0.0), 1.0)
eased = 1.0 - (1.0 - elapsed) * (1.0 - elapsed)
alpha = int(150 + 105 * eased)
offset_y = direction * (1.0 - eased) * 14
scale = 0.965 + 0.035 * eased
return alpha, offset_y, scale
class StarPilotDrivingModelLayout(_SettingsPage):
def __init__(self):
super().__init__()
self._model_dir = MODELS_PATH
self._model_dir.mkdir(parents=True, exist_ok=True)
self._catalog_entries: dict[str, ModelCatalogEntry] = {}
self._model_file_to_name: dict[str, str] = {}
self._model_file_to_name_processed: dict[str, str] = {}
self._model_series_map: dict[str, str] = {}
self._model_released_dates: dict[str, str] = {}
self._model_version_map: dict[str, str] = {}
self._community_favorites: set[str] = set()
self._user_favorites: set[str] = set()
self._current_model_key = self._default_model_key()
self._current_model_name = self._default_model_name()
self._model_manager = ModelManager(self._params, self._params_memory)
self._download_thread: threading.Thread | None = None
self._manifest_fetch_thread: threading.Thread | None = None
self._manifest_fetched = False
self._transient_status_text = ""
self._transient_status_until = 0.0
self._manager_view = DrivingModelManagerView(self)
self._fetch_manifest_async()
self._update_model_metadata()
def _render(self, rect: rl.Rectangle):
self._update_state()
super()._render(rect)
def show_event(self):
super().show_event()
self._fetch_manifest_async()
self._update_model_metadata()
def _fetch_manifest_async(self):
if self._manifest_fetch_thread is not None and self._manifest_fetch_thread.is_alive():
return
def _task():
try:
self._model_manager.update_models()
finally:
self._manifest_fetched = True
self._manifest_fetch_thread = threading.Thread(target=_task, daemon=True)
self._manifest_fetch_thread.start()
def _default_model_key(self) -> str:
default_key = self._params.get_default_value("Model") or self._params.get_default_value("DrivingModel")
if isinstance(default_key, bytes):
default_key = default_key.decode("utf-8", errors="ignore")
return canonical_model_key(str(default_key or "").strip()) or "sc2"
def _default_model_name(self) -> str:
default_name = self._params.get_default_value("DrivingModelName")
if isinstance(default_name, bytes):
default_name = default_name.decode("utf-8", errors="ignore")
return _clean_model_name(default_name or "") or "South Carolina"
def _default_model_version(self) -> str:
default_version = self._params.get_default_value("ModelVersion") or self._params.get_default_value("DrivingModelVersion")
if isinstance(default_version, bytes):
default_version = default_version.decode("utf-8", errors="ignore")
return str(default_version or "").strip() or "v11"
def _current_selected_key(self) -> str:
current_key = self._params.get("Model", encoding="utf-8") or self._params.get("DrivingModel", encoding="utf-8") or ""
return canonical_model_key(str(current_key).strip()) or self._default_model_key()
def _load_on_disk_files(self) -> set[str]:
try:
return {entry.name for entry in self._model_dir.iterdir()}
except Exception:
return set()
def _is_model_installed(self, key: str, version: str = "", on_disk_files: set[str] | None = None) -> bool:
model_key = canonical_model_key(str(key or "").strip())
if not model_key:
return False
if is_builtin_model_key(model_key):
return True
files = on_disk_files if on_disk_files is not None else self._load_on_disk_files()
return f"{model_key}_driving_tinygrad.pkl" in files
def _required_files_for_version(self, key: str, version: str) -> list[str]:
del version
return [f"{key}_driving_tinygrad.pkl"]
def _ensure_default_model_visible(self):
default_key = self._default_model_key()
default_name = self._default_model_name()
default_series = tr("Custom Series")
default_released = ""
for alias in model_key_aliases(default_key):
alias = canonical_model_key(alias)
if alias not in self._model_file_to_name:
continue
default_name = self._model_file_to_name.get(alias, default_name)
default_series = self._model_series_map.get(alias, default_series)
default_released = self._model_released_dates.get(alias, default_released)
if alias != default_key:
self._model_file_to_name.pop(alias, None)
self._model_file_to_name_processed.pop(alias, None)
self._model_series_map.pop(alias, None)
self._model_released_dates.pop(alias, None)
self._model_version_map.pop(alias, None)
self._catalog_entries.pop(alias, None)
version = self._model_version_map.get(default_key, self._default_model_version())
self._model_file_to_name[default_key] = default_name
self._model_file_to_name_processed[default_key] = _clean_model_name(default_name)
self._model_series_map[default_key] = default_series
if default_released:
self._model_released_dates[default_key] = default_released
self._model_version_map.setdefault(default_key, version)
def _build_catalog_entries(self, on_disk_files: set[str]):
self._catalog_entries.clear()
self._model_file_to_name.clear()
self._model_file_to_name_processed.clear()
self._model_series_map.clear()
self._model_released_dates.clear()
self._model_version_map.clear()
available_models = [entry.strip() for entry in (self._params.get("AvailableModels", encoding="utf-8") or "").split(",")]
available_names = [entry.strip() for entry in (self._params.get("AvailableModelNames", encoding="utf-8") or "").split(",")]
available_series = [entry.strip() for entry in (self._params.get("AvailableModelSeries", encoding="utf-8") or "").split(",")]
available_versions = [entry.strip() for entry in (self._params.get("ModelVersions", encoding="utf-8") or "").split(",")]
released_dates = [entry.strip() for entry in (self._params.get("ModelReleasedDates", encoding="utf-8") or "").split(",")]
self._community_favorites = {
canonical_model_key(entry.strip()) for entry in (self._params.get("CommunityFavorites", encoding="utf-8") or "").split(",") if entry.strip()
}
self._user_favorites = {
canonical_model_key(entry.strip()) for entry in (self._params.get("UserFavorites", encoding="utf-8") or "").split(",") if entry.strip()
}
size = min(len(available_models), len(available_names))
for i in range(size):
canonical_key = canonical_model_key(available_models[i])
name = available_names[i].strip()
if not canonical_key or not name:
continue
series = available_series[i].strip() if i < len(available_series) and available_series[i].strip() else tr("Custom Series")
version = available_versions[i].strip() if i < len(available_versions) else ""
released = released_dates[i].strip() if i < len(released_dates) else ""
self._model_file_to_name.setdefault(canonical_key, name)
self._model_file_to_name_processed.setdefault(canonical_key, _clean_model_name(name))
self._model_series_map.setdefault(canonical_key, series)
if released:
self._model_released_dates.setdefault(canonical_key, released)
if version:
self._model_version_map.setdefault(canonical_key, version)
self._ensure_default_model_visible()
for key, name in self._model_file_to_name.items():
version = self._model_version_map.get(key, self._default_model_version() if is_builtin_model_key(key) else "")
installed = self._is_model_installed(key, version, on_disk_files)
partial = (not is_builtin_model_key(key)) and (not installed) and any(file.startswith(f"{key}.") or file.startswith(f"{key}_") for file in on_disk_files)
self._catalog_entries[key] = ModelCatalogEntry(
key=key,
name=name,
series=self._model_series_map.get(key, tr("Custom Series")),
version=version,
released=self._model_released_dates.get(key, ""),
builtin=is_builtin_model_key(key),
installed=installed,
partial=partial,
community_favorite=(key in self._community_favorites),
user_favorite=(key in self._user_favorites),
)
def _update_model_metadata(self):
on_disk_files = self._load_on_disk_files()
self._build_catalog_entries(on_disk_files)
self._current_model_key = self._current_selected_key()
current_entry = self._catalog_entries.get(self._current_model_key)
if current_entry is None or not current_entry.installed:
self._current_model_key = self._default_model_key()
current_entry = self._catalog_entries.get(self._current_model_key)
if current_entry is not None:
self._current_model_name = current_entry.name
else:
self._current_model_name = self._default_model_name()
def _show_selection_dialog(self, title: str, options: dict[str, str] | list[str], current_val: str, on_confirm: Callable, current_key: str = ""):
if not options:
gui_app.push_widget(alert_dialog(tr("No options available.")))
return
option_labels = list(options.values()) if isinstance(options, dict) else list(options)
def _on_close(result):
if result == DialogResult.CONFIRM and dialog.selection:
if isinstance(options, dict):
reverse_map = {value: key for key, value in options.items()}
on_confirm(reverse_map.get(dialog.selection, dialog.selection))
else:
on_confirm(dialog.selection)
dialog = MultiOptionDialog(title, option_labels, current_val, callback=_on_close)
gui_app.push_widget(dialog)
def _is_download_active(self) -> bool:
return bool(self._params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or self._params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM))
def _selected_model_version(self, model_key: str) -> str:
version = self._model_version_map.get(model_key, "")
if version:
return version
try:
versions_file = self._model_dir / ".model_versions.json"
if versions_file.is_file():
payload = json.loads(versions_file.read_text())
if isinstance(payload, dict):
for alias in model_key_aliases(model_key):
resolved = str(payload.get(alias, "")).strip()
if resolved:
return resolved
except Exception:
pass
if is_builtin_model_key(model_key):
return self._default_model_version()
return ""
def installed_entries(self) -> list[ModelCatalogEntry]:
entries = [entry for entry in self._catalog_entries.values() if entry.installed]
sort_mode = self._get_sort_mode()
if sort_mode == "date":
return sorted(entries, key=lambda e: (e.released or "0000-00-00", self._model_file_to_name_processed.get(e.key, e.name).lower(), e.key), reverse=True)
if sort_mode == "date_oldest":
return sorted(entries, key=lambda e: (e.released or "0000-00-00", self._model_file_to_name_processed.get(e.key, e.name).lower(), e.key))
return sorted(entries, key=lambda e: (0 if e.builtin else 1, self._model_file_to_name_processed.get(e.key, e.name).lower(), e.key))
def available_entries(self) -> list[ModelCatalogEntry]:
entries = [entry for entry in self._catalog_entries.values() if not entry.installed]
sort_mode = self._get_sort_mode()
if sort_mode == "date":
return sorted(entries, key=lambda e: (e.released or "0000-00-00", self._model_file_to_name_processed.get(e.key, e.name).lower(), e.key), reverse=True)
if sort_mode == "date_oldest":
return sorted(entries, key=lambda e: (e.released or "0000-00-00", self._model_file_to_name_processed.get(e.key, e.name).lower(), e.key))
return sorted(entries, key=lambda e: (self._model_file_to_name_processed.get(e.key, e.name).lower(), e.key))
def community_picks_entries(self) -> list[ModelCatalogEntry]:
entries = [entry for entry in self._catalog_entries.values() if entry.community_favorite]
if self._params.get_bool("ModelRandomizer"):
entries = [e for e in entries if e.installed]
return sorted(entries, key=lambda e: (
self._model_file_to_name_processed.get(e.key, e.name).lower(),
e.key
))
def favorites_entries(self) -> list[ModelCatalogEntry]:
entries = [entry for entry in self._catalog_entries.values() if entry.user_favorite]
if self._params.get_bool("ModelRandomizer"):
entries = [e for e in entries if e.installed]
return sorted(entries, key=lambda e: (
self._model_file_to_name_processed.get(e.key, e.name).lower(),
e.key
))
def is_current_model(self, model_key: str) -> bool:
return canonical_model_key(model_key) == self._current_model_key
def current_entry(self) -> ModelCatalogEntry | None:
return self._catalog_entries.get(self._current_model_key)
def _get_sort_mode(self) -> str:
mode = (self._params.get("ModelSortMode", encoding="utf-8") or "").strip()
return mode if mode in _SORT_MODES else _SORT_MODES[0]
def is_model_removable(self, model_key: str) -> bool:
key = canonical_model_key(model_key)
if not key:
return False
if is_builtin_model_key(key):
return False
if key == self._default_model_key() or key == self._current_model_key:
return False
return self._catalog_entries.get(key, ModelCatalogEntry(key, "", "", "", "", False, False, False, False, False)).installed
def is_entry_actively_downloading(self, model_key: str, active_key: str | None) -> bool:
if not self._is_download_active():
return False
return canonical_model_key(model_key) == canonical_model_key(active_key or "")
def download_progress_text(self) -> str:
progress = self._params_memory.get(DOWNLOAD_PROGRESS_PARAM, encoding="utf-8") or ""
if progress:
return progress
if self._transient_status_text and time.monotonic() < self._transient_status_until:
return self._transient_status_text
return ""
def _model_key_for_progress(self, progress_text: str) -> str | None:
if not progress_text:
return None
lower_progress = progress_text.lower()
for key, entry in self._catalog_entries.items():
if f'"{entry.name}"'.lower() in lower_progress:
return key
clean_name = _clean_model_name(entry.name).lower()
if clean_name and clean_name in lower_progress:
return key
return None
def primary_header_button_state(self) -> tuple[str, bool]:
if self._is_download_active():
return tr("Cancel Download"), True
missing_count = len(self.available_entries())
if missing_count == 0:
return tr("All Models On Device"), False
if ui_state.started:
return tr("Downloads Pause Onroad"), False
return tr(f"Download All Missing ({missing_count})"), True
def secondary_header_button_state(self) -> tuple[str, bool]:
if self._manifest_fetch_thread is not None and self._manifest_fetch_thread.is_alive():
return tr("Refreshing..."), False
if ui_state.started or self._is_download_active():
return tr("Refresh Catalog"), False
return tr("Refresh Catalog"), True
def header_description_text(self) -> str:
if self._is_download_active():
return self.download_progress_text() or tr("Downloading model files...")
if self._manifest_fetch_thread is not None and self._manifest_fetch_thread.is_alive():
return tr("Refreshing the driving model catalog in the background.")
if ui_state.started:
return tr("Downloads and removals pause while driving.")
return tr("Tap a downloaded model to set it as active.")
def empty_state_title(self) -> str:
if self._params.get_bool("ModelRandomizer"):
return tr("Model Randomizer Active")
if self._manifest_fetch_thread is not None and self._manifest_fetch_thread.is_alive():
return tr("Refreshing model catalog")
return tr("No models available")
def empty_state_body(self) -> str:
if self._params.get_bool("ModelRandomizer"):
return tr("Models are selected automatically each drive. Disable Randomizer to choose manually.")
if self._manifest_fetch_thread is not None and self._manifest_fetch_thread.is_alive():
return tr("StarPilot is pulling the latest driving model list. This panel will populate automatically when the refresh completes.")
return tr("Try refreshing the catalog once the device is offroad and connected.")
def utility_rows(self) -> list[dict]:
rows = []
if self._params.get_bool("ModelRandomizer"):
blacklist_count = len([m.strip() for m in (self._params.get("BlacklistedModels", encoding="utf-8") or "").split(",") if m.strip()])
rows.extend(
[
{
"id": "blacklist",
"title": tr("Blacklist"),
"subtitle": tr("Keep specific installed models out of the rotation."),
"type": "value",
"value": tr(f"{blacklist_count} blocked" if blacklist_count else "Manage"),
},
{
"id": "ratings",
"title": tr("Ratings"),
"subtitle": tr("Review recorded drives and model score history."),
"type": "value",
"value": tr("View"),
},
]
)
return rows
def select_model(self, model_key: str):
selected_model = canonical_model_key(model_key)
entry = self._catalog_entries.get(selected_model)
if entry is None or not entry.installed:
gui_app.push_widget(alert_dialog(tr("Model is not available on this device.")))
return False
if self._params.get_bool("ModelRandomizer"):
gui_app.push_widget(alert_dialog(tr("Turn off Model Randomizer to choose a model manually.")))
return False
if selected_model == self._current_model_key:
return True
self._params.put("Model", selected_model)
self._params.put("DrivingModel", selected_model)
self._params.put("DrivingModelName", entry.name)
resolved_version = self._selected_model_version(selected_model)
resolved_version = resolved_version or entry.version or self._default_model_version()
self._params.put("ModelVersion", resolved_version)
self._params.put("DrivingModelVersion", resolved_version)
update_starpilot_toggles()
self._update_model_metadata()
if ui_state.started:
self._params.put_bool("OnroadCycleRequested", True)
gui_app.push_widget(alert_dialog(tr("Drive-cycle requested for immediate apply.")))
return True
def start_download(self, model_key: str):
self._update_model_metadata()
if ui_state.started:
gui_app.push_widget(alert_dialog(tr("Cannot download models while driving.")))
return False
if self._is_download_active():
gui_app.push_widget(alert_dialog(tr("A model download is already in progress.")))
return False
entry = self._catalog_entries.get(canonical_model_key(model_key))
if entry is None:
gui_app.push_widget(alert_dialog(tr("Unknown model.")))
return False
if entry.installed:
gui_app.push_widget(alert_dialog(tr("Model is already on this device.")))
return False
self._params_memory.remove(CANCEL_DOWNLOAD_PARAM)
self._params_memory.remove(MODEL_DOWNLOAD_ALL_PARAM)
self._params_memory.put(MODEL_DOWNLOAD_PARAM, entry.key)
self._params_memory.put(DOWNLOAD_PROGRESS_PARAM, f'Downloading "{entry.name}"...')
return True
def download_all_missing(self):
self._update_model_metadata()
if ui_state.started:
gui_app.push_widget(alert_dialog(tr("Cannot download models while driving.")))
return False
if self._is_download_active():
gui_app.push_widget(alert_dialog(tr("A model download is already in progress.")))
return False
if not self.available_entries():
return False
self._params_memory.remove(CANCEL_DOWNLOAD_PARAM)
self._params_memory.remove(MODEL_DOWNLOAD_PARAM)
self._params_memory.put_bool(MODEL_DOWNLOAD_ALL_PARAM, True)
self._params_memory.put(DOWNLOAD_PROGRESS_PARAM, "Downloading...")
return True
def cancel_active_download(self):
if self._is_download_active():
self._params_memory.put_bool(CANCEL_DOWNLOAD_PARAM, True)
def refresh_manifest(self):
if ui_state.started:
gui_app.push_widget(alert_dialog(tr("Cannot refresh the model catalog while driving.")))
return False
if self._is_download_active():
gui_app.push_widget(alert_dialog(tr("Cannot refresh the model catalog during an active download.")))
return False
self._fetch_manifest_async()
return True
def delete_model(self, model_key: str):
self._update_model_metadata()
key = canonical_model_key(model_key)
entry = self._catalog_entries.get(key)
if entry is None:
gui_app.push_widget(alert_dialog(tr("Unknown model.")))
return False
if ui_state.started:
gui_app.push_widget(alert_dialog(tr("Cannot delete model files while driving.")))
return False
if self._is_download_active():
gui_app.push_widget(alert_dialog(tr("Cannot delete model files while a download is in progress.")))
return False
if not self.is_model_removable(key):
gui_app.push_widget(alert_dialog(tr("This model is protected and cannot be removed.")))
return False
for file in self._model_dir.iterdir():
if not (file.name == f"{key}.thneed" or file.name == f"{key}.pkl" or file.name.startswith(f"{key}_")):
continue
if file.is_dir():
shutil.rmtree(file, ignore_errors=True)
elif file.is_file():
file.unlink(missing_ok=True)
self._update_model_metadata()
return True
def toggle_favorite(self, model_key: str):
key = canonical_model_key(model_key)
current_favorites = {
canonical_model_key(m.strip())
for m in (self._params.get("UserFavorites", encoding="utf-8") or "").split(",")
if m.strip()
}
if key in current_favorites:
current_favorites.discard(key)
else:
current_favorites.add(key)
self._params.put("UserFavorites", ",".join(sorted(current_favorites)))
self._update_model_metadata()
def _on_blacklist_clicked(self):
blacklisted = [m.strip() for m in (self._params.get("BlacklistedModels", encoding="utf-8") or "").split(",") if m.strip()]
def _on_close(result):
if result != DialogResult.CONFIRM or not dialog.selection:
return
if dialog.selection == tr("Add"):
blacklistable = {k: v for k, v in self._model_file_to_name.items() if k not in blacklisted}
self._show_selection_dialog(tr("Add to Blacklist"), blacklistable, "", lambda k: self._params.put("BlacklistedModels", ",".join(blacklisted + [k])))
elif dialog.selection == tr("Remove"):
options = {k: self._model_file_to_name.get(k, k) for k in blacklisted}
def _remove(k):
blacklisted.remove(k)
self._params.put("BlacklistedModels", ",".join(blacklisted))
self._show_selection_dialog(tr("Remove from Blacklist"), options, "", _remove)
elif dialog.selection == tr("Reset All"):
self._params.remove("BlacklistedModels")
dialog = MultiOptionDialog(tr("Manage Blacklist"), [tr("Add"), tr("Remove"), tr("Reset All")], callback=_on_close)
gui_app.push_widget(dialog)
def _on_scores_clicked(self):
scores_raw = self._params.get("ModelDrivesAndScores", encoding="utf-8") or ""
if not scores_raw:
gui_app.push_widget(alert_dialog(tr("No model ratings found.")))
return
try:
scores = json.loads(scores_raw)
lines = [f"{key}: {value.get('Score', 0)}% ({value.get('Drives', 0)} drives)" for key, value in scores.items()]
gui_app.push_widget(ConfirmDialog("\n".join(lines), tr("Close"), rich=True))
except Exception:
gui_app.push_widget(alert_dialog(tr("Unable to read model ratings.")))
def _on_model_randomizer_toggled(self, state: bool):
self._params.put_bool("ModelRandomizer", state)
update_starpilot_toggles()
self._update_model_metadata()
def toggle_model_randomizer(self):
currently = self._params.get_bool("ModelRandomizer")
if not currently:
def on_confirm(result):
if result == DialogResult.CONFIRM:
self._on_model_randomizer_toggled(True)
gui_app.push_widget(ConfirmDialog(
tr("Model Randomizer will change your driving model each drive."),
tr("Enable"),
tr("Cancel"),
callback=on_confirm,
))
else:
self._on_model_randomizer_toggled(False)
def random_model_button_label(self) -> str:
return tr("Model Randomizer")
def _update_state(self):
if self._transient_status_text and time.monotonic() >= self._transient_status_until:
self._transient_status_text = ""
if self._manifest_fetched:
self._manifest_fetched = False
self._update_model_metadata()
model_to_download = self._params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or ""
download_all = self._params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM)
is_downloading = bool(model_to_download or download_all)
if is_downloading and (self._download_thread is None or not self._download_thread.is_alive()):
def _download_task():
try:
if download_all:
self._model_manager.download_all_models()
else:
self._model_manager.download_model(model_to_download)
except Exception:
pass
finally:
final_status = self._params_memory.get(DOWNLOAD_PROGRESS_PARAM, encoding="utf-8") or ""
self._params_memory.remove(CANCEL_DOWNLOAD_PARAM)
self._params_memory.remove(MODEL_DOWNLOAD_PARAM)
self._params_memory.put_bool(MODEL_DOWNLOAD_ALL_PARAM, False)
self._params_memory.remove(DOWNLOAD_PROGRESS_PARAM)
if final_status:
self._transient_status_text = final_status
self._transient_status_until = time.monotonic() + 2.5
self._download_thread = None
self._update_model_metadata()
self._download_thread = threading.Thread(target=_download_task, daemon=True)
self._download_thread.start()