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/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 }}
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
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/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/selfdrive/ui/sunnypilot/layouts/settings/models.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py
index d1abfa22a..d036d8d28 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
)
@@ -115,39 +116,38 @@ 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)
@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")
@@ -345,6 +345,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)
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/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('