diff --git a/selfdrive/ui/layouts/settings/software.py b/selfdrive/ui/layouts/settings/software.py index 39efbe4a4..7810615ab 100644 --- a/selfdrive/ui/layouts/settings/software.py +++ b/selfdrive/ui/layouts/settings/software.py @@ -1,10 +1,14 @@ import os +import subprocess +import threading import time import datetime from pathlib import Path from openpilot.common.time_helpers import system_time_valid +from openpilot.common.swaglog import cloudlog from openpilot.selfdrive.ui.lib.starpilot_version import starpilot_display_description 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.lib.multilang import tr, trn from openpilot.system.ui.widgets import Widget, DialogResult @@ -23,6 +27,16 @@ STATE_TO_DISPLAY_TEXT = { "finalizing update...": tr("finalizing update..."), } +# ── Git environment hardening (prevent prompts, hangs) ─ +_GIT_ENV = os.environ.copy() | { + "GIT_TERMINAL_PROMPT": "0", + "GIT_ASKPASS": "/bin/false", + "SSH_ASKPASS": "/bin/false", + "GCM_INTERACTIVE": "Never", +} + +_fast_update_lock = threading.Lock() + def time_ago(date: datetime.datetime | None) -> str: if not date: @@ -62,7 +76,11 @@ class SoftwareLayout(Widget): initial_state=ui_state.params.get_bool("AutomaticUpdates"), callback=self._on_auto_updates_toggle, ) - self._download_btn = button_item(lambda: tr("Download"), lambda: tr("CHECK"), callback=self._on_download_update) + self._download_btn = button_item( + lambda: tr("Download"), lambda: tr("CHECK"), + callback=self._on_download_update, + long_press_callback=self._on_long_press_fast_update, + ) # Install button is initially hidden self._install_btn = button_item(lambda: tr("Install Update"), lambda: tr("INSTALL"), callback=self._on_install_update) @@ -71,6 +89,9 @@ class SoftwareLayout(Widget): # Track waiting-for-updater transition to avoid brief re-enable while still idle self._waiting_for_updater = False self._waiting_start_ts: float = 0.0 + self._fast_update_active = False + self._fast_update_status: str = "" + self._fast_update_error: str = "" # Branch switcher self._branch_btn = button_item(lambda: tr("Target Branch"), lambda: tr("SELECT"), callback=self._on_select_branch) @@ -113,73 +134,172 @@ class SoftwareLayout(Widget): # Update download button visibility and state self._download_btn.set_visible(ui_state.is_offroad()) - updater_state = ui_state.params.get("UpdaterState") or "idle" - failed_count = ui_state.params.get("UpdateFailedCount") or 0 - fetch_available = ui_state.params.get_bool("UpdaterFetchAvailable") - update_available = ui_state.params.get_bool("UpdateAvailable") - - if updater_state != "idle": - # Updater responded - self._waiting_for_updater = False - self._download_btn.action_item.set_enabled(False) - # Use the mapping, with a fallback to the original state string - display_text = STATE_TO_DISPLAY_TEXT.get(updater_state, updater_state) - self._download_btn.action_item.set_value(display_text) - else: - if failed_count > 0: - self._download_btn.action_item.set_value(tr("failed to check for update")) - self._download_btn.action_item.set_text(tr("CHECK")) - elif fetch_available: - self._download_btn.action_item.set_value(tr("update available")) - self._download_btn.action_item.set_text(tr("DOWNLOAD")) + # ── Fast update progress (in-memory status from worker thread) ─ + if self._fast_update_active: + if self._fast_update_status == "rebooting": + self._download_btn.action_item.set_enabled(False) + self._download_btn.action_item.set_text(tr("REBOOTING")) + self._download_btn.action_item.set_value(tr("Update applied, rebooting...")) + elif self._fast_update_error: + self._download_btn.action_item.set_enabled(True) + self._download_btn.action_item.set_text(tr("RETRY")) + self._download_btn.action_item.set_value(tr("Failed: {}").format(self._fast_update_error)) else: - last_update = ui_state.params.get("LastUpdateTime") - if last_update: - formatted = time_ago(last_update) - self._download_btn.action_item.set_value(tr("up to date, last checked {}").format(formatted)) - else: - self._download_btn.action_item.set_value(tr("up to date, last checked never")) - self._download_btn.action_item.set_text(tr("CHECK")) + self._download_btn.action_item.set_enabled(False) + self._download_btn.action_item.set_text(tr("FAST UPDATE")) + self._download_btn.action_item.set_value(self._fast_update_status or tr("Starting...")) - # If we've been waiting too long without a state change, reset state - if self._waiting_for_updater and (time.monotonic() - self._waiting_start_ts > UPDATED_TIMEOUT): + # ── Normal updater state (only when fast update NOT active) ─── + if not self._fast_update_active: + updater_state = ui_state.params.get("UpdaterState") or "idle" + failed_count = ui_state.params.get("UpdateFailedCount") or 0 + fetch_available = ui_state.params.get_bool("UpdaterFetchAvailable") + update_available = ui_state.params.get_bool("UpdateAvailable") + + if updater_state != "idle": self._waiting_for_updater = False + self._download_btn.action_item.set_enabled(False) + display_text = STATE_TO_DISPLAY_TEXT.get(updater_state, updater_state) + self._download_btn.action_item.set_value(display_text) + else: + if failed_count > 0: + self._download_btn.action_item.set_value(tr("failed to check for update")) + self._download_btn.action_item.set_text(tr("CHECK")) + elif fetch_available: + self._download_btn.action_item.set_value(tr("update available")) + self._download_btn.action_item.set_text(tr("DOWNLOAD")) + else: + last_update = ui_state.params.get("LastUpdateTime") + if last_update: + formatted = time_ago(last_update) + self._download_btn.action_item.set_value(tr("up to date, last checked {}").format(formatted)) + else: + self._download_btn.action_item.set_value(tr("up to date, last checked never")) + self._download_btn.action_item.set_text(tr("CHECK")) - # Only enable if we're not waiting for updater to flip out of idle - self._download_btn.action_item.set_enabled(not self._waiting_for_updater) + if self._waiting_for_updater and (time.monotonic() - self._waiting_start_ts > UPDATED_TIMEOUT): + self._waiting_for_updater = False + + self._download_btn.action_item.set_enabled(not self._waiting_for_updater) + else: + update_available = False # Update target branch button value current_branch = ui_state.params.get("UpdaterTargetBranch") or "" self._branch_btn.action_item.set_value(current_branch) # Update install button - self._install_btn.set_visible(ui_state.is_offroad() and update_available) - if update_available: + self._install_btn.set_visible(ui_state.is_offroad() and update_available and not self._fast_update_active) + if update_available and not self._fast_update_active: new_desc = starpilot_display_description(ui_state.params.get("UpdaterNewDescription")) new_release_notes = (ui_state.params.get("UpdaterNewReleaseNotes") or b"").decode("utf-8", "replace") self._install_btn.action_item.set_text(tr("INSTALL")) self._install_btn.action_item.set_value(new_desc) self._install_btn.set_description(new_release_notes) - # Enable install button for testing (like Qt showEvent) self._install_btn.action_item.set_enabled(True) else: self._install_btn.set_visible(False) def _on_download_update(self): - # Check if we should start checking or start downloading + if self._fast_update_active: + if self._fast_update_error: + self._fast_update_active = False + self._fast_update_error = "" + self._fast_update_status = "" + return self._download_btn.action_item.set_enabled(False) if self._download_btn.action_item.text == tr("CHECK"): - # Start checking for updates self._waiting_for_updater = True self._waiting_start_ts = time.monotonic() os.system("pkill -SIGUSR1 -f system.updated.updated") else: - # Start downloading self._waiting_for_updater = True self._waiting_start_ts = time.monotonic() ui_state.params_memory.put_bool("ManualUpdateInitiated", True) os.system("pkill -SIGHUP -f system.updated.updated") + def _on_long_press_fast_update(self): + if self._fast_update_active: + return + def on_confirm(result): + if result == DialogResult.CONFIRM: + self._execute_fast_update() + gui_app.push_widget(ConfirmDialog( + tr("Fast update will replace the current installation without backup and reboot the device. Continue?"), + tr("Fast Update"), callback=on_confirm, + )) + + def _execute_fast_update(self): + if ui_state.is_onroad(): + self._download_btn.action_item.set_value(tr("Cannot update while driving")) + return + if not _fast_update_lock.acquire(blocking=False): + return + self._fast_update_active = True + self._fast_update_status = tr("Starting fast update...") + self._fast_update_error = "" + self._download_btn.action_item.set_enabled(False) + self._download_btn.action_item.set_text(tr("FAST UPDATE")) + self._download_btn.action_item.set_value(self._fast_update_status) + os.system("pkill -f system.updated.updated") + + def _run_worker(): + repo_path = str(Path(__file__).resolve().parents[4]) + try: + self._fast_update_status = tr("Preparing...") + result = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=repo_path, + capture_output=True, text=True, timeout=15, env=_GIT_ENV) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or "failed to resolve HEAD branch") + branch = result.stdout.strip() + result = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=repo_path, + capture_output=True, text=True, timeout=15, env=_GIT_ENV) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or "failed to resolve HEAD commit") + current_commit = result.stdout.strip() + + subprocess.run(["git", "update-ref", "refs/starpilot/rollback", current_commit], + cwd=repo_path, capture_output=True, timeout=15, env=_GIT_ENV) + subprocess.run(["git", "config", "--local", "--replace-all", "starpilot.rollbackbranch", branch], + cwd=repo_path, capture_output=True, timeout=15, env=_GIT_ENV) + + self._fast_update_status = tr("Fetching latest commit...") + result = subprocess.run( + ["git", "-c", "gc.auto=0", "-c", "maintenance.auto=false", "fetch", + "--progress", "--depth=1", "--no-recurse-submodules", "origin", branch], + cwd=repo_path, capture_output=True, text=True, timeout=120, env=_GIT_ENV) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or "fetch failed") + + self._fast_update_status = tr("Applying update...") + result = subprocess.run(["git", "reset", "--hard", "FETCH_HEAD"], + cwd=repo_path, capture_output=True, text=True, timeout=120, env=_GIT_ENV) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or "reset failed") + + gitmodules = Path(repo_path) / ".gitmodules" + if gitmodules.is_file(): + self._fast_update_status = tr("Updating submodules...") + result = subprocess.run( + ["git", "submodule", "update", "--init", "--recursive", "--depth=1", "--progress"], + cwd=repo_path, capture_output=True, text=True, timeout=300, env=_GIT_ENV) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or "submodule update failed") + + self._fast_update_status = "rebooting" + time.sleep(6) + HARDWARE.reboot() + + except Exception as e: + self._fast_update_error = str(e)[:200] + cloudlog.exception("Fast update failed") + finally: + _fast_update_lock.release() + + threading.Thread(target=_run_worker, daemon=True).start() + def _on_auto_updates_toggle(self, enabled: bool): ui_state.params.put_bool("AutomaticUpdates", enabled) diff --git a/system/ui/widgets/button.py b/system/ui/widgets/button.py index 36ef3beda..a4e37c7bf 100644 --- a/system/ui/widgets/button.py +++ b/system/ui/widgets/button.py @@ -91,6 +91,8 @@ class Button(Widget): icon=None, elide_right: bool = False, multi_touch: bool = False, + long_press_callback: Callable[[], None] | None = None, + long_press_threshold: float = 0.8, ): super().__init__() @@ -103,6 +105,9 @@ class Button(Widget): self._click_callback = click_callback self._multi_touch = multi_touch + self._long_press_callback = long_press_callback + self._long_press_threshold = long_press_threshold + self._press_start_time: float | None = None def set_text(self, text): self._label.set_text(text) @@ -112,11 +117,35 @@ class Button(Widget): self._background_color = BUTTON_BACKGROUND_COLORS[self._button_style] self._label.set_text_color(BUTTON_TEXT_COLOR[self._button_style]) + def _handle_mouse_press(self, mouse_pos: MousePos): + if self._long_press_callback: + self._press_start_time = rl.get_time() + + def _handle_mouse_release(self, mouse_pos: MousePos): + is_long_press = False + if self._press_start_time is not None and self._long_press_callback: + if rl.get_time() - self._press_start_time >= self._long_press_threshold: + is_long_press = True + self._long_press_callback() + self._press_start_time = None + if not is_long_press: + super()._handle_mouse_release(mouse_pos) + def _update_state(self): if self.enabled: self._label.set_text_color(BUTTON_TEXT_COLOR[self._button_style]) if self.is_pressed: - self._background_color = BUTTON_PRESSED_BACKGROUND_COLORS[self._button_style] + if self._long_press_callback and self._press_start_time is not None: + t = min(1.0, (rl.get_time() - self._press_start_time) / self._long_press_threshold) + base = BUTTON_PRESSED_BACKGROUND_COLORS[self._button_style] + dr, dg, db = 226, 44, 44 + self._background_color = rl.Color( + int(base.r + (dr - base.r) * t), + int(base.g + (dg - base.g) * t), + int(base.b + (db - base.b) * t), + int(base.a + (255 - base.a) * t)) + else: + self._background_color = BUTTON_PRESSED_BACKGROUND_COLORS[self._button_style] else: self._background_color = BUTTON_BACKGROUND_COLORS[self._button_style] elif self._button_style != ButtonStyle.NO_EFFECT: diff --git a/system/ui/widgets/list_view.py b/system/ui/widgets/list_view.py index 82613c37c..a209829cf 100644 --- a/system/ui/widgets/list_view.py +++ b/system/ui/widgets/list_view.py @@ -79,7 +79,9 @@ class ToggleAction(ItemAction): class ButtonAction(ItemAction): - def __init__(self, text: str | Callable[[], str], width: int = BUTTON_WIDTH, enabled: bool | Callable[[], bool] = True): + def __init__(self, text: str | Callable[[], str], width: int = BUTTON_WIDTH, enabled: bool | Callable[[], bool] = True, + long_press_callback: Callable[[], None] | None = None, + long_press_threshold: float = 0.8): super().__init__(width, enabled) self._text_source = text self._value_source: str | Callable[[], str] | None = None @@ -97,6 +99,8 @@ class ButtonAction(ItemAction): border_radius=BUTTON_BORDER_RADIUS, click_callback=pressed, text_padding=0, + long_press_callback=long_press_callback, + long_press_threshold=long_press_threshold, ) self.set_enabled(enabled) @@ -444,8 +448,12 @@ def toggle_item(title: str | Callable[[], str], description: str | Callable[[], def button_item(title: str | Callable[[], str], button_text: str | Callable[[], str], description: str | Callable[[], str] | None = None, - callback: Callable | None = None, enabled: bool | Callable[[], bool] = True) -> ListItem: - action = ButtonAction(text=button_text, enabled=enabled) + callback: Callable | None = None, enabled: bool | Callable[[], bool] = True, + long_press_callback: Callable[[], None] | None = None, + long_press_threshold: float = 0.8) -> ListItem: + action = ButtonAction(text=button_text, enabled=enabled, + long_press_callback=long_press_callback, + long_press_threshold=long_press_threshold) return ListItem(title=title, description=description, action_item=action, callback=callback)