From b67898fac4e99d8850ed39fb545b00badc4ca227 Mon Sep 17 00:00:00 2001 From: James Vecellio-Grant <159560811+Discountchubbs@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:24:12 -0700 Subject: [PATCH 1/8] models: test tinygrad concurrency (#2006) --- .github/workflows/test_models.yml | 79 ++++++++++++++ openpilot/sunnypilot/modeld_v2/helpers.py | 100 ++++++++++++++++++ openpilot/sunnypilot/modeld_v2/modeld.py | 3 +- .../sunnypilot/modeld_v2/tests/test_models.py | 43 ++++++++ .../models/tests/test_tinygrad_ref.py | 24 ----- tinygrad_repo | 2 +- 6 files changed, 225 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/test_models.yml create mode 100644 openpilot/sunnypilot/modeld_v2/helpers.py create mode 100644 openpilot/sunnypilot/modeld_v2/tests/test_models.py delete mode 100644 openpilot/sunnypilot/models/tests/test_tinygrad_ref.py diff --git a/.github/workflows/test_models.yml b/.github/workflows/test_models.yml new file mode 100644 index 000000000..0a1a463aa --- /dev/null +++ b/.github/workflows/test_models.yml @@ -0,0 +1,79 @@ +name: Test Models Compatibility With Tinygrad Changes +on: + pull_request: + paths: + - 'tinygrad_repo' + workflow_dispatch: + +jobs: + generate-matrix: + runs-on: ubuntu-latest + outputs: + models: ${{ steps.set-matrix.outputs.models }} + steps: + - uses: actions/checkout@v4 + - name: Fetch and Parse json + id: set-matrix + run: | + python3 -c ' + import json, urllib.request, os, re + + with open("openpilot/sunnypilot/models/fetcher.py", "r") as f: + urls = re.findall(r"MODEL_URL(?:_CHESTNUT)?\s*=\s*[\"'"'"']([^\"'"'"']+)[\"'"'"']", f.read()) + + artifacts = [] + for url in urls: + data = json.loads(urllib.request.urlopen(url).read()) + for bundle in data.get("bundles", []): + for model in bundle.get("models", []): + if "artifact" in model: + artifacts.append(model["artifact"]) + + with open(os.environ["GITHUB_OUTPUT"], "a") as f: + f.write(f"models={json.dumps(artifacts)}\n") + ' + + test-model: + name: Test ${{ matrix.artifact.file_name }} + needs: generate-matrix + runs-on: ubuntu-latest + container: ghcr.io/commaai/openpilot-base:latest + strategy: + fail-fast: false + matrix: + artifact: ${{ fromJson(needs.generate-matrix.outputs.models) }} + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + - name: Download Model Chunks in Parallel + run: | + mkdir -p /tmp/model_chunks + echo '${{ toJson(matrix.artifact.chunks) }}' > chunks.json + + BASE_URL="${{ matrix.artifact.download_uri.url }}" + export BASE_DIR=$(dirname "$BASE_URL") + + python3 -c ' + import json, os + with open("chunks.json") as f: + chunks = json.load(f) + manifest_path = f"/tmp/model_chunks/${{ matrix.artifact.file_name }}.chunkmanifest" + with open(manifest_path, "w") as f: + f.write(str(len(chunks))) + base_dir = os.environ["BASE_DIR"] + with open("/tmp/curl_config.txt", "w") as f: + for c in chunks: + fn = c["file_name"] + f.write(f"url = \"{base_dir}/{fn}\"\noutput = \"/tmp/model_chunks/{fn}\"\n") + ' + curl -Z --parallel-immediate --parallel-max 16 -s -S -f -L -K /tmp/curl_config.txt + + - name: Run Model Compatibility Test + env: + MODEL_BASE_NAME: ${{ matrix.artifact.file_name }} + MODEL_CHUNK_DIR: "/tmp/model_chunks" + PYTHONPATH: ".:./tinygrad_repo" + run: | + python3 -m pytest openpilot/sunnypilot/modeld_v2/tests/test_models.py diff --git a/openpilot/sunnypilot/modeld_v2/helpers.py b/openpilot/sunnypilot/modeld_v2/helpers.py new file mode 100644 index 000000000..5beb52ac7 --- /dev/null +++ b/openpilot/sunnypilot/modeld_v2/helpers.py @@ -0,0 +1,100 @@ +""" +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 io +import struct +import pickle +import inspect +import importlib +import enum + + +def _pad_args(func, args, kwargs): + try: + sig = inspect.signature(func) + except Exception: + return args, kwargs + params = list(sig.parameters.values()) + if inspect.isfunction(func) and params and params[0].name in ('cls', 'self'): + params = params[1:] + + new_args = list(args) + has_varargs = any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in params) + if len(new_args) > len(params) and not has_varargs: + new_args = new_args[:len(params)] + + for i in range(len(new_args), len(params)): + param = params[i] + if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): + continue + val = param.default if param.default is not inspect.Parameter.empty else None + new_args.append(val) + return new_args, kwargs + + +def _enum_factory(enum_class): + def factory(*args, **kwargs): + try: + return enum_class(*args, **kwargs) + # OptOps and UOp objects in the .pkl are left over from the compilation phase, + # reassignment does nothing because they aren't tied to the execution graph + # It never executes or evaluates the UOp nodes again. + except ValueError: + return list(enum_class)[0] + factory.__name__ = enum_class.__name__ + factory.__module__ = enum_class.__module__ + return factory + + +def _dynamic_factory(real_class): + if isinstance(real_class, type) and issubclass(real_class, enum.Enum): + return _enum_factory(real_class) + + def factory(*args, **kwargs): + try: + return real_class(*args, **kwargs) + except TypeError: + new_args, new_kwargs = _pad_args(real_class, args, kwargs) + return real_class(*new_args, **new_kwargs) + + class DynamicMeta(type(real_class)): + def __call__(cls, *args, **kwargs): + return factory(*args, **kwargs) + + class DynamicProxy(real_class, metaclass=DynamicMeta): + __slots__ = () + + def __new__(cls, *args, **kwargs): + return factory(*args, **kwargs) + + DynamicProxy.__name__ = real_class.__name__ + DynamicProxy.__module__ = real_class.__module__ + return DynamicProxy + + +class DynamicTinygradUnpickler(pickle.Unpickler): + def find_class(self, module, name): + if module == "tinygrad.ops": + try: + importlib.import_module("tinygrad.uops") + module = "tinygrad.uops" + except ImportError: + pass + real_class = getattr(importlib.import_module(module), name) + if module.startswith("tinygrad"): + return _dynamic_factory(real_class) + return real_class + + +def load_oob(f): + opcodes = f.read(struct.unpack(' Date: Sun, 13 Sep 2026 16:12:56 +0100 Subject: [PATCH 2/8] mici: add a clear cache button to the models panel (#2008) models: add a clear cache button to mici, gate and show progress on both panels Adds the clear-cache tile to the mici models panel (trash slide-to-confirm), factoring the cache-size math into model_cache_size_mb() shared with the big UI. On both UIs the clear button is now gated on offroad + not-downloading + not-clearing (the manager runs offroad-only, so a clear queued onroad would never be serviced and would stick), and shows progress while the manager works: mici shows "clearing..." on the tile, the big UI flips its button to "CLEARING..." to match its FETCHING.../SELECT/CLEAR label style (as in the OSM panel). Claude-Session: https://claude.ai/code/session_01EGMnVnSk5inGTrd7kuDVG9 Co-authored-by: Claude Opus 4.8 (1M context) --- .../ui/sunnypilot/layouts/settings/models.py | 26 +++++++++--------- .../ui/sunnypilot/mici/layouts/models.py | 27 ++++++++++++++++--- .../selfdrive/ui/sunnypilot/model_info.py | 15 +++++++++++ 3 files changed, 51 insertions(+), 17 deletions(-) diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py index d1abfa22a..86d3e1e51 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) 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,7 @@ class ModelsLayout(Widget): self._selection_source = None self._downloading = False self._verifying = False + self._clearing = False self._last_note = None self.last_cache_calc_time = 0 @@ -75,7 +75,7 @@ class ModelsLayout(Widget): 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,20 +122,12 @@ 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) @@ -147,7 +139,10 @@ class ModelsLayout(Widget): 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") @@ -345,6 +340,9 @@ 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) + 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..7522be9d6 100644 --- a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py @@ -4,15 +4,17 @@ 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) 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 @@ -84,7 +86,11 @@ class ModelsLayoutMici(NavScroller): 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.clear_cache_btn] self._scroller.add_widgets(self.main_items) @property @@ -162,6 +168,11 @@ 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 _select_folder(self, folder_name): source = self._selection_source if source is None: # folders are only reachable after picking a hardware @@ -205,6 +216,16 @@ 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") + 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..a284acfca 100644 --- a/openpilot/selfdrive/ui/sunnypilot/model_info.py +++ b/openpilot/selfdrive/ui/sunnypilot/model_info.py @@ -4,12 +4,27 @@ 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 + +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, From 3c24eeea2518cd1685da71fd44cc4bc51cb8c4a6 Mon Sep 17 00:00:00 2001 From: Amy Jeanes Date: Sun, 13 Sep 2026 18:16:44 +0100 Subject: [PATCH 3/8] ci: remove stale disabled workflows (#1994) Both are disabled in the Actions tab and have not run in months: - Release Drafter (release-drafter.yml) and its config .github/release-drafter.yml: last run 2025-12-18 - Debug Discourse Posting (test-discourse.yaml.yml): one-off debug workflow from #1435, last run 2025-10-28 The post-to-discourse composite action is kept; the prebuilt workflow still uses it. docs, stale and jenkins scan are also disabled here but are inherited from commaai/openpilot and left in place to avoid modify/delete conflicts on every upstream sync. Claude-Session: https://claude.ai/code/session_01WAnUCRcA7Hp68qxyL11Wvm Co-authored-by: Claude Fable 5.1 Co-authored-by: James Vecellio-Grant <159560811+Discountchubbs@users.noreply.github.com> --- .github/release-drafter.yml | 43 ------------- .github/workflows/release-drafter.yml | 28 -------- .github/workflows/test-discourse.yaml.yml | 78 ----------------------- 3 files changed, 149 deletions(-) delete mode 100644 .github/release-drafter.yml delete mode 100644 .github/workflows/release-drafter.yml delete mode 100644 .github/workflows/test-discourse.yaml.yml diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml deleted file mode 100644 index a868ebb11..000000000 --- a/.github/release-drafter.yml +++ /dev/null @@ -1,43 +0,0 @@ -exclude-labels: - - 'no-changelog' -categories: - - title: '๐Ÿš€ Features' - labels: - - 'feature' - - 'enhancement' - - title: '๐Ÿ› Bug Fixes' - collapse-after: 5 - labels: - - 'fix' - - 'bugfix' - - 'bug' - - title: '๐Ÿงฐ Maintenance' - collapse-after: 5 - label: 'chore' -change-template: '- $TITLE @$AUTHOR (#$NUMBER)' -change-title-escapes: '\<*_&' -replacers: - - search: '/[Ss][Uu][Nn][Nn][Yy][Pp][Ii][Ll][Oo][Tt]/g' - replace: 'sunnypilot' - - search: '/\b[Ss][Pp]\b/g' - replace: 'SP' -version-resolver: - major: - labels: - - 'major' - minor: - labels: - - 'minor' - patch: - labels: - - 'patch' - default: patch -name-template: 'v$RESOLVED_VERSION ๐Ÿš€' -tag-template: 'v$RESOLVED_VERSION' -version-template: "0.$MAJOR.$MINOR.$PATCH" # The day OP becomes v1, we need to bump this -tag-prefix: "v0." # The day OP becomes v1, we need to bump this -prerelease-identifier: "staging" -template: | - ## Changes - - $CHANGES diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml deleted file mode 100644 index c072e98e2..000000000 --- a/.github/workflows/release-drafter.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Release Drafter - -on: - push: - branches: - - master - tags: - - 'v*' - pull_request_target: - types: [opened, reopened, synchronize] - workflow_dispatch: - -permissions: - contents: read - -jobs: - update_release_draft: - permissions: - contents: write - pull-requests: write - runs-on: ubuntu-latest - steps: - - uses: release-drafter/release-drafter@v6 - with: - config-name: release-drafter.yml - prerelease: ${{ !startsWith(github.ref, 'refs/tags/v') }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test-discourse.yaml.yml b/.github/workflows/test-discourse.yaml.yml deleted file mode 100644 index fadaec4ea..000000000 --- a/.github/workflows/test-discourse.yaml.yml +++ /dev/null @@ -1,78 +0,0 @@ -name: Debug Discourse Posting - -on: - push: - -jobs: - test-discourse-post: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Post test message to Discourse - uses: ./.github/workflows/post-to-discourse - with: - discourse-url: ${{ vars.DISCOURSE_URL }} - api-key: ${{ secrets.DISCOURSE_API_KEY }} - api-username: ${{ secrets.DISCOURSE_API_USERNAME }} - topic-id: ${{ vars.DISCOURSE_UPDATES_TOPIC_ID }} - message: | - ## ๐Ÿงช Test Post from GitHub Actions - - **This is a test post to verify Discourse integration** - - - **Workflow**: ${{ github.workflow }} - - **Run Number**: #${{ github.run_number }} - - **Branch**: `${{ github.ref_name }}` - - **Commit**: ${{ github.sha }} - - **Actor**: @${{ github.actor }} - - **Timestamp**: ${{ github.event.head_commit.timestamp }} - - --- - - ### Fake Build Info (for testing) - - **Version**: 0.9.8-test - - **Build**: #42 - - **Branch**: release-test - - [View workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) - - *This is an automated test message. Drive safe! ๐Ÿš—๐Ÿ’จ* - - - - name: Create topic on Discourse - uses: ./.github/workflows/post-to-discourse - with: - discourse-url: ${{ vars.DISCOURSE_URL }} - api-key: ${{ secrets.DISCOURSE_API_KEY }} - api-username: ${{ secrets.DISCOURSE_API_USERNAME }} - #topic-id: ${{ vars.DISCOURSE_UPDATES_TOPIC_ID }} - category-id: 4 - title: "This is a test of a new topic instead of a reply" - message: | - ## ๐Ÿงช Test Post from GitHub Actions - - **This is a test post to verify Discourse integration** - - - **Workflow**: ${{ github.workflow }} - - **Run Number**: #${{ github.run_number }} - - **Branch**: `${{ github.ref_name }}` - - **Commit**: ${{ github.sha }} - - **Actor**: @${{ github.actor }} - - **Timestamp**: ${{ github.event.head_commit.timestamp }} - - --- - - ### Fake Build Info (for testing) - - **Version**: 0.9.8-test - - **Build**: #42 - - **Branch**: release-test - - [View workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) - - *This is an automated test message. Drive safe! ๐Ÿš—๐Ÿ’จ* - - name: Display results - if: always() - run: | - echo "::notice::Discourse post test completed" - echo "Check your Discourse topic to verify the post appeared correctly" \ No newline at end of file From c57f9a7f4e2f736e5d71fea1a695f741607cceb5 Mon Sep 17 00:00:00 2001 From: Amy Jeanes Date: Sun, 13 Sep 2026 18:31:27 +0100 Subject: [PATCH 4/8] workflows: let forks run their own model builds (#2009) build-single-tinygrad-model and build-all-tinygrad-models gain a docs_repo input (default sunnypilot/sunnypilot-models) so a fork can run either against its own gh-pages catalog, next to the existing hf_repo input for the dataset. build-all's setup job now checks out the repo and branch it was dispatched from instead of sunnypilot/sunnypilot's default branch. That keeps the manifest's tinygrad_ref tied to the code that compiled the models, and lets a fork run the full rebuild against its own dataset and docs repo. Claude-Session: https://claude.ai/code/session_01EGMnVnSk5inGTrd7kuDVG9 Co-authored-by: Claude Fable 5.1 Co-authored-by: James Vecellio-Grant <159560811+Discountchubbs@users.noreply.github.com> --- .github/workflows/build-all-tinygrad-models.yaml | 12 +++++++++--- .github/workflows/build-single-tinygrad-model.yaml | 12 +++++++++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-all-tinygrad-models.yaml b/.github/workflows/build-all-tinygrad-models.yaml index 8c2bcad0a..acf907e0c 100644 --- a/.github/workflows/build-all-tinygrad-models.yaml +++ b/.github/workflows/build-all-tinygrad-models.yaml @@ -20,6 +20,11 @@ on: required: false type: string default: 'sunnypilot/sunnypilot_models_v1' + docs_repo: + description: 'GitHub repo holding the driving_models JSON on its gh-pages branch' + required: false + type: string + default: 'sunnypilot/sunnypilot-models' jobs: setup: @@ -34,7 +39,6 @@ jobs: - name: Checkout sunnypilot repo uses: actions/checkout@v4 with: - repository: sunnypilot/sunnypilot path: sunnypilot submodules: recursive @@ -47,10 +51,10 @@ jobs: echo "tinygrad_ref=$ref" >> $GITHUB_OUTPUT echo "tinygrad_ref is $ref" - - name: Checkout docs repo (sunnypilot-models, gh-pages) + - name: Checkout docs repo (gh-pages) uses: actions/checkout@v4 with: - repository: sunnypilot/sunnypilot-models + repository: ${{ inputs.docs_repo }} ref: gh-pages path: docs ssh-key: ${{ secrets.CI_SUNNYPILOT_DOCS_PRIVATE_KEY }} @@ -118,6 +122,7 @@ jobs: json_version: ${{ needs.setup.outputs.json_version }} target_hardware: ${{ github.event.inputs.target_hardware }} hf_repo: ${{ github.event.inputs.hf_repo }} + docs_repo: ${{ inputs.docs_repo }} set_min_version: ${{ github.event.inputs.set_min_version }} tinygrad_ref: ${{ needs.setup.outputs.tinygrad_ref }} secrets: inherit @@ -162,6 +167,7 @@ jobs: target_hardware: ${{ github.event.inputs.target_hardware }} artifact_suffix: -retry hf_repo: ${{ github.event.inputs.hf_repo }} + docs_repo: ${{ inputs.docs_repo }} set_min_version: ${{ github.event.inputs.set_min_version }} tinygrad_ref: ${{ needs.setup.outputs.tinygrad_ref }} secrets: inherit diff --git a/.github/workflows/build-single-tinygrad-model.yaml b/.github/workflows/build-single-tinygrad-model.yaml index c795541e6..eb4b3d0aa 100644 --- a/.github/workflows/build-single-tinygrad-model.yaml +++ b/.github/workflows/build-single-tinygrad-model.yaml @@ -39,6 +39,11 @@ on: required: false type: string default: 'sunnypilot/sunnypilot_models_v1' + docs_repo: + description: 'GitHub repo holding the driving_models JSON on its gh-pages branch' + required: false + type: string + default: 'sunnypilot/sunnypilot-models' set_min_version: description: 'Minimum selector version' required: false @@ -107,6 +112,11 @@ on: required: false type: string default: 'sunnypilot/sunnypilot_models_v1' + docs_repo: + description: 'GitHub repo holding the driving_models JSON on its gh-pages branch' + required: false + type: string + default: 'sunnypilot/sunnypilot-models' env: RECOMPILED_DIR: recompiled${{ inputs.recompiled_dir }} JSON_FILE: docs/docs/driving_models_${{ inputs.target_hardware == 'chestnut' && 'chestnut_v' || 'v' }}${{ inputs.json_version }}.json @@ -136,7 +146,7 @@ jobs: - name: Checkout docs repo uses: actions/checkout@v4 with: - repository: sunnypilot/sunnypilot-models + repository: ${{ inputs.docs_repo }} ref: gh-pages path: docs ssh-key: ${{ secrets.CI_SUNNYPILOT_DOCS_PRIVATE_KEY }} From 347238b307027d7c0d1c10d51a38a7f6ad885649 Mon Sep 17 00:00:00 2001 From: dzid26 Date: Sun, 13 Sep 2026 20:38:20 +0100 Subject: [PATCH 5/8] camera offset: use real horizon for the shear center (#2016) --- .../modeld_v2/camera_offset_helper.py | 19 ++++++-- .../tests/test_camera_offset_helper.py | 48 +++++++++++++++---- 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/openpilot/sunnypilot/modeld_v2/camera_offset_helper.py b/openpilot/sunnypilot/modeld_v2/camera_offset_helper.py index 648ba0108..bcf3a20c0 100644 --- a/openpilot/sunnypilot/modeld_v2/camera_offset_helper.py +++ b/openpilot/sunnypilot/modeld_v2/camera_offset_helper.py @@ -15,11 +15,19 @@ class CameraOffsetHelper: self.actual_camera_offset = 0.0 @staticmethod - def apply_camera_offset(model_transform, intrinsics, height, offset_param): + def get_v_horizon(intrinsics, rpy_calib): cy = intrinsics[1, 2] + if len(rpy_calib) == 3 and np.isfinite(rpy_calib).all(): + fy = intrinsics[1, 1] + pitch = rpy_calib[1] + return float(cy - fy * np.tan(pitch)) + return float(cy) + + @staticmethod + def apply_camera_offset(model_transform, height, offset_param, v_horizon): shear = np.eye(3, dtype=np.float32) shear[0, 1] = offset_param / height - shear[0, 2] = -offset_param / height * cy + shear[0, 2] = -offset_param / height * v_horizon model_transform = (shear @ model_transform).astype(np.float32) return model_transform @@ -30,10 +38,13 @@ class CameraOffsetHelper: self.actual_camera_offset = (0.9 * self.actual_camera_offset) + (0.1 * self.camera_offset) dc = DEVICE_CAMERAS[(str(sm['deviceState'].deviceType), str(sm['narrowRoadCameraState'].sensor))] height = sm["extrinsicsCalibration"].height[0] if sm['extrinsicsCalibration'].height else 1.22 + rpy_calib = sm['extrinsicsCalibration'].rpyCalib intrinsics_main = dc.wide_road.intrinsics if main_wide_camera else dc.narrow_road.intrinsics - model_transform_main = self.apply_camera_offset(model_transform_main, intrinsics_main, height, self.actual_camera_offset) + v_horizon_main = self.get_v_horizon(intrinsics_main, rpy_calib) + model_transform_main = self.apply_camera_offset(model_transform_main, height, self.actual_camera_offset, v_horizon_main) intrinsics_extra = dc.wide_road.intrinsics - model_transform_extra = self.apply_camera_offset(model_transform_extra, intrinsics_extra, height, self.actual_camera_offset) + v_horizon_extra = self.get_v_horizon(intrinsics_extra, rpy_calib) + model_transform_extra = self.apply_camera_offset(model_transform_extra, height, self.actual_camera_offset, v_horizon_extra) return model_transform_main, model_transform_extra diff --git a/openpilot/sunnypilot/modeld_v2/tests/test_camera_offset_helper.py b/openpilot/sunnypilot/modeld_v2/tests/test_camera_offset_helper.py index 5398ac0ff..4cd3634c1 100644 --- a/openpilot/sunnypilot/modeld_v2/tests/test_camera_offset_helper.py +++ b/openpilot/sunnypilot/modeld_v2/tests/test_camera_offset_helper.py @@ -6,8 +6,9 @@ See the LICENSE.md file in the root directory for more details. """ import numpy as np -from openpilot.common.transformations.camera import DEVICE_CAMERAS +from openpilot.common.transformations.camera import DEVICE_CAMERAS, view_frame_from_device_frame from openpilot.common.transformations.model import get_warp_matrix +from openpilot.common.transformations.orientation import rot_from_euler from openpilot.sunnypilot.modeld_v2.camera_offset_helper import CameraOffsetHelper from openpilot.common.test import OpenpilotTestCase @@ -46,29 +47,50 @@ class TestCameraOffset(OpenpilotTestCase): self.camera_offset.update(main_transform, extra_transform, sm, False) np.testing.assert_almost_equal(self.camera_offset.actual_camera_offset, 0.038) - def test_camera_offset_(self): + def test_apply_camera_offset(self): intrinsics = self.dc.narrow_road.intrinsics + v_horizon = CameraOffsetHelper.get_v_horizon(intrinsics, []) # pitch = 0 fallback: v_horizon == cy transform = np.eye(3, dtype=np.float32) height = 1.22 offset = 0.1 - cy = intrinsics[1, 2] expected_shear = np.eye(3, dtype=np.float32) expected_shear[0, 1] = offset / height - expected_shear[0, 2] = -offset / height * cy + expected_shear[0, 2] = -offset / height * v_horizon - result = CameraOffsetHelper.apply_camera_offset(transform, intrinsics, height, offset) + result = CameraOffsetHelper.apply_camera_offset(transform, height, offset, v_horizon) np.testing.assert_array_almost_equal(result, expected_shear) + def test_v_horizon_empty_rpy(self): + intrinsics = self.dc.narrow_road.intrinsics + v_horizon = CameraOffsetHelper.get_v_horizon(intrinsics, []) + np.testing.assert_almost_equal(v_horizon, intrinsics[1, 2]) + + def test_v_horizon_projection(self): + intrinsics = self.dc.narrow_road.intrinsics + f, cy = intrinsics[1, 1], intrinsics[1, 2] + + for pitch_deg in [6.0, -6.0, 0.0]: + rpy = [0.0, np.radians(pitch_deg), 0.0] + d_dev = rot_from_euler(rpy) @ np.array([1.0, 0.0, 0.0]) + view = view_frame_from_device_frame @ d_dev + expected = cy + f * view[1] / view[2] + + v_horizon = CameraOffsetHelper.get_v_horizon(intrinsics, rpy) + np.testing.assert_almost_equal(v_horizon, expected, decimal=4) + def test_update(self): + height = 1.2 + pitch = np.radians(-8.0) + sm = MockStruct( deviceState=MockStruct(deviceType='mici'), narrowRoadCameraState=MockStruct(sensor='os04c10'), - extrinsicsCalibration=MockStruct(rpyCalib=[0.0, 0.0, 0.0], height=[1.22]) + extrinsicsCalibration=MockStruct(rpyCalib=[0.0, pitch, 0.0], height=[height]) ) intrinsics_main = self.dc.narrow_road.intrinsics intrinsics_extra = self.dc.wide_road.intrinsics - device_from_calib_euler = np.array([0.0, 0.0, 0.0], dtype=np.float32) + device_from_calib_euler = np.array(sm['extrinsicsCalibration'].rpyCalib, dtype=np.float32) main_transform = get_warp_matrix(device_from_calib_euler, intrinsics_main, False).astype(np.float32) extra_transform = get_warp_matrix(device_from_calib_euler, intrinsics_extra, True).astype(np.float32) @@ -81,5 +103,13 @@ class TestCameraOffset(OpenpilotTestCase): main_out, extra_out = self.camera_offset.update(main_transform, extra_transform, sm, False) assert not np.array_equal(main_out, main_transform) assert not np.array_equal(extra_out, extra_transform) - assert main_out[0, 1] != 0.0 - assert main_out[0, 2] != 0.0 + + # settle the low-pass filter + for _ in range(100): + main_out, extra_out = self.camera_offset.update(main_transform, extra_transform, sm, False) + + # undo main_transform dot product to get shear matrix + shear = main_out @ np.linalg.inv(main_transform) + expected_v_horizon = intrinsics_main[1, 2] - intrinsics_main[1, 1] * np.tan(pitch) + np.testing.assert_almost_equal(shear[0, 1], self.camera_offset.actual_camera_offset / height, decimal=4) + np.testing.assert_almost_equal(shear[0, 2], -self.camera_offset.actual_camera_offset / height * expected_v_horizon, decimal=4) From 5484f7f4a72a98ff863762ab8049d5d167bc5c95 Mon Sep 17 00:00:00 2001 From: Matt Purnell <65473602+mpurnell1@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:49:59 -0500 Subject: [PATCH 6/8] modeld: set the valid flag on modelDataV2SP (#2017) * modeld: set the valid flag on modelDataV2SP modelDataV2SP was published with new_message's default valid=False, so the message was permanently invalid. Nothing acts on that today because selfdrived lists it under ignore_valid, but it shows up as invalid in every commIssue dump and hides any real problem behind a false one. Copy modelV2's flag, the same way fill_model_msg already does for drivingModelData. Assisted-by: Claude Co-Authored-By: Claude Fable 5.1 * modeld_v2: set the valid flag on modelDataV2SP The sunnypilot model runner publishes the same message and had the same gap. Copy modelV2's flag here too, so both daemons agree. Assisted-by: Claude Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Fable 5.1 Co-authored-by: James Vecellio-Grant <159560811+Discountchubbs@users.noreply.github.com> --- openpilot/selfdrive/modeld/modeld.py | 1 + openpilot/sunnypilot/modeld_v2/modeld.py | 1 + 2 files changed, 2 insertions(+) diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index 7e7fa7f58..b98b4d0f5 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -474,6 +474,7 @@ def main(demo=False): DH.update(sm['carState'], sm['carControl'].latActive, lane_change_prob, left_edge, right_edge) modelv2_send.modelV2.meta.laneChangeState = DH.lane_change_state modelv2_send.modelV2.meta.laneChangeDirection = DH.lane_change_direction + mdv2sp_send.valid = modelv2_send.valid mdv2sp_send.modelDataV2SP.laneTurnDirection = DH.lane_turn_direction fill_driving_model_data(drivingdata_send, modelv2_send) diff --git a/openpilot/sunnypilot/modeld_v2/modeld.py b/openpilot/sunnypilot/modeld_v2/modeld.py index c80249c57..6e31045b8 100755 --- a/openpilot/sunnypilot/modeld_v2/modeld.py +++ b/openpilot/sunnypilot/modeld_v2/modeld.py @@ -552,6 +552,7 @@ def main(demo=False): DH.update(sm['carState'], sm['carControl'].latActive, lane_change_prob, left_edge, right_edge) modelv2_send.modelV2.meta.laneChangeState = DH.lane_change_state modelv2_send.modelV2.meta.laneChangeDirection = DH.lane_change_direction + mdv2sp_send.valid = modelv2_send.valid mdv2sp_send.modelDataV2SP.laneTurnDirection = DH.lane_turn_direction drivingdata_send.drivingModelData.meta.laneChangeState = DH.lane_change_state drivingdata_send.drivingModelData.meta.laneChangeDirection = DH.lane_change_direction From 63a2a3868e0ed194ec5fedd628f711eab0411423 Mon Sep 17 00:00:00 2001 From: Amy Jeanes Date: Sun, 13 Sep 2026 21:31:13 +0100 Subject: [PATCH 7/8] models: don't freeze the ui on an unset LagdToggleDelay (#2026) models: don't block the ui on an unset LagdToggleDelay Params.get's second positional is `block`, not a fallback value, so get("LagdToggleDelay", "0.2") passes block=True and does a blocking read. When the param is unset this spins the ui thread until it appears, freezing the models panel (the description is rebuilt every frame). Read it the same way livedelay/lagd_toggle.py does. 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> --- openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py index 86d3e1e51..a81679b29 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py @@ -115,7 +115,7 @@ class ModelsLayout(Widget): if lagd_toggle: desc += f"
{tr('Live Steer Delay:')} {ui_state.sm['lateralDelay'].lateralDelay:.3f} s" elif ui_state.CP is not None: - sw = float(ui_state.params.get("LagdToggleDelay", "0.2")) + sw = float(ui_state.params.get("LagdToggleDelay", return_default=True)) cp = ui_state.CP.steerActuatorDelay desc += f"
{tr('Actuator Delay:')} {cp:.2f} s + {tr('Software Delay:')} {sw:.2f} s = {tr('Total Delay:')} {cp + sw:.2f} s" self.lagd_toggle.set_description(desc) From a5f44653d7f43ad57fef2f546f3916ec4cbf3c56 Mon Sep 17 00:00:00 2001 From: Amy Jeanes Date: Mon, 14 Sep 2026 16:33:48 +0100 Subject: [PATCH 8/8] 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> --- .../ui/sunnypilot/layouts/settings/models.py | 19 +++++++++++++----- .../ui/sunnypilot/mici/layouts/models.py | 18 +++++++++++++++-- .../selfdrive/ui/sunnypilot/model_info.py | 20 +++++++++++++++++++ 3 files changed, 50 insertions(+), 7 deletions(-) diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py index a81679b29..d036d8d28 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py @@ -13,7 +13,7 @@ from openpilot.sunnypilot.models.helpers import ACTIVE_BUNDLE_KEYS, get_selected 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, - model_cache_size_mb, queued_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 @@ -40,6 +40,8 @@ class ModelsLayout(Widget): 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,10 +69,9 @@ 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"), @@ -133,6 +134,10 @@ class ModelsLayout(Widget): 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 @@ -343,6 +348,10 @@ class ModelsLayout(Widget): # 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 7522be9d6..404ff2e30 100644 --- a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py @@ -14,7 +14,8 @@ from openpilot.sunnypilot.models.helpers import ACTIVE_BUNDLE_KEYS, get_selected 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_cache_size_mb, 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 @@ -83,6 +84,10 @@ 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")) @@ -90,7 +95,7 @@ class ModelsLayoutMici(NavScroller): 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.clear_cache_btn] + 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 @@ -173,6 +178,10 @@ class ModelsLayoutMici(NavScroller): 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 @@ -226,6 +235,11 @@ class ModelsLayoutMici(NavScroller): 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 a284acfca..3820e4daa 100644 --- a/openpilot/selfdrive/ui/sunnypilot/model_info.py +++ b/openpilot/selfdrive/ui/sunnypilot/model_info.py @@ -6,6 +6,7 @@ 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 @@ -98,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)