BigUI WIP: Fast Update Update...Update

This commit is contained in:
firestarsdog
2026-07-05 02:50:57 -04:00
parent 831a8f27e5
commit cbe6eb077e
2 changed files with 266 additions and 48 deletions
+74 -48
View File
@@ -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()
@@ -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