From e0ab1731be63421e454fd24599bc55a96eb5e60c Mon Sep 17 00:00:00 2001 From: Amy Jeanes Date: Mon, 14 Sep 2026 16:33:48 +0100 Subject: [PATCH] mici: add a refresh models button to the models panel (#2018) models: add a refresh models button to mici, gate and show progress on both panels Adds the refresh-models tile to the mici models panel (it was missing there), factoring the sync-key trigger + in-progress check into refresh_model_list() and refresh_in_progress() shared with the big UI. On both UIs the refresh button is now gated on offroad + not-downloading + not-refreshing (the manager runs offroad-only and its per-tick manifest fetch sits above a blocking download loop, so a refresh queued in either state would stick), and shows progress while the manager refetches: mici shows "fetching..." on the tile, the big UI flips its button from REFRESH to "FETCHING..." to match its FETCHING.../SELECT/CLEAR label style (as in the OSM panel), replacing the old fire-and-forget popup. Claude-Session: https://claude.ai/code/session_01EGMnVnSk5inGTrd7kuDVG9 Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: James Vecellio-Grant <159560811+Discountchubbs@users.noreply.github.com> (cherry picked from commit a5f44653d7f43ad57fef2f546f3916ec4cbf3c56) --- .../ui/sunnypilot/layouts/settings/models.py | 43 +++++++++++-------- .../ui/sunnypilot/mici/layouts/models.py | 41 ++++++++++++++++-- .../selfdrive/ui/sunnypilot/model_info.py | 35 +++++++++++++++ 3 files changed, 98 insertions(+), 21 deletions(-) diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py index 4c71c79cb..9c72dad0c 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py @@ -4,7 +4,6 @@ Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. This file is part of sunnypilot and is licensed under the MIT License. See the LICENSE.md file in the root directory for more details. """ -import os import re import time import pyray as rl @@ -13,7 +12,8 @@ from openpilot.cereal import custom from openpilot.sunnypilot.models.helpers import ACTIVE_BUNDLE_KEYS, get_selected_bundle, resolve_bundle_by_ref from openpilot.common.constants import CV from openpilot.selfdrive.ui.ui_state import device, ui_state -from openpilot.selfdrive.ui.sunnypilot.model_info import big_model_state, bundles_for_source, carrying_model, default_model_name, queued_name +from openpilot.selfdrive.ui.sunnypilot.model_info import (big_model_state, bundles_for_source, carrying_model, default_model_name, + model_cache_size_mb, queued_name, refresh_in_progress, refresh_model_list) from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.widgets import DialogResult, Widget @@ -21,7 +21,6 @@ from openpilot.system.ui.widgets.confirm_dialog import alert_dialog, ConfirmDial from openpilot.system.ui.widgets.scroller_tici import Scroller from openpilot.system.ui.widgets.toggle import ON_COLOR -from openpilot.sunnypilot.models.runners.constants import CUSTOM_MODEL_PATH from openpilot.system.ui.sunnypilot.lib.styles import style from openpilot.system.ui.sunnypilot.lib.utils import NoElideButtonAction, ScrollingButtonAction from openpilot.system.ui.sunnypilot.widgets.list_view import ListItemSP, toggle_item_sp, option_item_sp @@ -40,6 +39,9 @@ class ModelsLayout(Widget): self._selection_source = None self._downloading = False self._verifying = False + self._clearing = False + self._refreshing = False + self._refresh_start: float | None = None self._last_note = None self.last_cache_calc_time = 0 @@ -67,15 +69,14 @@ class ModelsLayout(Widget): self.download_item = download_status_item(lambda: tr("Download") if self._downloading else tr("Model Status")) - self.refresh_item = button_item(tr("Refresh Model List"), tr("REFRESH"), "", - lambda: (ui_state.params.put("ModelManager_LastSyncTime", 0), - ui_state.params.put("ModelManager_LastSyncTime_Chestnut", 0), - gui_app.push_widget(alert_dialog(tr("Fetching Latest Models"))))) + self.refresh_item = button_item(tr("Refresh Model List"), + lambda: tr("FETCHING...") if self._refreshing else tr("REFRESH"), "", + self._refresh_models) self.clear_cache_item = ListItemSP( title=tr("Clear Model Cache"), description="", - action_item=NoElideButtonAction(tr("CLEAR")), + action_item=NoElideButtonAction(lambda: tr("CLEARING...") if self._clearing else tr("CLEAR")), callback=self._clear_cache ) @@ -122,32 +123,31 @@ class ModelsLayout(Widget): @staticmethod def calculate_cache_size(): - cache_size = 0.0 - if os.path.exists(CUSTOM_MODEL_PATH): - for file in os.listdir(CUSTOM_MODEL_PATH): - try: - cache_size += os.path.getsize(os.path.join(CUSTOM_MODEL_PATH, file)) - except OSError: - continue - return cache_size / (1024**2) + return model_cache_size_mb() def _clear_cache(self): def _callback(response): if response == DialogResult.CONFIRM: ui_state.params.put_bool("ModelManager_ClearCache", True) - self.clear_cache_item.action_item.set_value(f"{self.calculate_cache_size():.2f} MB") dialog = ConfirmDialog(tr("This will delete ALL downloaded models from the cache except the currently active model. Are you sure?"), tr("Clear Cache"), callback=_callback) gui_app.push_widget(dialog) + def _refresh_models(self): + refresh_model_list() + self._refresh_start = time.monotonic() + def _handle_bundle_download_progress(self): self.cancel_download_item.set_visible(False) self._downloading = False self._verifying = False self.download_item.set_visible(True) - if (current_time := time.monotonic()) - self.last_cache_calc_time > 0.5: + self._clearing = ui_state.params.get_bool("ModelManager_ClearCache") + if self._clearing: + self.last_cache_calc_time = 0.0 # refresh the size as soon as clearing finishes + elif (current_time := time.monotonic()) - self.last_cache_calc_time > 0.5: self.last_cache_calc_time = current_time self.clear_cache_item.action_item.set_value(f"{self.calculate_cache_size():.2f} MB") @@ -351,6 +351,13 @@ class ModelsLayout(Widget): self.big_model_item.action_item.set_enabled(offroad) self.small_model_item.set_description("" if offroad else tr("Only available when vehicle is off, or always offroad mode is on")) + # manager is offroad-only, so an onroad clear would never be serviced + self.clear_cache_item.action_item.set_enabled(offroad and not self._downloading and not self._clearing) + + # manager is offroad-only, so a refresh queued onroad would never be serviced + self._refreshing = refresh_in_progress(self._refresh_start) + self.refresh_item.action_item.set_enabled(offroad and not self._downloading and not self._refreshing) + def _render(self, rect): self._scroller.render(rect) diff --git a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py index 4c769498f..404ff2e30 100644 --- a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py @@ -4,15 +4,18 @@ Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. This file is part of sunnypilot and is licensed under the MIT License. See the LICENSE.md file in the root directory for more details. """ +import time + import pyray as rl from openpilot.cereal import custom -from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog +from openpilot.selfdrive.ui.mici.widgets.dialog import BigConfirmationDialog, BigDialog from openpilot.sunnypilot.models.helpers import ACTIVE_BUNDLE_KEYS, get_selected_bundle from openpilot.selfdrive.ui.mici.widgets.button import BigButton from openpilot.selfdrive.ui.ui_state import ui_state, device from openpilot.selfdrive.ui.sunnypilot.model_info import (active_source, big_model_state, bundles_for_source, carrying_model, - default_model_name, model_info, queued_name) + default_model_name, model_cache_size_mb, model_info, queued_name, + refresh_in_progress, refresh_model_list) from openpilot.system.ui.lib.application import FontWeight, gui_app from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.widgets import Widget @@ -81,10 +84,18 @@ class ModelsLayoutMici(NavScroller): self.select_model_btn = BigButton(tr("select model")) self.select_model_btn.set_click_callback(self._show_folders) + self.refresh_btn = BigButton(tr("refresh models")) + self.refresh_btn.set_click_callback(self._refresh_models) + self._refresh_start: float | None = None + self.cancel_download_btn = BigButton(tr("cancel download")) self.cancel_download_btn.set_click_callback(lambda: ui_state.params.remove("ModelManager_DownloadRef")) - self.main_items = [self.current_model_info, self.select_model_btn, self.cancel_download_btn] + self.clear_cache_btn = BigButton(tr("clear cache"), value=f"{model_cache_size_mb():.1f} MB") + self.clear_cache_btn.set_click_callback(self._confirm_clear_cache) + self._cache_size_time = 0.0 + + self.main_items = [self.current_model_info, self.select_model_btn, self.cancel_download_btn, self.refresh_btn, self.clear_cache_btn] self._scroller.add_widgets(self.main_items) @property @@ -162,6 +173,15 @@ class ModelsLayoutMici(NavScroller): ui_state.params.remove(ACTIVE_BUNDLE_KEYS[source]) self._pop_to_main() + def _confirm_clear_cache(self): + icon = gui_app.texture("icons_mici/settings/network/new/trash.png", 54, 64) + gui_app.push_widget(BigConfirmationDialog(f"{tr('slide to')}\n{tr('clear cache')}", icon, + lambda: ui_state.params.put_bool("ModelManager_ClearCache", True), red=True)) + + def _refresh_models(self): + refresh_model_list() + self._refresh_start = time.monotonic() + def _select_folder(self, folder_name): source = self._selection_source if source is None: # folders are only reachable after picking a hardware @@ -205,6 +225,21 @@ class ModelsLayoutMici(NavScroller): device.set_override_interactive_timeout(None) self._was_downloading = is_downloading + # manager is offroad-only, so an onroad clear would never be serviced + clearing = ui_state.params.get_bool("ModelManager_ClearCache") + self.clear_cache_btn.set_enabled(ui_state.is_offroad() and not is_downloading and not clearing) + if clearing: + self.clear_cache_btn.set_value(tr("clearing...")) + self._cache_size_time = 0.0 # refresh the size as soon as clearing finishes + elif (now := time.monotonic()) - self._cache_size_time > 0.5: + self._cache_size_time = now + self.clear_cache_btn.set_value(f"{model_cache_size_mb():.1f} MB") + + # manager is offroad-only, so a refresh queued onroad would never be serviced + refreshing = refresh_in_progress(self._refresh_start) + self.refresh_btn.set_enabled(ui_state.is_offroad() and not is_downloading and not refreshing) + self.refresh_btn.set_value(tr("fetching...") if refreshing else "") + self.current_model_info.current_model_header.set_text(tr("active model")) active_text, info_header, info_text = _model_info() self.current_model_info.current_model_text.set_text(active_text) diff --git a/openpilot/selfdrive/ui/sunnypilot/model_info.py b/openpilot/selfdrive/ui/sunnypilot/model_info.py index e338feb9e..3820e4daa 100644 --- a/openpilot/selfdrive/ui/sunnypilot/model_info.py +++ b/openpilot/selfdrive/ui/sunnypilot/model_info.py @@ -4,12 +4,28 @@ Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. This file is part of sunnypilot and is licensed under the MIT License. See the LICENSE.md file in the root directory for more details. """ +import contextlib +import os +import time + +from openpilot.common.hardware.hw import Paths from openpilot.selfdrive.ui.ui_state import ui_state, ChestnutState from openpilot.sunnypilot.models.fetcher import get_cached_bundles from openpilot.sunnypilot.models.helpers import get_active_source, get_selected_bundle, resolve_bundle_by_ref from openpilot.sunnypilot.models.model_name import DEFAULT_BIG_MODEL, DEFAULT_MODEL +def model_cache_size_mb() -> float: + """Bytes on disk under the model cache directory, in MB.""" + model_root = Paths.model_root() + total = 0 + if os.path.isdir(model_root): + for name in os.listdir(model_root): + with contextlib.suppress(OSError): + total += os.path.getsize(os.path.join(model_root, name)) + return total / (1024 ** 2) + + def active_source() -> str: return get_active_source(chestnut=ui_state.chestnut_present, chestnut_active=ui_state.chestnut_active, chestnut_loading=ui_state.chestnut_loading, @@ -83,3 +99,22 @@ def model_info() -> tuple[str, str, str]: active_name = active_bundle.displayName if active_bundle else default_model_name(source) other_name = other_bundle.displayName if other_bundle else default_model_name(other) return source, active_name, other_name + + +# mirrors the manager's ModelCache keys; the manager restamps them on a successful fetch +MODEL_SYNC_KEYS = ("ModelManager_LastSyncTime", "ModelManager_LastSyncTime_Chestnut") +MODEL_SYNC_TIMEOUT = 20.0 + + +def refresh_model_list() -> None: + # zeroing the sync keys makes the manager refetch each manifest on its next tick + for key in MODEL_SYNC_KEYS: + ui_state.params.put(key, 0) + + +def refresh_in_progress(started_at: float | None) -> bool: + """Whether a user refresh is still outstanding. A failed fetch never restamps the + sync keys, so the spinner is bounded by MODEL_SYNC_TIMEOUT rather than sticking.""" + if started_at is None or time.monotonic() - started_at > MODEL_SYNC_TIMEOUT: + return False + return not all(ui_state.params.get(key) for key in MODEL_SYNC_KEYS)