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('