From cbe6eb077e3aa42b9f7c684a76eaeb2d500c6c26 Mon Sep 17 00:00:00 2001 From: firestarsdog <229254897+firestarsdog@users.noreply.github.com> Date: Sun, 5 Jul 2026 02:50:57 -0400 Subject: [PATCH] BigUI WIP: Fast Update Update...Update --- selfdrive/ui/layouts/settings/software.py | 122 ++++++----- .../tests/test_software_fast_update.py | 192 ++++++++++++++++++ 2 files changed, 266 insertions(+), 48 deletions(-) create mode 100644 selfdrive/ui/layouts/settings/tests/test_software_fast_update.py diff --git a/selfdrive/ui/layouts/settings/software.py b/selfdrive/ui/layouts/settings/software.py index 7810615ab..2e5d3e601 100644 --- a/selfdrive/ui/layouts/settings/software.py +++ b/selfdrive/ui/layouts/settings/software.py @@ -1,3 +1,4 @@ +import dataclasses import os import subprocess import threading @@ -35,7 +36,43 @@ _GIT_ENV = os.environ.copy() | { "GCM_INTERACTIVE": "Never", } +class FastUpdateStage: + IDLE = "idle" + PREPARING = "preparing" + FETCHING = "fetching" + APPLYING = "applying" + SUBMODULES = "submodules" + REBOOTING = "rebooting" + ERROR = "error" + + +@dataclasses.dataclass +class _FastUpdateState: + stage: str = FastUpdateStage.IDLE + status: str = "" + error: str = "" + + +_FAST_UPDATE_GIT_TIMEOUT_S = 15 +_FAST_UPDATE_FETCH_TIMEOUT_S = 120 +_FAST_UPDATE_RESET_TIMEOUT_S = 120 +_FAST_UPDATE_SUBMODULE_TIMEOUT_S = 300 +_FAST_UPDATE_REBOOT_NOTICE_S = 6.0 + _fast_update_lock = threading.Lock() +_fast_update_state = _FastUpdateState() +_fast_update_state_lock = threading.Lock() + + +def _set_fast_update_state(**kwargs): + with _fast_update_state_lock: + for k, v in kwargs.items(): + setattr(_fast_update_state, k, v) + + +def _get_fast_update_state() -> _FastUpdateState: + with _fast_update_state_lock: + return dataclasses.replace(_fast_update_state) def time_ago(date: datetime.datetime | None) -> str: @@ -89,9 +126,6 @@ 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) @@ -135,22 +169,22 @@ class SoftwareLayout(Widget): self._download_btn.set_visible(ui_state.is_offroad()) # ── 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: - 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...")) + state = _get_fast_update_state() + if state.stage == FastUpdateStage.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 state.stage == FastUpdateStage.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(state.error)) + elif state.stage != FastUpdateStage.IDLE: + 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(state.status or tr("Starting...")) # ── Normal updater state (only when fast update NOT active) ─── - if not self._fast_update_active: + if state.stage == FastUpdateStage.IDLE: 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") @@ -189,8 +223,8 @@ class SoftwareLayout(Widget): self._branch_btn.action_item.set_value(current_branch) # Update install button - 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: + self._install_btn.set_visible(ui_state.is_offroad() and update_available and state.stage == FastUpdateStage.IDLE) + if update_available and state.stage == FastUpdateStage.IDLE: 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")) @@ -201,11 +235,11 @@ class SoftwareLayout(Widget): self._install_btn.set_visible(False) def _on_download_update(self): - if self._fast_update_active: - if self._fast_update_error: - self._fast_update_active = False - self._fast_update_error = "" - self._fast_update_status = "" + state = _get_fast_update_state() + if state.stage == FastUpdateStage.ERROR: + _set_fast_update_state(stage=FastUpdateStage.IDLE, status="", error="") + return + if state.stage != FastUpdateStage.IDLE: return self._download_btn.action_item.set_enabled(False) if self._download_btn.action_item.text == tr("CHECK"): @@ -219,7 +253,7 @@ class SoftwareLayout(Widget): os.system("pkill -SIGHUP -f system.updated.updated") def _on_long_press_fast_update(self): - if self._fast_update_active: + if _get_fast_update_state().stage != FastUpdateStage.IDLE: return def on_confirm(result): if result == DialogResult.CONFIRM: @@ -235,65 +269,57 @@ class SoftwareLayout(Widget): 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 = "" + _set_fast_update_state(stage=FastUpdateStage.PREPARING, status=tr("Starting 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") + self._download_btn.action_item.set_value(tr("Starting fast update...")) + subprocess.run(["pkill", "-f", "system.updated.updated"], check=False) 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) + capture_output=True, text=True, timeout=_FAST_UPDATE_GIT_TIMEOUT_S, 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) + capture_output=True, text=True, timeout=_FAST_UPDATE_GIT_TIMEOUT_S, 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...") + _set_fast_update_state(stage=FastUpdateStage.FETCHING, 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) + cwd=repo_path, capture_output=True, text=True, timeout=_FAST_UPDATE_FETCH_TIMEOUT_S, env=_GIT_ENV) if result.returncode != 0: raise RuntimeError(result.stderr.strip() or "fetch failed") - self._fast_update_status = tr("Applying update...") + _set_fast_update_state(stage=FastUpdateStage.APPLYING, 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) + cwd=repo_path, capture_output=True, text=True, timeout=_FAST_UPDATE_RESET_TIMEOUT_S, 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...") + _set_fast_update_state(stage=FastUpdateStage.SUBMODULES, 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) + cwd=repo_path, capture_output=True, text=True, timeout=_FAST_UPDATE_SUBMODULE_TIMEOUT_S, env=_GIT_ENV) if result.returncode != 0: raise RuntimeError(result.stderr.strip() or "submodule update failed") - self._fast_update_status = "rebooting" - time.sleep(6) + _set_fast_update_state(stage=FastUpdateStage.REBOOTING, status=tr("Update applied, rebooting...")) + time.sleep(_FAST_UPDATE_REBOOT_NOTICE_S) HARDWARE.reboot() except Exception as e: - self._fast_update_error = str(e)[:200] + _set_fast_update_state(stage=FastUpdateStage.ERROR, error=str(e)[:1000], status="") cloudlog.exception("Fast update failed") finally: _fast_update_lock.release() diff --git a/selfdrive/ui/layouts/settings/tests/test_software_fast_update.py b/selfdrive/ui/layouts/settings/tests/test_software_fast_update.py new file mode 100644 index 000000000..9f6df30d9 --- /dev/null +++ b/selfdrive/ui/layouts/settings/tests/test_software_fast_update.py @@ -0,0 +1,192 @@ +import threading + +import pytest + +from openpilot.selfdrive.ui.layouts.settings import software + + +@pytest.fixture(autouse=True) +def fast_update_env(mocker): + import pyray as rl + from openpilot.system.ui.lib.application import gui_app as real_gui_app + mocker.patch.object(real_gui_app, "font", return_value=rl.Font()) + mocker.patch.object(real_gui_app, "texture", return_value=mocker.MagicMock()) + mocker.patch.object(real_gui_app, "push_widget") + + mocker.patch.object(software, "ui_state") + software.ui_state.is_onroad.return_value = False + software.ui_state.is_offroad.return_value = True + + mocker.patch.object(software, "HARDWARE") + mocker.patch.object(software, "time") + + software._set_fast_update_state(stage=software.FastUpdateStage.IDLE, status="", error="") + + +@pytest.fixture +def sync_thread(mocker): + """Make Thread run the target synchronously so tests are deterministic.""" + def _sync_thread(target, daemon=True): + target() + return mocker.MagicMock() + mocker.patch.object(threading, "Thread", _sync_thread) + + +def test_fast_update_stages(mocker, sync_thread): + """Fast update transitions through all stages on success (no submodules).""" + mocker.patch.object(software, "subprocess") + software.subprocess.run.side_effect = [ + mocker.MagicMock(returncode=0, stdout="", stderr=""), # pkill + mocker.MagicMock(returncode=0, stdout="main\n", stderr=""), # rev-parse branch + mocker.MagicMock(returncode=0, stdout="abc123\n", stderr=""), # rev-parse HEAD + mocker.MagicMock(returncode=0, stdout="", stderr=""), # fetch + mocker.MagicMock(returncode=0, stdout="", stderr=""), # reset --hard + ] + mocker.patch.object(software.Path, "is_file", return_value=False) + + layout = software.SoftwareLayout() + layout._execute_fast_update() + + state = software._get_fast_update_state() + assert state.stage == software.FastUpdateStage.REBOOTING + assert state.error == "" + assert state.status == "Update applied, rebooting..." + + +def test_fast_update_with_submodules(mocker, sync_thread): + """Fast update runs submodule step when .gitmodules exists.""" + mocker.patch.object(software, "subprocess") + software.subprocess.run.side_effect = [ + mocker.MagicMock(returncode=0, stdout="", stderr=""), # pkill + mocker.MagicMock(returncode=0, stdout="main\n", stderr=""), # rev-parse branch + mocker.MagicMock(returncode=0, stdout="abc123\n", stderr=""), # rev-parse HEAD + mocker.MagicMock(returncode=0, stdout="", stderr=""), # fetch + mocker.MagicMock(returncode=0, stdout="", stderr=""), # reset --hard + mocker.MagicMock(returncode=0, stdout="", stderr=""), # submodule update + ] + mocker.patch.object(software.Path, "is_file", return_value=True) + + layout = software.SoftwareLayout() + layout._execute_fast_update() + + state = software._get_fast_update_state() + assert state.stage == software.FastUpdateStage.REBOOTING + + +def test_fast_update_error_stores_full_message(mocker, sync_thread): + """Error message is not truncated to 200 chars.""" + long_error = "x" * 500 + mocker.patch.object(software, "subprocess") + software.subprocess.run.side_effect = [ + mocker.MagicMock(returncode=0, stdout="", stderr=""), # pkill + RuntimeError(long_error), # rev-parse fails + ] + + layout = software.SoftwareLayout() + layout._execute_fast_update() + + state = software._get_fast_update_state() + assert state.stage == software.FastUpdateStage.ERROR + assert len(state.error) == 500 + + +def test_fast_update_blocks_while_onroad(mocker): + """Fast update is rejected when car is onroad.""" + software.ui_state.is_onroad.return_value = True + + layout = software.SoftwareLayout() + layout._execute_fast_update() + + state = software._get_fast_update_state() + assert state.stage == software.FastUpdateStage.IDLE + + +def test_fast_update_error_clears_on_tap(mocker): + """Tapping download button in ERROR state clears back to IDLE.""" + software._set_fast_update_state(stage=software.FastUpdateStage.ERROR, error="some error", status="") + + layout = software.SoftwareLayout() + layout._on_download_update() + + state = software._get_fast_update_state() + assert state.stage == software.FastUpdateStage.IDLE + assert state.error == "" + assert state.status == "" + + +def test_fast_update_blocks_duplicate_long_press(mocker): + """Long-press while fast update is active does nothing.""" + from openpilot.system.ui.lib.application import gui_app as real_gui_app + software._set_fast_update_state(stage=software.FastUpdateStage.FETCHING, status="busy", error="") + + layout = software.SoftwareLayout() + layout._on_long_press_fast_update() + + # Confirm dialog should NOT have been pushed + assert real_gui_app.push_widget.call_count == 0 + + +def test_fast_update_duplicate_execution_blocked(mocker): + """A second _execute_fast_update while one is running is a no-op.""" + software._fast_update_lock.acquire(blocking=False) + software._set_fast_update_state(stage=software.FastUpdateStage.FETCHING, status="busy", error="") + + layout = software.SoftwareLayout() + layout._execute_fast_update() + + # State should be unchanged (not reset to PREPARING) + state = software._get_fast_update_state() + assert state.stage == software.FastUpdateStage.FETCHING + + software._fast_update_lock.release() + + +def test_fast_update_lock_released_on_error(mocker, sync_thread): + """Lock is released when the worker fails, allowing a retry.""" + mocker.patch.object(software, "subprocess") + software.subprocess.run.side_effect = [ + mocker.MagicMock(returncode=0, stdout="", stderr=""), # pkill + RuntimeError("boom"), # rev-parse fails + ] + + layout = software.SoftwareLayout() + layout._execute_fast_update() + + state = software._get_fast_update_state() + assert state.stage == software.FastUpdateStage.ERROR + + # Lock should be released + assert software._fast_update_lock.acquire(blocking=False) + software._fast_update_lock.release() + + +def test_fast_update_stages_progress_through_states(mocker, sync_thread): + """Verify each stage is set during execution by inspecting state after each call.""" + stages_seen = [] + + mocker.patch.object(software, "subprocess") + + # Slow down writes so we can observe intermediate state + original_set = software._set_fast_update_state + def tracking_set(**kwargs): + if "stage" in kwargs: + stages_seen.append(kwargs["stage"]) + return original_set(**kwargs) + mocker.patch.object(software, "_set_fast_update_state", tracking_set) + + software.subprocess.run.side_effect = [ + mocker.MagicMock(returncode=0, stdout="", stderr=""), # pkill + mocker.MagicMock(returncode=0, stdout="main\n", stderr=""), # rev-parse branch + mocker.MagicMock(returncode=0, stdout="abc123\n", stderr=""), # rev-parse HEAD + mocker.MagicMock(returncode=0, stdout="", stderr=""), # fetch + mocker.MagicMock(returncode=0, stdout="", stderr=""), # reset --hard + ] + + layout = software.SoftwareLayout() + layout._execute_fast_update() + + assert software.FastUpdateStage.PREPARING in stages_seen + assert software.FastUpdateStage.FETCHING in stages_seen + assert software.FastUpdateStage.APPLYING in stages_seen + assert software.FastUpdateStage.REBOOTING in stages_seen + assert software.FastUpdateStage.ERROR not in stages_seen