From 94ed0608e6c62f33f7cf17aaa0498869e065324c Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Mon, 24 Aug 2026 01:40:31 -0400 Subject: [PATCH 01/67] models: use less strict chestnut detection state (#1948) --- openpilot/sunnypilot/models/fetcher.py | 17 ++++++++--------- openpilot/sunnypilot/models/manager.py | 4 +++- .../sunnypilot/sunnylink/athena/sunnylinkd.py | 7 ++----- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/openpilot/sunnypilot/models/fetcher.py b/openpilot/sunnypilot/models/fetcher.py index b64e27b1ad..773eb5b95c 100644 --- a/openpilot/sunnypilot/models/fetcher.py +++ b/openpilot/sunnypilot/models/fetcher.py @@ -13,8 +13,6 @@ from openpilot.common.params import Params from openpilot.common.swaglog import cloudlog from openpilot.common.hardware.hw import Paths from openpilot.sunnypilot.models.helpers import is_bundle_version_compatible -from openpilot.selfdrive.modeld.helpers import usbgpu_present - from openpilot.cereal import custom @@ -149,11 +147,10 @@ class ModelFetcher: self._is_usbgpu: bool | None = None self.model_cache = ModelCache(params) self.model_url = self.MODEL_URL - self._update_model_source() - def _update_model_source(self) -> None: - """Updates what json to use based on usbgpu availability""" - is_usbgpu = usbgpu_present() + def _update_model_source(self, chestnut_present: bool) -> None: + """Updates what json to use based on chestnut hardware presence via deviceState""" + is_usbgpu = chestnut_present if is_usbgpu != self._is_usbgpu: self._is_usbgpu = is_usbgpu self.model_cache = ModelCache(self.params, suffix="_USBGPU" if is_usbgpu else "") @@ -191,9 +188,9 @@ class ModelFetcher: return None - def get_available_bundles(self) -> list[custom.ModelManagerSP.ModelBundle]: + def get_available_bundles(self, chestnut_present: bool = False) -> list[custom.ModelManagerSP.ModelBundle]: """Gets the list of available models, with smart cache handling""" - self._update_model_source() + self._update_model_source(chestnut_present) cached_data, is_expired = self.model_cache.get() if cached_data and not is_expired: @@ -210,10 +207,12 @@ class ModelFetcher: cloudlog.warning("Failed to fetch fresh data. Using expired cache as fallback") return self.model_parser.parse_models(cached_data) + if __name__ == "__main__": + from openpilot.selfdrive.modeld.helpers import usbgpu_present params = Params() model_fetcher = ModelFetcher(params) - bundles = model_fetcher.get_available_bundles() + bundles = model_fetcher.get_available_bundles(chestnut_present=usbgpu_present()) for bundle in bundles: for model in bundle.models: model_overrides = {override.key: override.value for override in bundle.overrides} diff --git a/openpilot/sunnypilot/models/manager.py b/openpilot/sunnypilot/models/manager.py index 8ca8775875..37bcb781cf 100644 --- a/openpilot/sunnypilot/models/manager.py +++ b/openpilot/sunnypilot/models/manager.py @@ -30,6 +30,7 @@ class ModelManagerSP: self.params = Params() self.model_fetcher = ModelFetcher(self.params) self.pm = messaging.PubMaster(["modelManagerSP"]) + self.sm = messaging.SubMaster(["deviceState"]) self.available_models: list[custom.ModelManagerSP.ModelBundle] = [] self.selected_bundle: custom.ModelManagerSP.ModelBundle = None self.active_bundle: custom.ModelManagerSP.ModelBundle = get_active_bundle(self.params) @@ -262,7 +263,8 @@ class ModelManagerSP: while True: try: - self.available_models = self.model_fetcher.get_available_bundles() + self.sm.update(0) + self.available_models = self.model_fetcher.get_available_bundles(self.sm['deviceState'].chestnutPresent) validate_active_bundle(self.params, self.available_models) self.active_bundle = get_active_bundle(self.params) diff --git a/openpilot/sunnypilot/sunnylink/athena/sunnylinkd.py b/openpilot/sunnypilot/sunnylink/athena/sunnylinkd.py index 3425a85624..1ab2f373ed 100755 --- a/openpilot/sunnypilot/sunnylink/athena/sunnylinkd.py +++ b/openpilot/sunnypilot/sunnylink/athena/sunnylinkd.py @@ -28,7 +28,6 @@ from websocket import (ABNF, WebSocket, WebSocketException, WebSocketTimeoutExce create_connection, WebSocketConnectionClosedException) import openpilot.cereal.messaging as messaging -from openpilot.selfdrive.modeld.helpers import usbgpu_present from openpilot.sunnypilot.models.model_name import DEFAULT_MODEL, DEFAULT_BIG_MODEL from openpilot.sunnypilot.selfdrive.car.sync_sunnylink_params import update_car_list_param from openpilot.sunnypilot.sunnylink.api import SunnylinkApi @@ -182,10 +181,8 @@ def getParamsMetadata() -> str: schema = generate_schema() schema["capabilities"] = generate_capabilities() schema["capability_labels"] = CAPABILITY_LABELS - # mirrors get_default_model() — ui_state unavailable in sunnylinkd process - show_big = (usbgpu_present() - and (params.get_bool("UsbGpuActive") or params.get_bool("UsbGpuLoading") or params.get_bool("IsOffroad"))) - schema["default_model"] = DEFAULT_BIG_MODEL if show_big else DEFAULT_MODEL + schema["default_model"] = DEFAULT_MODEL + schema["default_big_model"] = DEFAULT_BIG_MODEL schema["usbgpu_active"] = params.get_bool("UsbGpuActive") raw = json.dumps(schema, separators=(",", ":")).encode("utf-8") return base64.b64encode(gzip.compress(raw)).decode("utf-8") From 66cf334067cac6a412302b2afa3a18c29b3acbe7 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Mon, 24 Aug 2026 12:35:37 -0400 Subject: [PATCH 02/67] ci: unify default model build into single workflow (#1951) * ci: unify default model build into single workflow * ci: consolidate upload jobs and add tinygrad ref validation --- .../workflows/build-default-big-model.yaml | 83 ------ .github/workflows/build-default-models.yaml | 279 ++++++++++++++++++ .../workflows/sunnypilot-build-prebuilt.yaml | 8 +- 3 files changed, 283 insertions(+), 87 deletions(-) delete mode 100644 .github/workflows/build-default-big-model.yaml create mode 100644 .github/workflows/build-default-models.yaml diff --git a/.github/workflows/build-default-big-model.yaml b/.github/workflows/build-default-big-model.yaml deleted file mode 100644 index 4b05a7977d..0000000000 --- a/.github/workflows/build-default-big-model.yaml +++ /dev/null @@ -1,83 +0,0 @@ -name: Build default big model - -on: - workflow_dispatch: - -env: - HF_REPO: sunnypilot/sunnypilot_models_v1 - HF_DEFAULTS_PATH: models/defaults/big - -jobs: - resolve_name: - runs-on: ubuntu-24.04 - outputs: - model_name: ${{ steps.name.outputs.model_name }} - onnx_ref: ${{ steps.name.outputs.onnx_ref }} - steps: - - uses: actions/checkout@v4 - - id: name - run: | - NAME=$(PYTHONPATH=${{ github.workspace }} python3 -c "from openpilot.sunnypilot.models.model_name import DEFAULT_BIG_MODEL; print(DEFAULT_BIG_MODEL)") - ONNX_REF=$(git log -1 --format='%H' -- openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx) - echo "model_name=${NAME}" >> $GITHUB_OUTPUT - echo "onnx_ref=$ONNX_REF" >> $GITHUB_OUTPUT - - build_model: - needs: resolve_name - uses: ./.github/workflows/sunnypilot-build-model.yaml - with: - upstream_branch: ${{ needs.resolve_name.outputs.onnx_ref }} - custom_name: ${{ needs.resolve_name.outputs.model_name }} - target_hardware: usbgpu - secrets: inherit - - upload_defaults: - needs: [ resolve_name, build_model ] - runs-on: ubuntu-24.04 - permissions: - id-token: write - contents: write - steps: - - uses: actions/checkout@v4 - with: - submodules: recursive - - run: git lfs pull -I "openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx" - - - name: Install huggingface_hub - run: pip install --upgrade "huggingface_hub>=0.22.0" - - - name: Download artifact name - uses: actions/download-artifact@v4 - with: - name: artifact-name-${{ needs.resolve_name.outputs.model_name }} - path: artifact_name - - - name: Read artifact name - id: artifact - run: | - ARTIFACT_NAME=$(cat artifact_name/artifact_name.txt) - echo "artifact_name=$ARTIFACT_NAME" >> $GITHUB_OUTPUT - - - name: Download model artifact - uses: actions/download-artifact@v4 - with: - name: ${{ steps.artifact.outputs.artifact_name }} - path: output - - - name: Upload to HF and update default_models.json - env: - HF_OIDC_RESOURCE: datasets/${{ env.HF_REPO }} - ARTIFACT_NAME: ${{ steps.artifact.outputs.artifact_name }} - run: | - rm -f output/artifact_name.txt - export PYTHONPATH=$(pwd) - python3 release/ci/upload_default_model.py \ - --hf-repo "${{ env.HF_REPO }}" \ - --hf-defaults-path "${{ env.HF_DEFAULTS_PATH }}" \ - --artifact-name "$ARTIFACT_NAME" \ - --model-dir output \ - --onnx-path "openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx" \ - --onnx-ref "${{ needs.resolve_name.outputs.onnx_ref }}" \ - --model-name "${{ needs.resolve_name.outputs.model_name }}" \ - --tinygrad-ref "$(python3 openpilot/sunnypilot/models/tinygrad_ref.py)" \ - --run-number "${{ github.run_number }}" diff --git a/.github/workflows/build-default-models.yaml b/.github/workflows/build-default-models.yaml new file mode 100644 index 0000000000..55efda4e4d --- /dev/null +++ b/.github/workflows/build-default-models.yaml @@ -0,0 +1,279 @@ +name: Build default models + +on: + workflow_dispatch: + inputs: + target: + description: 'Model target to build' + required: true + type: choice + options: + - small + - big + workflow_call: + inputs: + target: + description: 'Model target to build (small or big)' + required: true + type: string + +env: + HF_REPO: sunnypilot/sunnypilot_models_v1 + +jobs: + resolve: + runs-on: ubuntu-24.04 + outputs: + model_name: ${{ steps.resolve.outputs.model_name }} + onnx_ref: ${{ steps.resolve.outputs.onnx_ref }} + onnx_path: ${{ steps.resolve.outputs.onnx_path }} + hf_defaults_path: ${{ steps.resolve.outputs.hf_defaults_path }} + target_hardware: ${{ steps.resolve.outputs.target_hardware }} + tinygrad_ref: ${{ steps.resolve.outputs.tinygrad_ref }} + dm_onnx_ref: ${{ steps.resolve.outputs.dm_onnx_ref }} + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - id: resolve + run: | + export PYTHONPATH=${{ github.workspace }} + + if [ "${{ inputs.target }}" = "big" ]; then + NAME=$(python3 -c "from openpilot.sunnypilot.models.model_name import DEFAULT_BIG_MODEL; print(DEFAULT_BIG_MODEL)") + ONNX_PATH="openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx" + HF_DEFAULTS_PATH="models/defaults/big" + TARGET_HW="usbgpu" + else + NAME=$(python3 -c "from openpilot.sunnypilot.models.model_name import DEFAULT_MODEL; print(DEFAULT_MODEL)") + ONNX_PATH="openpilot/selfdrive/modeld/models/driving_supercombo.onnx" + HF_DEFAULTS_PATH="models/defaults/small" + TARGET_HW="qcom" + fi + + ONNX_REF=$(git log -1 --format='%H' -- "$ONNX_PATH") + TINYGRAD_REF=$(python3 openpilot/sunnypilot/models/tinygrad_ref.py) + if [ -z "$TINYGRAD_REF" ]; then + echo "::error::Failed to resolve tinygrad ref" + exit 1 + fi + + DM_ONNX_REF="" + if [ "${{ inputs.target }}" = "small" ]; then + DM_ONNX_REF=$(git log -1 --format='%H' -- openpilot/selfdrive/modeld/models/dmonitoring_model.onnx) + fi + + echo "model_name=${NAME}" >> $GITHUB_OUTPUT + echo "onnx_ref=${ONNX_REF}" >> $GITHUB_OUTPUT + echo "onnx_path=${ONNX_PATH}" >> $GITHUB_OUTPUT + echo "hf_defaults_path=${HF_DEFAULTS_PATH}" >> $GITHUB_OUTPUT + echo "target_hardware=${TARGET_HW}" >> $GITHUB_OUTPUT + echo "tinygrad_ref=${TINYGRAD_REF}" >> $GITHUB_OUTPUT + echo "dm_onnx_ref=${DM_ONNX_REF}" >> $GITHUB_OUTPUT + + build_driving_model: + needs: resolve + uses: ./.github/workflows/sunnypilot-build-model.yaml + with: + upstream_branch: ${{ needs.resolve.outputs.onnx_ref }} + custom_name: ${{ needs.resolve.outputs.model_name }} + target_hardware: ${{ needs.resolve.outputs.target_hardware }} + secrets: inherit + + upload_defaults: + needs: [ resolve, build_driving_model, build_dm_model ] + if: ${{ !cancelled() && needs.build_driving_model.result == 'success' && (inputs.target != 'small' || needs.build_dm_model.result == 'success') }} + runs-on: ubuntu-24.04 + permissions: + id-token: write + contents: write + env: + DM_ONNX: openpilot/selfdrive/modeld/models/dmonitoring_model.onnx + steps: + - uses: actions/checkout@v4 + + - name: Pull ONNX via LFS + run: git lfs pull -I "${{ needs.resolve.outputs.onnx_path }}${{ inputs.target == 'small' && ',openpilot/selfdrive/modeld/models/dmonitoring_model.onnx' || '' }}" + + - name: Install huggingface_hub + run: pip install --upgrade "huggingface_hub>=0.22.0" + + - name: Download driving artifact name + uses: actions/download-artifact@v4 + with: + name: artifact-name-${{ needs.resolve.outputs.model_name }} + path: artifact_name + + - name: Read driving artifact name + id: artifact + run: | + ARTIFACT_NAME=$(cat artifact_name/artifact_name.txt) + echo "artifact_name=$ARTIFACT_NAME" >> $GITHUB_OUTPUT + + - name: Download driving model artifact + uses: actions/download-artifact@v4 + with: + name: ${{ steps.artifact.outputs.artifact_name }} + path: output + + - name: Upload driving model to HF + env: + HF_OIDC_RESOURCE: datasets/${{ env.HF_REPO }} + ARTIFACT_NAME: ${{ steps.artifact.outputs.artifact_name }} + run: | + rm -f output/artifact_name.txt + export PYTHONPATH=$(pwd) + python3 release/ci/upload_default_model.py \ + --hf-repo "${{ env.HF_REPO }}" \ + --hf-defaults-path "${{ needs.resolve.outputs.hf_defaults_path }}" \ + --artifact-name "$ARTIFACT_NAME" \ + --model-dir output \ + --onnx-path "${{ needs.resolve.outputs.onnx_path }}" \ + --onnx-ref "${{ needs.resolve.outputs.onnx_ref }}" \ + --model-name "${{ needs.resolve.outputs.model_name }}" \ + --tinygrad-ref "${{ needs.resolve.outputs.tinygrad_ref }}" \ + --run-number "${{ github.run_number }}" + + - name: Download DM artifact + if: ${{ inputs.target == 'small' }} + uses: actions/download-artifact@v4 + with: + name: dm-model-${{ github.run_number }} + path: dm_output + + - name: Generate DM metadata and upload to HF + if: ${{ inputs.target == 'small' }} + env: + HF_OIDC_RESOURCE: datasets/${{ env.HF_REPO }} + run: | + export PYTHONPATH=$(pwd) + python3 -c " + import json, hashlib + from pathlib import Path + from datetime import datetime, UTC + + dm_dir = Path('dm_output') + manifest = list(dm_dir.glob('*.chunkmanifest')) + assert manifest, 'No chunkmanifest found' + pkl_name = manifest[0].name.removesuffix('.chunkmanifest') + num_chunks = int(manifest[0].read_text().strip()) + + chunks = [] + for i in range(num_chunks): + chunk = dm_dir / f'{pkl_name}.chunk{i+1:02d}of{num_chunks:02d}' + chunks.append({ + 'file_name': chunk.name, + 'sha256': hashlib.sha256(chunk.read_bytes()).hexdigest() + }) + + digest = hashlib.sha256() + for c in chunks: + with open(dm_dir / c['file_name'], 'rb') as f: + while block := f.read(1024*1024): + digest.update(block) + + metadata = { + 'bundles': [{ + 'short_name': 'DMMODEL', + 'display_name': 'dmonitoring_model', + 'ref': '${{ needs.resolve.outputs.dm_onnx_ref }}', + 'runner': 'tinygrad', + 'build_time': datetime.now(UTC).strftime('%Y-%m-%dT%H:%M:%SZ'), + 'models': [{ + 'type': 'chunked', + 'artifact': { + 'file_name': pkl_name, + 'download_uri': {'url': '', 'sha256': digest.hexdigest()}, + 'chunks': chunks + } + }] + }] + } + with open(dm_dir / 'metadata.json', 'w') as f: + json.dump(metadata, f, indent=2) + print('Generated DM metadata.json') + " + + python3 release/ci/upload_default_model.py \ + --hf-repo "${{ env.HF_REPO }}" \ + --hf-defaults-path "${{ needs.resolve.outputs.hf_defaults_path }}" \ + --artifact-name "dm-model-${{ github.run_number }}" \ + --model-dir dm_output \ + --onnx-path "${{ env.DM_ONNX }}" \ + --onnx-ref "${{ needs.resolve.outputs.dm_onnx_ref }}" \ + --model-name "dmonitoring_model" \ + --tinygrad-ref "${{ needs.resolve.outputs.tinygrad_ref }}" \ + --run-number "${{ github.run_number }}" + + build_dm_model: + needs: resolve + if: ${{ inputs.target == 'small' }} + runs-on: [self-hosted, tici] + env: + DM_ONNX: openpilot/selfdrive/modeld/models/dmonitoring_model.onnx + DM_PKL: openpilot/selfdrive/modeld/models/dmonitoring_model_tinygrad.pkl + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set environment variables + run: | + source /etc/profile + export UV_PROJECT_ENVIRONMENT=${HOME}/venv + export UV_PYTHON_PREFERENCE=managed + export UV_PYTHON_INSTALL_DIR=${HOME}/uv/python + export VIRTUAL_ENV=$UV_PROJECT_ENVIRONMENT + uv sync --frozen + printenv >> $GITHUB_ENV + + - name: Disable powersave + run: | + source ${UV_PROJECT_ENVIRONMENT}/bin/activate + PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --disable + + - name: Compile DM model + run: | + source ${UV_PROJECT_ENVIRONMENT}/bin/activate + export PYTHONPATH="${PYTHONPATH}:${{ github.workspace }}/tinygrad_repo:${{ github.workspace }}" + + TG_FLAGS="DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1" + + taskset -c 7 env ${TG_FLAGS} python3 \ + ${{ github.workspace }}/tinygrad_repo/examples/openpilot/compile3.py \ + ${{ github.workspace }}/${{ env.DM_ONNX }} \ + ${{ github.workspace }}/${{ env.DM_PKL }} + + - name: Chunk DM pkl + run: | + source ${UV_PROJECT_ENVIRONMENT}/bin/activate + export PYTHONPATH=${{ github.workspace }} + python3 -c " + from openpilot.common.file_chunker import chunk_file, get_chunk_targets + import os + pkl = '${{ github.workspace }}/${{ env.DM_PKL }}' + size = os.path.getsize(pkl) + targets = get_chunk_targets(pkl, size) + chunk_file(pkl, targets) + print(f'Chunked {pkl} into {len(targets)} chunks') + " + + - name: Prepare DM output + run: | + mkdir -p dm_output + cp ${{ github.workspace }}/${{ env.DM_PKL }}.chunk* dm_output/ + cp ${{ github.workspace }}/${{ env.DM_PKL }}.chunkmanifest dm_output/ + + - name: Upload DM artifact + uses: actions/upload-artifact@v4 + with: + name: dm-model-${{ github.run_number }} + path: dm_output/ + + - name: Re-enable powersave + if: always() + run: | + source ${UV_PROJECT_ENVIRONMENT}/bin/activate + PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --enable + diff --git a/.github/workflows/sunnypilot-build-prebuilt.yaml b/.github/workflows/sunnypilot-build-prebuilt.yaml index 84fe6b3cc1..c7232505ee 100644 --- a/.github/workflows/sunnypilot-build-prebuilt.yaml +++ b/.github/workflows/sunnypilot-build-prebuilt.yaml @@ -242,14 +242,14 @@ jobs: echo "HF defaults match repo ONNX" else echo "No matching model on HF — triggering build" - gh workflow run build-default-big-model.yaml --ref "${{ github.head_ref || github.ref_name }}" + gh workflow run build-default-models.yaml --ref "${{ github.head_ref || github.ref_name }}" -f target=big echo "Waiting for build to start..." sleep 120 - RUN_ID=$(gh run list --workflow=build-default-big-model.yaml --branch="${{ github.head_ref || github.ref_name }}" --limit=1 --json databaseId --jq '.[0].databaseId') + RUN_ID=$(gh run list --workflow=build-default-models.yaml --branch="${{ github.head_ref || github.ref_name }}" --limit=1 --json databaseId --jq '.[0].databaseId') if [ -z "$RUN_ID" ] || [ "$RUN_ID" = "null" ]; then - echo "::error::Failed to find build-default-big-model run" + echo "::error::Failed to find build-default-models run" exit 1 fi @@ -258,7 +258,7 @@ jobs: CONCLUSION=$(gh run view "$RUN_ID" --json conclusion --jq '.conclusion') if [ "$CONCLUSION" != "success" ]; then - echo "::error::build-default-big-model failed: $CONCLUSION" + echo "::error::build-default-models failed: $CONCLUSION" exit 1 fi From 2bcfed5c7120763c76f6824d0ed0d5f6423c5ed9 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Mon, 24 Aug 2026 15:37:46 -0400 Subject: [PATCH 03/67] ci: compile default models with stock modeld (#1954) * ci: compile default big model with stock modeld * Revert "Revert big RL model (#38627)" This reverts commit 516ec1e68203439a73f340f1d0b3b91eabc626ee. * ci: compile default small model with stock modeld compiler * Reapply "Revert big RL model (#38627)" This reverts commit d06cfabb625e16bfd8f74984b359162be4b71417. --- .github/workflows/build-default-models.yaml | 237 +++++++++++++++++++- 1 file changed, 228 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build-default-models.yaml b/.github/workflows/build-default-models.yaml index 55efda4e4d..00a2df0efe 100644 --- a/.github/workflows/build-default-models.yaml +++ b/.github/workflows/build-default-models.yaml @@ -72,18 +72,237 @@ jobs: echo "tinygrad_ref=${TINYGRAD_REF}" >> $GITHUB_OUTPUT echo "dm_onnx_ref=${DM_ONNX_REF}" >> $GITHUB_OUTPUT - build_driving_model: + build_small_model: needs: resolve - uses: ./.github/workflows/sunnypilot-build-model.yaml - with: - upstream_branch: ${{ needs.resolve.outputs.onnx_ref }} - custom_name: ${{ needs.resolve.outputs.model_name }} - target_hardware: ${{ needs.resolve.outputs.target_hardware }} - secrets: inherit + if: ${{ inputs.target == 'small' }} + runs-on: [self-hosted, tici] + env: + SMALL_ONNX: openpilot/selfdrive/modeld/models/driving_supercombo.onnx + SMALL_PKL: openpilot/selfdrive/modeld/models/driving_tinygrad.pkl + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Pull ONNX via LFS + run: git lfs pull -I "${{ env.SMALL_ONNX }}" + + - name: Set environment variables + run: | + source /etc/profile + export UV_PROJECT_ENVIRONMENT=${HOME}/venv + export UV_PYTHON_PREFERENCE=managed + export UV_PYTHON_INSTALL_DIR=${HOME}/uv/python + export VIRTUAL_ENV=$UV_PROJECT_ENVIRONMENT + uv sync --frozen + printenv >> $GITHUB_ENV + + - name: Disable powersave + run: | + source ${UV_PROJECT_ENVIRONMENT}/bin/activate + PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --disable + + - name: Compile small model with stock compiler + run: | + source ${UV_PROJECT_ENVIRONMENT}/bin/activate + export PYTHONPATH="${PYTHONPATH}:${{ github.workspace }}/tinygrad_repo:${{ github.workspace }}" + + MODEL_SIZE=$(python3 -c "from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE as s; print(f'{s[0]}x{s[1]}')") + CAMERA_RES=$(python3 -c "from openpilot.common.transformations.camera import _ar_ox_fisheye as a, _os_fisheye as o; print(f'{a.width}x{a.height} {o.width}x{o.height}')") + FRAME_SKIP=$(python3 -c "from openpilot.selfdrive.modeld.constants import ModelConstants as MC; print(MC.MODEL_RUN_FREQ // MC.MODEL_CONTEXT_FREQ)") + + TG_FLAGS="DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1" + + env ${TG_FLAGS} python3 \ + ${{ github.workspace }}/openpilot/selfdrive/modeld/compile_modeld.py \ + --onnx ${{ github.workspace }}/${{ env.SMALL_ONNX }} \ + --model-size $MODEL_SIZE \ + --camera-resolutions $CAMERA_RES \ + --frame-skip $FRAME_SKIP \ + --output ${{ github.workspace }}/${{ env.SMALL_PKL }} + + - name: Chunk small pkl + run: | + source ${UV_PROJECT_ENVIRONMENT}/bin/activate + export PYTHONPATH=${{ github.workspace }} + python3 -c " + from openpilot.common.file_chunker import chunk_file, get_chunk_targets + import os + pkl = '${{ github.workspace }}/${{ env.SMALL_PKL }}' + size = os.path.getsize(pkl) + targets = get_chunk_targets(pkl, size) + chunk_file(pkl, targets) + print(f'Chunked into {len(targets)} files') + " + + - name: Prepare output + env: + MODEL_NAME: ${{ needs.resolve.outputs.model_name }} + run: | + source ${UV_PROJECT_ENVIRONMENT}/bin/activate + export PYTHONPATH=${{ github.workspace }} + MODELS_DIR="${{ github.workspace }}/openpilot/selfdrive/modeld/models" + OUTPUT_DIR="${{ github.workspace }}/small_output" + PKL_BASE="driving_tinygrad.pkl" + mkdir -p "$OUTPUT_DIR" + + cp "$MODELS_DIR/${PKL_BASE}".chunk* "$OUTPUT_DIR/" + cp "$MODELS_DIR/${PKL_BASE}.chunkmanifest" "$OUTPUT_DIR/" + + python3 "${{ github.workspace }}/release/ci/model_generator.py" \ + --model-dir "$MODELS_DIR" \ + --output-dir "$OUTPUT_DIR" \ + --custom-name "$MODEL_NAME" \ + --upstream-branch "${{ needs.resolve.outputs.onnx_ref }}" + + echo "model-${MODEL_NAME}-${{ github.run_number }}" > "$OUTPUT_DIR/artifact_name.txt" + + - name: Upload small model artifact + uses: actions/upload-artifact@v4 + with: + name: model-${{ needs.resolve.outputs.model_name }}-${{ github.run_number }} + path: ${{ github.workspace }}/small_output/ + + - name: Upload artifact name file + uses: actions/upload-artifact@v4 + with: + name: artifact-name-${{ needs.resolve.outputs.model_name }} + path: ${{ github.workspace }}/small_output/artifact_name.txt + + - name: Re-enable powersave + if: always() + run: | + source ${UV_PROJECT_ENVIRONMENT}/bin/activate + PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --enable + + build_big_model: + needs: resolve + if: ${{ inputs.target == 'big' }} + runs-on: [self-hosted, usbgpu] + env: + BIG_ONNX: openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx + BIG_PKL: openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Pull big ONNX via LFS + run: git lfs pull -I "${{ env.BIG_ONNX }}" + + - name: Set environment variables + run: | + source /etc/profile + export UV_PROJECT_ENVIRONMENT=${HOME}/venv + export UV_PYTHON_PREFERENCE=managed + export UV_PYTHON_INSTALL_DIR=${HOME}/uv/python + export VIRTUAL_ENV=$UV_PROJECT_ENVIRONMENT + uv sync --frozen + printenv >> $GITHUB_ENV + + - name: Disable powersave + run: | + source ${UV_PROJECT_ENVIRONMENT}/bin/activate + PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --disable + + - name: Wait for chestnut PCIe link + run: | + source ${UV_PROJECT_ENVIRONMENT}/bin/activate + export PYTHONPATH="${PYTHONPATH}:${{ github.workspace }}/tinygrad_repo:${{ github.workspace }}" + python3 -c " + import time + from openpilot.system.hardware.chestnut.flash import link_up + for i in range(10): + if link_up(): + print(f'PCIe link up after {i+1} attempt(s)') + break + time.sleep(1) + else: + raise RuntimeError('Chestnut PCIe link not ready after 10 attempts') + " + + - name: Compile big model with stock compiler + run: | + source ${UV_PROJECT_ENVIRONMENT}/bin/activate + export PYTHONPATH="${PYTHONPATH}:${{ github.workspace }}/tinygrad_repo:${{ github.workspace }}" + + MODEL_SIZE=$(python3 -c "from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE as s; print(f'{s[0]}x{s[1]}')") + CAMERA_RES=$(python3 -c "from openpilot.common.transformations.camera import _ar_ox_fisheye as a, _os_fisheye as o; print(f'{a.width}x{a.height} {o.width}x{o.height}')") + FRAME_SKIP=$(python3 -c "from openpilot.selfdrive.modeld.constants import ModelConstants as MC; print(MC.MODEL_RUN_FREQ // MC.MODEL_CONTEXT_FREQ)") + + TG_FLAGS="DEBUG=2 DEV=USB+AMD:LLVM WARP_DEV=QCOM FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2" + + env ${TG_FLAGS} python3 \ + ${{ github.workspace }}/openpilot/selfdrive/modeld/compile_modeld.py \ + --onnx ${{ github.workspace }}/${{ env.BIG_ONNX }} \ + --model-size $MODEL_SIZE \ + --camera-resolutions $CAMERA_RES \ + --frame-skip $FRAME_SKIP \ + --output ${{ github.workspace }}/${{ env.BIG_PKL }} + + - name: Chunk big pkl + run: | + source ${UV_PROJECT_ENVIRONMENT}/bin/activate + export PYTHONPATH=${{ github.workspace }} + python3 -c " + from openpilot.common.file_chunker import chunk_file, get_chunk_targets + import os + pkl = '${{ github.workspace }}/${{ env.BIG_PKL }}' + size = os.path.getsize(pkl) + targets = get_chunk_targets(pkl, size) + chunk_file(pkl, targets) + print(f'Chunked into {len(targets)} files') + " + + - name: Prepare output + env: + MODEL_NAME: ${{ needs.resolve.outputs.model_name }} + run: | + source ${UV_PROJECT_ENVIRONMENT}/bin/activate + export PYTHONPATH=${{ github.workspace }} + MODELS_DIR="${{ github.workspace }}/openpilot/selfdrive/modeld/models" + OUTPUT_DIR="${{ github.workspace }}/big_output" + PKL_BASE="big_driving_tinygrad.pkl" + mkdir -p "$OUTPUT_DIR" + + cp "$MODELS_DIR/${PKL_BASE}".chunk* "$OUTPUT_DIR/" + cp "$MODELS_DIR/${PKL_BASE}.chunkmanifest" "$OUTPUT_DIR/" + + python3 "${{ github.workspace }}/release/ci/model_generator.py" \ + --model-dir "$MODELS_DIR" \ + --output-dir "$OUTPUT_DIR" \ + --custom-name "$MODEL_NAME" \ + --upstream-branch "${{ needs.resolve.outputs.onnx_ref }}" + + echo "model-${MODEL_NAME}-${{ github.run_number }}" > "$OUTPUT_DIR/artifact_name.txt" + + - name: Upload big model artifact + uses: actions/upload-artifact@v4 + with: + name: model-${{ needs.resolve.outputs.model_name }}-${{ github.run_number }} + path: ${{ github.workspace }}/big_output/ + + - name: Upload artifact name file + uses: actions/upload-artifact@v4 + with: + name: artifact-name-${{ needs.resolve.outputs.model_name }} + path: ${{ github.workspace }}/big_output/artifact_name.txt + + - name: Re-enable powersave + if: always() + run: | + source ${UV_PROJECT_ENVIRONMENT}/bin/activate + PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --enable upload_defaults: - needs: [ resolve, build_driving_model, build_dm_model ] - if: ${{ !cancelled() && needs.build_driving_model.result == 'success' && (inputs.target != 'small' || needs.build_dm_model.result == 'success') }} + needs: [ resolve, build_small_model, build_big_model, build_dm_model ] + if: | + ${{ + !cancelled() && + (inputs.target == 'big' && needs.build_big_model.result == 'success' || + inputs.target == 'small' && needs.build_small_model.result == 'success') && + (inputs.target != 'small' || needs.build_dm_model.result == 'success') + }} runs-on: ubuntu-24.04 permissions: id-token: write From 8e16c9babb96661eca53766d89e47f0e75d2fe08 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Mon, 24 Aug 2026 16:24:19 -0400 Subject: [PATCH 04/67] ci: offload small model compilation (#1952) * ci: compile default big model with stock modeld * Revert "Revert big RL model (#38627)" This reverts commit 516ec1e68203439a73f340f1d0b3b91eabc626ee. * ci: compile default small model with stock modeld compiler * ci: offload small model compilation * Reapply "Revert big RL model (#38627)" This reverts commit d06cfabb625e16bfd8f74984b359162be4b71417. * Reapply "Revert big RL model (#38627)" This reverts commit d06cfabb625e16bfd8f74984b359162be4b71417. --- .github/workflows/build-default-models.yaml | 8 +- .../workflows/sunnypilot-build-prebuilt.yaml | 127 +++++++++++++++++- openpilot/selfdrive/modeld/SConscript | 80 +++++------ 3 files changed, 168 insertions(+), 47 deletions(-) diff --git a/.github/workflows/build-default-models.yaml b/.github/workflows/build-default-models.yaml index 00a2df0efe..996e89fc3e 100644 --- a/.github/workflows/build-default-models.yaml +++ b/.github/workflows/build-default-models.yaml @@ -31,6 +31,7 @@ jobs: target_hardware: ${{ steps.resolve.outputs.target_hardware }} tinygrad_ref: ${{ steps.resolve.outputs.tinygrad_ref }} dm_onnx_ref: ${{ steps.resolve.outputs.dm_onnx_ref }} + dm_onnx_date: ${{ steps.resolve.outputs.dm_onnx_date }} steps: - uses: actions/checkout@v4 with: @@ -60,8 +61,10 @@ jobs: fi DM_ONNX_REF="" + DM_ONNX_DATE="" if [ "${{ inputs.target }}" = "small" ]; then DM_ONNX_REF=$(git log -1 --format='%H' -- openpilot/selfdrive/modeld/models/dmonitoring_model.onnx) + DM_ONNX_DATE=$(git log -1 --format=%cd --date=format:'%B %d, %Y' -- openpilot/selfdrive/modeld/models/dmonitoring_model.onnx) fi echo "model_name=${NAME}" >> $GITHUB_OUTPUT @@ -71,6 +74,7 @@ jobs: echo "target_hardware=${TARGET_HW}" >> $GITHUB_OUTPUT echo "tinygrad_ref=${TINYGRAD_REF}" >> $GITHUB_OUTPUT echo "dm_onnx_ref=${DM_ONNX_REF}" >> $GITHUB_OUTPUT + echo "dm_onnx_date=${DM_ONNX_DATE}" >> $GITHUB_OUTPUT build_small_model: needs: resolve @@ -395,7 +399,7 @@ jobs: metadata = { 'bundles': [{ 'short_name': 'DMMODEL', - 'display_name': 'dmonitoring_model', + 'display_name': 'dmonitoring_model (${{ needs.resolve.outputs.dm_onnx_date }})', 'ref': '${{ needs.resolve.outputs.dm_onnx_ref }}', 'runner': 'tinygrad', 'build_time': datetime.now(UTC).strftime('%Y-%m-%dT%H:%M:%SZ'), @@ -421,7 +425,7 @@ jobs: --model-dir dm_output \ --onnx-path "${{ env.DM_ONNX }}" \ --onnx-ref "${{ needs.resolve.outputs.dm_onnx_ref }}" \ - --model-name "dmonitoring_model" \ + --model-name "dmonitoring_model (${{ needs.resolve.outputs.dm_onnx_date }})" \ --tinygrad-ref "${{ needs.resolve.outputs.tinygrad_ref }}" \ --run-number "${{ github.run_number }}" diff --git a/.github/workflows/sunnypilot-build-prebuilt.yaml b/.github/workflows/sunnypilot-build-prebuilt.yaml index c7232505ee..2f824be964 100644 --- a/.github/workflows/sunnypilot-build-prebuilt.yaml +++ b/.github/workflows/sunnypilot-build-prebuilt.yaml @@ -165,7 +165,7 @@ jobs: scons -j1 cache_dir="$SCONS_CACHE" --minimal \ openpilot/selfdrive/locationd openpilot/sunnypilot/selfdrive/locationd echo "Building rest of sunnypilot" - /usr/bin/time -v scons -j$(nproc) cache_dir="$SCONS_CACHE" --minimal + SKIP_TINYGRAD_COMPILE=1 /usr/bin/time -v scons -j$(nproc) cache_dir="$SCONS_CACHE" --minimal touch ${BUILD_DIR}/prebuilt if [[ "${{ runner.debug }}" == "1" ]]; then ls -la ${BUILD_DIR} @@ -242,12 +242,13 @@ jobs: echo "HF defaults match repo ONNX" else echo "No matching model on HF — triggering build" + TRIGGER_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) gh workflow run build-default-models.yaml --ref "${{ github.head_ref || github.ref_name }}" -f target=big echo "Waiting for build to start..." sleep 120 - RUN_ID=$(gh run list --workflow=build-default-models.yaml --branch="${{ github.head_ref || github.ref_name }}" --limit=1 --json databaseId --jq '.[0].databaseId') + RUN_ID=$(gh run list --workflow=build-default-models.yaml --branch="${{ github.head_ref || github.ref_name }}" --created=">$TRIGGER_TIME" --limit=1 --json databaseId --jq '.[0].databaseId') if [ -z "$RUN_ID" ] || [ "$RUN_ID" = "null" ]; then echo "::error::Failed to find build-default-models run" exit 1 @@ -276,21 +277,99 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + prepare_small_models: + needs: [ prepare_strategy ] + runs-on: ubuntu-24.04 + outputs: + driving_onnx_sha256: ${{ steps.resolve.outputs.driving_onnx_sha256 }} + dm_onnx_sha256: ${{ steps.resolve.outputs.dm_onnx_sha256 }} + env: + HF_REPO: sunnypilot/sunnypilot_models_v1 + HF_DEFAULTS_PATH: models/defaults/small + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref || github.ref_name }} + submodules: recursive + + - run: git lfs pull -I "openpilot/selfdrive/modeld/models/driving_supercombo.onnx,openpilot/selfdrive/modeld/models/dmonitoring_model.onnx" + + - name: Check HF defaults and build if needed + id: resolve + run: | + DRIVING_HASH=$(sha256sum "openpilot/selfdrive/modeld/models/driving_supercombo.onnx" | cut -d' ' -f1) + DM_HASH=$(sha256sum "openpilot/selfdrive/modeld/models/dmonitoring_model.onnx" | cut -d' ' -f1) + TINYGRAD_REF=$(PYTHONPATH=${{ github.workspace }} python3 openpilot/sunnypilot/models/tinygrad_ref.py) + echo "driving_onnx_sha256=$DRIVING_HASH" >> $GITHUB_OUTPUT + echo "dm_onnx_sha256=$DM_HASH" >> $GITHUB_OUTPUT + echo "Driving ONNX hash: $DRIVING_HASH" + echo "DM ONNX hash: $DM_HASH" + echo "tinygrad ref: $TINYGRAD_REF" + + JSON_URL="https://huggingface.co/datasets/${HF_REPO}/resolve/main/${HF_DEFAULTS_PATH}/default_models.json" + + check_defaults() { + DEFAULTS=$(curl -fsSL "$JSON_URL" 2>/dev/null) || return 1 + TINYGRAD_MATCH=$(echo "$DEFAULTS" | jq -r --arg ref "$TINYGRAD_REF" '.tinygrad_ref == $ref' 2>/dev/null) + [ "$TINYGRAD_MATCH" = "true" ] || return 1 + DRIVING=$(echo "$DEFAULTS" | jq --arg hash "$DRIVING_HASH" '.bundles[] | select(.onnx_sha256 == $hash)' 2>/dev/null) + [ -n "$DRIVING" ] && [ "$DRIVING" != "null" ] || return 1 + DM=$(echo "$DEFAULTS" | jq --arg hash "$DM_HASH" '.bundles[] | select(.onnx_sha256 == $hash)' 2>/dev/null) + [ -n "$DM" ] && [ "$DM" != "null" ] || return 1 + } + + if check_defaults; then + echo "HF defaults match repo ONNX hashes and tinygrad ref" + else + echo "No matching models on HF — triggering build" + TRIGGER_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) + gh workflow run build-default-models.yaml --ref "${{ github.head_ref || github.ref_name }}" -f target=small + + echo "Waiting for build to start..." + sleep 120 + + RUN_ID=$(gh run list --workflow=build-default-models.yaml --branch="${{ github.head_ref || github.ref_name }}" --created=">$TRIGGER_TIME" --limit=1 --json databaseId --jq '.[0].databaseId') + if [ -z "$RUN_ID" ] || [ "$RUN_ID" = "null" ]; then + echo "::error::Failed to find build-default-models run" + exit 1 + fi + + echo "Waiting for run $RUN_ID..." + gh run watch "$RUN_ID" + + CONCLUSION=$(gh run view "$RUN_ID" --json conclusion --jq '.conclusion') + if [ "$CONCLUSION" != "success" ]; then + echo "::error::build-default-models failed: $CONCLUSION" + exit 1 + fi + + if ! check_defaults; then + echo "::error::HF defaults still don't match after build" + exit 1 + fi + fi + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Cancel run on failure + if: failure() + run: gh run cancel ${{ github.run_id }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + publish: concurrency: - # We do a bit of a hack here to avoid canceling the publishing job if a new commit comes in while we're publishing by adding the sha to the group name. - # This means that if multiple commits come in while we're publishing, they will be queued up and publish one after the other. - # Otherwise, if a job is waiting to be published due to environment wait time, it would be canceled by a new commit and restart the wait time. group: ${{ needs.prepare_strategy.outputs.publish_concurrency_group }} cancel-in-progress: ${{ needs.prepare_strategy.outputs.cancel_publish_in_progress == 'true' }} if: ${{ always() && !cancelled() && needs.build.result == 'success' && needs.prepare_strategy.result == 'success' && + needs.prepare_small_models.result == 'success' && (!contains(github.event_name, 'pull_request') || (github.event.action == 'labeled' && github.event.label.name == 'prebuilt')) && (needs.prepare_strategy.outputs.include_big_model != 'true' || needs.prepare_chestnut.result == 'success') }} - needs: [ build, prepare_strategy, prepare_chestnut ] + needs: [ build, prepare_strategy, prepare_chestnut, prepare_small_models ] runs-on: ubuntu-24.04 environment: ${{ needs.prepare_strategy.outputs.environment }} steps: @@ -306,6 +385,41 @@ jobs: mkdir -p ${{ env.OUTPUT_DIR }} tar xzf prebuilt.tar.gz -C ${{ env.OUTPUT_DIR }} + - name: Download small model chunks from HF + env: + HF_REPO: sunnypilot/sunnypilot_models_v1 + HF_DEFAULTS_PATH: models/defaults/small + run: | + set -o pipefail + JSON_URL="https://huggingface.co/datasets/${HF_REPO}/resolve/main/${HF_DEFAULTS_PATH}/default_models.json" + DEFAULTS=$(curl -fsSL "$JSON_URL") + MODELS_DIR="${{ env.OUTPUT_DIR }}/openpilot/selfdrive/modeld/models" + + download_model_chunks() { + local ONNX_HASH="$1" + local CANONICAL="$2" + BUNDLE=$(echo "$DEFAULTS" | jq --arg hash "$ONNX_HASH" '.bundles[] | select(.onnx_sha256 == $hash)') + ARTIFACT=$(echo "$BUNDLE" | jq -r '.models[0].artifact') + BASE_URL=$(echo "$ARTIFACT" | jq -r '.download_uri.url' | sed 's|/[^/]*$||') + NUM_CHUNKS=$(echo "$ARTIFACT" | jq -r '.chunks | length') + + echo "$ARTIFACT" | jq -r '.chunks[].file_name' | while read CHUNK_NAME; do + CHUNK_IDX=$(echo "$CHUNK_NAME" | grep -oP 'chunk\K[0-9]+of[0-9]+' || true) + if [ -z "$CHUNK_IDX" ]; then + echo "::error::Failed to parse chunk index from: $CHUNK_NAME" + exit 1 + fi + CANONICAL_CHUNK="${CANONICAL}.chunk${CHUNK_IDX}" + ENCODED_URL=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${BASE_URL}/${CHUNK_NAME}', safe=':/'))") + echo "Downloading $CHUNK_NAME -> $CANONICAL_CHUNK" + curl -fsSL -o "${MODELS_DIR}/${CANONICAL_CHUNK}" "$ENCODED_URL" + done + echo "$NUM_CHUNKS" > "${MODELS_DIR}/${CANONICAL}.chunkmanifest" + } + + download_model_chunks "${{ needs.prepare_small_models.outputs.driving_onnx_sha256 }}" "driving_tinygrad.pkl" + download_model_chunks "${{ needs.prepare_small_models.outputs.dm_onnx_sha256 }}" "dmonitoring_model_tinygrad.pkl" + - name: Prepare chestnut output if: ${{ needs.prepare_chestnut.result == 'success' }} run: | @@ -393,6 +507,7 @@ jobs: - build - publish - prepare_chestnut + - prepare_small_models runs-on: ubuntu-24.04 if: ${{ (always() && !cancelled() && !failure()) && needs.publish.result == 'success' diff --git a/openpilot/selfdrive/modeld/SConscript b/openpilot/selfdrive/modeld/SConscript index 30a31aae27..19ce7d5e00 100644 --- a/openpilot/selfdrive/modeld/SConscript +++ b/openpilot/selfdrive/modeld/SConscript @@ -73,44 +73,45 @@ compile_modeld_script = [ model_w, model_h = MEDMODEL_INPUT_SIZE frame_skip = ModelConstants.MODEL_RUN_FREQ // ModelConstants.MODEL_CONTEXT_FREQ -for usbgpu in [False, True] if USBGPU else [False]: - target_pkl_path = File(modeld_pkl_path(usbgpu)).abspath - # BIG_INTO_SMALL=1 builds the default target from the big model, e.g. to test it without a USB GPU - file_prefix, cmd_flags = ('big_', usbgpu_tg_flags) if usbgpu else ('big_' if os.getenv('BIG_INTO_SMALL') else '', tg_flags) - driving_onnx_deps = get_existing_chunks(File(f"models/{file_prefix}driving_supercombo.onnx").abspath) - camera_res_args = ' '.join(f'{cw}x{ch}' for cw, ch in CAMERA_CONFIGS) - # CPU 7 is isolated with isolcpus on AGNOS, so explicitly pin the compiler to it. - taskset = 'taskset -c 7 ' if arch == 'comma_arm64' else '' - cmd = (f'{cmd_flags} {mac_brew_string} {taskset}python3 {modeld_dir}/compile_modeld.py ' - f'--model-size {model_w}x{model_h} ' - f'--camera-resolutions {camera_res_args} ' - f'--onnx {File(f"models/{file_prefix}driving_supercombo.onnx").abspath} ' - f'--output {target_pkl_path} --frame-skip {frame_skip}') - onnx_sizes_sum = sum(os.path.getsize(f) for f in driving_onnx_deps) - chunk_targets = get_chunk_targets(target_pkl_path, estimate_pickle_max_size(onnx_sizes_sum)) - def do_compile(target, source, env, command=cmd, pkl=target_pkl_path, chunks=chunk_targets): - from openpilot.system.hardware.chestnut.flash import link_up - # chestnut can enumerate before its PCIe link is up due to varying 12V power behavior across cars - for _ in range(10): - if link_up(): - break - time.sleep(1) - else: - print("Chestnut not ready, skipping big model build") - return - if ret := env.Execute(command): - return ret - chunk_file(pkl, chunks) - def do_chunk(target, source, env, pkl=target_pkl_path, chunks=chunk_targets): - chunk_file(pkl, chunks) - actions = Action(do_compile, " [USBGPU] $TARGET") if usbgpu else [cmd, Action(do_chunk, " [CHUNK] $TARGET")] - node = lenv.Command( - chunk_targets, - tinygrad_files + compile_modeld_script + driving_onnx_deps + [Value(chunk_targets), chunker_file], - actions, - ) - if usbgpu: - lenv.SideEffect(usbgpu_lock, node) +if not os.getenv('SKIP_TINYGRAD_COMPILE'): + for usbgpu in [False, True] if USBGPU else [False]: + target_pkl_path = File(modeld_pkl_path(usbgpu)).abspath + # BIG_INTO_SMALL=1 builds the default target from the big model, e.g. to test it without a USB GPU + file_prefix, cmd_flags = ('big_', usbgpu_tg_flags) if usbgpu else ('big_' if os.getenv('BIG_INTO_SMALL') else '', tg_flags) + driving_onnx_deps = get_existing_chunks(File(f"models/{file_prefix}driving_supercombo.onnx").abspath) + camera_res_args = ' '.join(f'{cw}x{ch}' for cw, ch in CAMERA_CONFIGS) + # CPU 7 is isolated with isolcpus on AGNOS, so explicitly pin the compiler to it. + taskset = 'taskset -c 7 ' if arch == 'comma_arm64' else '' + cmd = (f'{cmd_flags} {mac_brew_string} {taskset}python3 {modeld_dir}/compile_modeld.py ' + f'--model-size {model_w}x{model_h} ' + f'--camera-resolutions {camera_res_args} ' + f'--onnx {File(f"models/{file_prefix}driving_supercombo.onnx").abspath} ' + f'--output {target_pkl_path} --frame-skip {frame_skip}') + onnx_sizes_sum = sum(os.path.getsize(f) for f in driving_onnx_deps) + chunk_targets = get_chunk_targets(target_pkl_path, estimate_pickle_max_size(onnx_sizes_sum)) + def do_compile(target, source, env, command=cmd, pkl=target_pkl_path, chunks=chunk_targets): + from openpilot.system.hardware.chestnut.flash import link_up + # chestnut can enumerate before its PCIe link is up due to varying 12V power behavior across cars + for _ in range(10): + if link_up(): + break + time.sleep(1) + else: + print("Chestnut not ready, skipping big model build") + return + if ret := env.Execute(command): + return ret + chunk_file(pkl, chunks) + def do_chunk(target, source, env, pkl=target_pkl_path, chunks=chunk_targets): + chunk_file(pkl, chunks) + actions = Action(do_compile, " [USBGPU] $TARGET") if usbgpu else [cmd, Action(do_chunk, " [CHUNK] $TARGET")] + node = lenv.Command( + chunk_targets, + tinygrad_files + compile_modeld_script + driving_onnx_deps + [Value(chunk_targets), chunker_file], + actions, + ) + if usbgpu: + lenv.SideEffect(usbgpu_lock, node) # get model metadata fn = File(f"models/dmonitoring_model").abspath @@ -142,4 +143,5 @@ def tg_compile(flags, model_name): Action(do_chunk, " [CHUNK] $TARGET")], ) -tg_compile(tg_flags, 'dmonitoring_model') +if not os.getenv('SKIP_TINYGRAD_COMPILE'): + tg_compile(tg_flags, 'dmonitoring_model') From 6cc5f3aad890527bee2ca85d71a43c205a69a4dc Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Mon, 24 Aug 2026 20:02:48 -0400 Subject: [PATCH 05/67] ci: fix DM model build, separate HF defaults paths, nuke build races (#1956) * ci: fix DM model build, separate HF defaults paths, nuke build races * more split! * name * ci: download driving and DM model chunks into chestnut prebuilt output --- .github/workflows/build-default-models.yaml | 65 +++--- .../workflows/sunnypilot-build-prebuilt.yaml | 219 ++++++++++++------ 2 files changed, 177 insertions(+), 107 deletions(-) diff --git a/.github/workflows/build-default-models.yaml b/.github/workflows/build-default-models.yaml index 996e89fc3e..bf00845e27 100644 --- a/.github/workflows/build-default-models.yaml +++ b/.github/workflows/build-default-models.yaml @@ -10,13 +10,18 @@ on: options: - small - big + - dm workflow_call: inputs: target: - description: 'Model target to build (small or big)' + description: 'Model target to build (small, big, or dm)' required: true type: string +concurrency: + group: build-default-models-${{ inputs.target }} + cancel-in-progress: false + env: HF_REPO: sunnypilot/sunnypilot_models_v1 @@ -28,10 +33,7 @@ jobs: onnx_ref: ${{ steps.resolve.outputs.onnx_ref }} onnx_path: ${{ steps.resolve.outputs.onnx_path }} hf_defaults_path: ${{ steps.resolve.outputs.hf_defaults_path }} - target_hardware: ${{ steps.resolve.outputs.target_hardware }} tinygrad_ref: ${{ steps.resolve.outputs.tinygrad_ref }} - dm_onnx_ref: ${{ steps.resolve.outputs.dm_onnx_ref }} - dm_onnx_date: ${{ steps.resolve.outputs.dm_onnx_date }} steps: - uses: actions/checkout@v4 with: @@ -45,12 +47,14 @@ jobs: NAME=$(python3 -c "from openpilot.sunnypilot.models.model_name import DEFAULT_BIG_MODEL; print(DEFAULT_BIG_MODEL)") ONNX_PATH="openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx" HF_DEFAULTS_PATH="models/defaults/big" - TARGET_HW="usbgpu" + elif [ "${{ inputs.target }}" = "dm" ]; then + ONNX_PATH="openpilot/selfdrive/modeld/models/dmonitoring_model.onnx" + HF_DEFAULTS_PATH="models/defaults/dm" + NAME="dmonitoring_model ($(git log -1 --format=%cd --date=format:'%B %d, %Y' -- "$ONNX_PATH"))" else NAME=$(python3 -c "from openpilot.sunnypilot.models.model_name import DEFAULT_MODEL; print(DEFAULT_MODEL)") ONNX_PATH="openpilot/selfdrive/modeld/models/driving_supercombo.onnx" HF_DEFAULTS_PATH="models/defaults/small" - TARGET_HW="qcom" fi ONNX_REF=$(git log -1 --format='%H' -- "$ONNX_PATH") @@ -60,21 +64,11 @@ jobs: exit 1 fi - DM_ONNX_REF="" - DM_ONNX_DATE="" - if [ "${{ inputs.target }}" = "small" ]; then - DM_ONNX_REF=$(git log -1 --format='%H' -- openpilot/selfdrive/modeld/models/dmonitoring_model.onnx) - DM_ONNX_DATE=$(git log -1 --format=%cd --date=format:'%B %d, %Y' -- openpilot/selfdrive/modeld/models/dmonitoring_model.onnx) - fi - echo "model_name=${NAME}" >> $GITHUB_OUTPUT echo "onnx_ref=${ONNX_REF}" >> $GITHUB_OUTPUT echo "onnx_path=${ONNX_PATH}" >> $GITHUB_OUTPUT echo "hf_defaults_path=${HF_DEFAULTS_PATH}" >> $GITHUB_OUTPUT - echo "target_hardware=${TARGET_HW}" >> $GITHUB_OUTPUT echo "tinygrad_ref=${TINYGRAD_REF}" >> $GITHUB_OUTPUT - echo "dm_onnx_ref=${DM_ONNX_REF}" >> $GITHUB_OUTPUT - echo "dm_onnx_date=${DM_ONNX_DATE}" >> $GITHUB_OUTPUT build_small_model: needs: resolve @@ -304,43 +298,45 @@ jobs: ${{ !cancelled() && (inputs.target == 'big' && needs.build_big_model.result == 'success' || - inputs.target == 'small' && needs.build_small_model.result == 'success') && - (inputs.target != 'small' || needs.build_dm_model.result == 'success') + inputs.target == 'small' && needs.build_small_model.result == 'success' || + inputs.target == 'dm' && needs.build_dm_model.result == 'success') }} runs-on: ubuntu-24.04 permissions: id-token: write contents: write - env: - DM_ONNX: openpilot/selfdrive/modeld/models/dmonitoring_model.onnx steps: - uses: actions/checkout@v4 - name: Pull ONNX via LFS - run: git lfs pull -I "${{ needs.resolve.outputs.onnx_path }}${{ inputs.target == 'small' && ',openpilot/selfdrive/modeld/models/dmonitoring_model.onnx' || '' }}" + run: git lfs pull -I "${{ needs.resolve.outputs.onnx_path }}" - name: Install huggingface_hub run: pip install --upgrade "huggingface_hub>=0.22.0" - - name: Download driving artifact name + - name: Download artifact name + if: ${{ inputs.target == 'small' || inputs.target == 'big' }} uses: actions/download-artifact@v4 with: name: artifact-name-${{ needs.resolve.outputs.model_name }} path: artifact_name - - name: Read driving artifact name + - name: Read artifact name + if: ${{ inputs.target == 'small' || inputs.target == 'big' }} id: artifact run: | ARTIFACT_NAME=$(cat artifact_name/artifact_name.txt) echo "artifact_name=$ARTIFACT_NAME" >> $GITHUB_OUTPUT - - name: Download driving model artifact + - name: Download model artifact + if: ${{ inputs.target == 'small' || inputs.target == 'big' }} uses: actions/download-artifact@v4 with: name: ${{ steps.artifact.outputs.artifact_name }} path: output - - name: Upload driving model to HF + - name: Upload model to HF + if: ${{ inputs.target == 'small' || inputs.target == 'big' }} env: HF_OIDC_RESOURCE: datasets/${{ env.HF_REPO }} ARTIFACT_NAME: ${{ steps.artifact.outputs.artifact_name }} @@ -359,14 +355,14 @@ jobs: --run-number "${{ github.run_number }}" - name: Download DM artifact - if: ${{ inputs.target == 'small' }} + if: ${{ inputs.target == 'dm' }} uses: actions/download-artifact@v4 with: name: dm-model-${{ github.run_number }} path: dm_output - name: Generate DM metadata and upload to HF - if: ${{ inputs.target == 'small' }} + if: ${{ inputs.target == 'dm' }} env: HF_OIDC_RESOURCE: datasets/${{ env.HF_REPO }} run: | @@ -399,8 +395,8 @@ jobs: metadata = { 'bundles': [{ 'short_name': 'DMMODEL', - 'display_name': 'dmonitoring_model (${{ needs.resolve.outputs.dm_onnx_date }})', - 'ref': '${{ needs.resolve.outputs.dm_onnx_ref }}', + 'display_name': '${{ needs.resolve.outputs.model_name }}', + 'ref': '${{ needs.resolve.outputs.onnx_ref }}', 'runner': 'tinygrad', 'build_time': datetime.now(UTC).strftime('%Y-%m-%dT%H:%M:%SZ'), 'models': [{ @@ -423,15 +419,15 @@ jobs: --hf-defaults-path "${{ needs.resolve.outputs.hf_defaults_path }}" \ --artifact-name "dm-model-${{ github.run_number }}" \ --model-dir dm_output \ - --onnx-path "${{ env.DM_ONNX }}" \ - --onnx-ref "${{ needs.resolve.outputs.dm_onnx_ref }}" \ - --model-name "dmonitoring_model (${{ needs.resolve.outputs.dm_onnx_date }})" \ + --onnx-path "${{ needs.resolve.outputs.onnx_path }}" \ + --onnx-ref "${{ needs.resolve.outputs.onnx_ref }}" \ + --model-name "${{ needs.resolve.outputs.model_name }}" \ --tinygrad-ref "${{ needs.resolve.outputs.tinygrad_ref }}" \ --run-number "${{ github.run_number }}" build_dm_model: needs: resolve - if: ${{ inputs.target == 'small' }} + if: ${{ inputs.target == 'dm' }} runs-on: [self-hosted, tici] env: DM_ONNX: openpilot/selfdrive/modeld/models/dmonitoring_model.onnx @@ -441,6 +437,9 @@ jobs: with: submodules: recursive + - name: Pull DM ONNX via LFS + run: git lfs pull -I "${{ env.DM_ONNX }}" + - name: Set environment variables run: | source /etc/profile diff --git a/.github/workflows/sunnypilot-build-prebuilt.yaml b/.github/workflows/sunnypilot-build-prebuilt.yaml index 2f824be964..8c954a7273 100644 --- a/.github/workflows/sunnypilot-build-prebuilt.yaml +++ b/.github/workflows/sunnypilot-build-prebuilt.yaml @@ -240,34 +240,24 @@ jobs: if check_hash; then echo "HF defaults match repo ONNX" - else - echo "No matching model on HF — triggering build" - TRIGGER_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) - gh workflow run build-default-models.yaml --ref "${{ github.head_ref || github.ref_name }}" -f target=big - - echo "Waiting for build to start..." - sleep 120 - - RUN_ID=$(gh run list --workflow=build-default-models.yaml --branch="${{ github.head_ref || github.ref_name }}" --created=">$TRIGGER_TIME" --limit=1 --json databaseId --jq '.[0].databaseId') - if [ -z "$RUN_ID" ] || [ "$RUN_ID" = "null" ]; then - echo "::error::Failed to find build-default-models run" - exit 1 - fi - - echo "Waiting for run $RUN_ID..." - gh run watch "$RUN_ID" - - CONCLUSION=$(gh run view "$RUN_ID" --json conclusion --jq '.conclusion') - if [ "$CONCLUSION" != "success" ]; then - echo "::error::build-default-models failed: $CONCLUSION" - exit 1 - fi - - if ! check_hash; then - echo "::error::HF defaults still don't match after build" - exit 1 - fi + exit 0 fi + + echo "No matching model on HF — dispatching build" + gh workflow run build-default-models.yaml --ref "${{ github.head_ref || github.ref_name }}" -f target=big + + echo "Polling HF for big model availability..." + for i in $(seq 1 90); do + sleep 30 + if check_hash; then + echo "Big model available on HF after $((i * 30))s" + exit 0 + fi + echo "Poll $i/90: not yet available" + done + + echo "::error::Big model not available on HF after 45 minutes" + exit 1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -277,12 +267,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - prepare_small_models: + prepare_small_model: needs: [ prepare_strategy ] runs-on: ubuntu-24.04 outputs: driving_onnx_sha256: ${{ steps.resolve.outputs.driving_onnx_sha256 }} - dm_onnx_sha256: ${{ steps.resolve.outputs.dm_onnx_sha256 }} env: HF_REPO: sunnypilot/sunnypilot_models_v1 HF_DEFAULTS_PATH: models/defaults/small @@ -292,18 +281,15 @@ jobs: ref: ${{ github.head_ref || github.ref_name }} submodules: recursive - - run: git lfs pull -I "openpilot/selfdrive/modeld/models/driving_supercombo.onnx,openpilot/selfdrive/modeld/models/dmonitoring_model.onnx" + - run: git lfs pull -I "openpilot/selfdrive/modeld/models/driving_supercombo.onnx" - name: Check HF defaults and build if needed id: resolve run: | DRIVING_HASH=$(sha256sum "openpilot/selfdrive/modeld/models/driving_supercombo.onnx" | cut -d' ' -f1) - DM_HASH=$(sha256sum "openpilot/selfdrive/modeld/models/dmonitoring_model.onnx" | cut -d' ' -f1) TINYGRAD_REF=$(PYTHONPATH=${{ github.workspace }} python3 openpilot/sunnypilot/models/tinygrad_ref.py) echo "driving_onnx_sha256=$DRIVING_HASH" >> $GITHUB_OUTPUT - echo "dm_onnx_sha256=$DM_HASH" >> $GITHUB_OUTPUT echo "Driving ONNX hash: $DRIVING_HASH" - echo "DM ONNX hash: $DM_HASH" echo "tinygrad ref: $TINYGRAD_REF" JSON_URL="https://huggingface.co/datasets/${HF_REPO}/resolve/main/${HF_DEFAULTS_PATH}/default_models.json" @@ -314,40 +300,92 @@ jobs: [ "$TINYGRAD_MATCH" = "true" ] || return 1 DRIVING=$(echo "$DEFAULTS" | jq --arg hash "$DRIVING_HASH" '.bundles[] | select(.onnx_sha256 == $hash)' 2>/dev/null) [ -n "$DRIVING" ] && [ "$DRIVING" != "null" ] || return 1 + } + + if check_defaults; then + echo "HF defaults match repo ONNX hash and tinygrad ref" + exit 0 + fi + + echo "No matching model on HF — dispatching build" + gh workflow run build-default-models.yaml --ref "${{ github.head_ref || github.ref_name }}" -f target=small + + echo "Polling HF for model availability..." + for i in $(seq 1 60); do + sleep 30 + if check_defaults; then + echo "Model available on HF after $((i * 30))s" + exit 0 + fi + echo "Poll $i/60: not yet available" + done + + echo "::error::Small driving model not available on HF after 30 minutes" + exit 1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Cancel run on failure + if: failure() + run: gh run cancel ${{ github.run_id }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + prepare_dm_model: + needs: [ prepare_strategy ] + runs-on: ubuntu-24.04 + outputs: + dm_onnx_sha256: ${{ steps.resolve.outputs.dm_onnx_sha256 }} + env: + HF_REPO: sunnypilot/sunnypilot_models_v1 + HF_DEFAULTS_PATH: models/defaults/dm + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref || github.ref_name }} + submodules: recursive + + - run: git lfs pull -I "openpilot/selfdrive/modeld/models/dmonitoring_model.onnx" + + - name: Check HF defaults and build if needed + id: resolve + run: | + DM_HASH=$(sha256sum "openpilot/selfdrive/modeld/models/dmonitoring_model.onnx" | cut -d' ' -f1) + TINYGRAD_REF=$(PYTHONPATH=${{ github.workspace }} python3 openpilot/sunnypilot/models/tinygrad_ref.py) + echo "dm_onnx_sha256=$DM_HASH" >> $GITHUB_OUTPUT + echo "DM ONNX hash: $DM_HASH" + echo "tinygrad ref: $TINYGRAD_REF" + + JSON_URL="https://huggingface.co/datasets/${HF_REPO}/resolve/main/${HF_DEFAULTS_PATH}/default_models.json" + + check_defaults() { + DEFAULTS=$(curl -fsSL "$JSON_URL" 2>/dev/null) || return 1 + TINYGRAD_MATCH=$(echo "$DEFAULTS" | jq -r --arg ref "$TINYGRAD_REF" '.tinygrad_ref == $ref' 2>/dev/null) + [ "$TINYGRAD_MATCH" = "true" ] || return 1 DM=$(echo "$DEFAULTS" | jq --arg hash "$DM_HASH" '.bundles[] | select(.onnx_sha256 == $hash)' 2>/dev/null) [ -n "$DM" ] && [ "$DM" != "null" ] || return 1 } if check_defaults; then - echo "HF defaults match repo ONNX hashes and tinygrad ref" - else - echo "No matching models on HF — triggering build" - TRIGGER_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) - gh workflow run build-default-models.yaml --ref "${{ github.head_ref || github.ref_name }}" -f target=small - - echo "Waiting for build to start..." - sleep 120 - - RUN_ID=$(gh run list --workflow=build-default-models.yaml --branch="${{ github.head_ref || github.ref_name }}" --created=">$TRIGGER_TIME" --limit=1 --json databaseId --jq '.[0].databaseId') - if [ -z "$RUN_ID" ] || [ "$RUN_ID" = "null" ]; then - echo "::error::Failed to find build-default-models run" - exit 1 - fi - - echo "Waiting for run $RUN_ID..." - gh run watch "$RUN_ID" - - CONCLUSION=$(gh run view "$RUN_ID" --json conclusion --jq '.conclusion') - if [ "$CONCLUSION" != "success" ]; then - echo "::error::build-default-models failed: $CONCLUSION" - exit 1 - fi - - if ! check_defaults; then - echo "::error::HF defaults still don't match after build" - exit 1 - fi + echo "HF defaults match DM ONNX hash and tinygrad ref" + exit 0 fi + + echo "No matching DM model on HF — dispatching build" + gh workflow run build-default-models.yaml --ref "${{ github.head_ref || github.ref_name }}" -f target=dm + + echo "Polling HF for DM model availability..." + for i in $(seq 1 60); do + sleep 30 + if check_defaults; then + echo "DM model available on HF after $((i * 30))s" + exit 0 + fi + echo "Poll $i/60: not yet available" + done + + echo "::error::DM model not available on HF after 30 minutes" + exit 1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -365,11 +403,12 @@ jobs: always() && !cancelled() && needs.build.result == 'success' && needs.prepare_strategy.result == 'success' && - needs.prepare_small_models.result == 'success' && + needs.prepare_small_model.result == 'success' && + needs.prepare_dm_model.result == 'success' && (!contains(github.event_name, 'pull_request') || (github.event.action == 'labeled' && github.event.label.name == 'prebuilt')) && (needs.prepare_strategy.outputs.include_big_model != 'true' || needs.prepare_chestnut.result == 'success') }} - needs: [ build, prepare_strategy, prepare_chestnut, prepare_small_models ] + needs: [ build, prepare_strategy, prepare_chestnut, prepare_small_model, prepare_dm_model ] runs-on: ubuntu-24.04 environment: ${{ needs.prepare_strategy.outputs.environment }} steps: @@ -385,19 +424,19 @@ jobs: mkdir -p ${{ env.OUTPUT_DIR }} tar xzf prebuilt.tar.gz -C ${{ env.OUTPUT_DIR }} - - name: Download small model chunks from HF + - name: Download default model chunks from HF env: HF_REPO: sunnypilot/sunnypilot_models_v1 - HF_DEFAULTS_PATH: models/defaults/small run: | set -o pipefail - JSON_URL="https://huggingface.co/datasets/${HF_REPO}/resolve/main/${HF_DEFAULTS_PATH}/default_models.json" - DEFAULTS=$(curl -fsSL "$JSON_URL") MODELS_DIR="${{ env.OUTPUT_DIR }}/openpilot/selfdrive/modeld/models" download_model_chunks() { - local ONNX_HASH="$1" - local CANONICAL="$2" + local DEFAULTS_PATH="$1" + local ONNX_HASH="$2" + local CANONICAL="$3" + local JSON_URL="https://huggingface.co/datasets/${HF_REPO}/resolve/main/${DEFAULTS_PATH}/default_models.json" + local DEFAULTS=$(curl -fsSL "$JSON_URL") BUNDLE=$(echo "$DEFAULTS" | jq --arg hash "$ONNX_HASH" '.bundles[] | select(.onnx_sha256 == $hash)') ARTIFACT=$(echo "$BUNDLE" | jq -r '.models[0].artifact') BASE_URL=$(echo "$ARTIFACT" | jq -r '.download_uri.url' | sed 's|/[^/]*$||') @@ -417,8 +456,8 @@ jobs: echo "$NUM_CHUNKS" > "${MODELS_DIR}/${CANONICAL}.chunkmanifest" } - download_model_chunks "${{ needs.prepare_small_models.outputs.driving_onnx_sha256 }}" "driving_tinygrad.pkl" - download_model_chunks "${{ needs.prepare_small_models.outputs.dm_onnx_sha256 }}" "dmonitoring_model_tinygrad.pkl" + download_model_chunks "models/defaults/small" "${{ needs.prepare_small_model.outputs.driving_onnx_sha256 }}" "driving_tinygrad.pkl" + download_model_chunks "models/defaults/dm" "${{ needs.prepare_dm_model.outputs.dm_onnx_sha256 }}" "dmonitoring_model_tinygrad.pkl" - name: Prepare chestnut output if: ${{ needs.prepare_chestnut.result == 'success' }} @@ -453,10 +492,41 @@ jobs: echo "$NUM_CHUNKS" > "big_model_chunks/${CANONICAL}.chunkmanifest" - - name: Inject big model into chestnut + - name: Inject models into chestnut if: ${{ needs.prepare_chestnut.result == 'success' }} + env: + HF_REPO: sunnypilot/sunnypilot_models_v1 run: | - cp big_model_chunks/* "${{ github.workspace }}/chestnut_output/openpilot/selfdrive/modeld/models/" + CHESTNUT_MODELS="${{ github.workspace }}/chestnut_output/openpilot/selfdrive/modeld/models" + cp big_model_chunks/* "$CHESTNUT_MODELS/" + + download_model_chunks() { + local DEFAULTS_PATH="$1" + local ONNX_HASH="$2" + local CANONICAL="$3" + local JSON_URL="https://huggingface.co/datasets/${HF_REPO}/resolve/main/${DEFAULTS_PATH}/default_models.json" + local DEFAULTS=$(curl -fsSL "$JSON_URL") + BUNDLE=$(echo "$DEFAULTS" | jq --arg hash "$ONNX_HASH" '.bundles[] | select(.onnx_sha256 == $hash)') + ARTIFACT=$(echo "$BUNDLE" | jq -r '.models[0].artifact') + BASE_URL=$(echo "$ARTIFACT" | jq -r '.download_uri.url' | sed 's|/[^/]*$||') + NUM_CHUNKS=$(echo "$ARTIFACT" | jq -r '.chunks | length') + + echo "$ARTIFACT" | jq -r '.chunks[].file_name' | while read CHUNK_NAME; do + CHUNK_IDX=$(echo "$CHUNK_NAME" | grep -oP 'chunk\K[0-9]+of[0-9]+' || true) + if [ -z "$CHUNK_IDX" ]; then + echo "::error::Failed to parse chunk index from: $CHUNK_NAME" + exit 1 + fi + CANONICAL_CHUNK="${CANONICAL}.chunk${CHUNK_IDX}" + ENCODED_URL=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${BASE_URL}/${CHUNK_NAME}', safe=':/'))") + echo "Downloading $CHUNK_NAME -> $CANONICAL_CHUNK" + curl -fsSL -o "${CHESTNUT_MODELS}/${CANONICAL_CHUNK}" "$ENCODED_URL" + done + echo "$NUM_CHUNKS" > "${CHESTNUT_MODELS}/${CANONICAL}.chunkmanifest" + } + + download_model_chunks "models/defaults/small" "${{ needs.prepare_small_model.outputs.driving_onnx_sha256 }}" "driving_tinygrad.pkl" + download_model_chunks "models/defaults/dm" "${{ needs.prepare_dm_model.outputs.dm_onnx_sha256 }}" "dmonitoring_model_tinygrad.pkl" - name: Configure Git run: | @@ -507,7 +577,8 @@ jobs: - build - publish - prepare_chestnut - - prepare_small_models + - prepare_small_model + - prepare_dm_model runs-on: ubuntu-24.04 if: ${{ (always() && !cancelled() && !failure()) && needs.publish.result == 'success' From d14d0b1dd04d2320e49e80e6ecbcb6906752cb66 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Mon, 24 Aug 2026 21:48:53 -0400 Subject: [PATCH 06/67] ci: parallelize models chunk downloads and split branch publishing (#1955) * ci: parallelize model chunk downloads and better publish * ci: download all model chunks in parallel with xargs -P8 * split split * ew * must require --- .../download-hf-model-chunks/action.yml | 66 ++++++ .../workflows/sunnypilot-build-prebuilt.yaml | 189 +++++++----------- 2 files changed, 136 insertions(+), 119 deletions(-) create mode 100644 .github/workflows/download-hf-model-chunks/action.yml diff --git a/.github/workflows/download-hf-model-chunks/action.yml b/.github/workflows/download-hf-model-chunks/action.yml new file mode 100644 index 0000000000..01ab5385da --- /dev/null +++ b/.github/workflows/download-hf-model-chunks/action.yml @@ -0,0 +1,66 @@ +name: Download HF model chunks +description: Resolve and download model chunks from HuggingFace in parallel + +inputs: + hf_repo: + description: HuggingFace dataset repo + required: true + models: + description: 'JSON array of {hf_path, onnx_hash, canonical} objects' + required: true + dest_dir: + description: Destination directory for downloaded chunks + required: true + +runs: + using: composite + steps: + - name: Download model chunks + shell: bash + env: + HF_REPO: ${{ inputs.hf_repo }} + MODELS_JSON: ${{ inputs.models }} + DEST_DIR: ${{ inputs.dest_dir }} + run: | + set -eo pipefail + DOWNLOAD_LIST=$(mktemp) + + resolve_chunks() { + local HF_PATH="$1" ONNX_HASH="$2" CANONICAL="$3" DEST_DIR="$4" + local JSON_URL="https://huggingface.co/datasets/${HF_REPO}/resolve/main/${HF_PATH}/default_models.json" + local DEFAULTS BUNDLE ARTIFACT BASE_URL NUM_CHUNKS + DEFAULTS=$(curl -fsSL "$JSON_URL") + BUNDLE=$(echo "$DEFAULTS" | jq --arg hash "$ONNX_HASH" '.bundles[] | select(.onnx_sha256 == $hash)') + ARTIFACT=$(echo "$BUNDLE" | jq -r '.models[0].artifact') + BASE_URL=$(echo "$ARTIFACT" | jq -r '.download_uri.url' | sed 's|/[^/]*$||') + NUM_CHUNKS=$(echo "$ARTIFACT" | jq -r '.chunks | length') + + mkdir -p "$DEST_DIR" + while IFS= read -r CHUNK_NAME; do + CHUNK_IDX=$(echo "$CHUNK_NAME" | grep -oP 'chunk\K[0-9]+of[0-9]+' || true) + if [ -z "$CHUNK_IDX" ]; then + echo "::error::Failed to parse chunk index from: $CHUNK_NAME" + return 1 + fi + ENCODED_URL=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${BASE_URL}/${CHUNK_NAME}', safe=':/'))") + printf '%s\t%s\n' "$ENCODED_URL" "${DEST_DIR}/${CANONICAL}.chunk${CHUNK_IDX}" >> "$DOWNLOAD_LIST" + done < <(echo "$ARTIFACT" | jq -r '.chunks[].file_name') + echo "$NUM_CHUNKS" > "${DEST_DIR}/${CANONICAL}.chunkmanifest" + } + + echo "$MODELS_JSON" | jq -c '.[]' | while IFS= read -r model; do + HF_PATH=$(echo "$model" | jq -r '.hf_path') + ONNX_HASH=$(echo "$model" | jq -r '.onnx_hash') + CANONICAL=$(echo "$model" | jq -r '.canonical') + resolve_chunks "$HF_PATH" "$ONNX_HASH" "$CANONICAL" "$DEST_DIR" + done + + TOTAL=$(wc -l < "$DOWNLOAD_LIST") + echo "Downloading $TOTAL chunks with 8 parallel connections..." + xargs -P8 -d'\n' -I{} bash -c ' + URL="${1%% *}" + DEST="${1#* }" + echo "Downloading $(basename "$DEST")" + curl -fsSL --retry 3 --retry-delay 5 -o "$DEST" "$URL" + ' _ {} < "$DOWNLOAD_LIST" + rm -f "$DOWNLOAD_LIST" diff --git a/.github/workflows/sunnypilot-build-prebuilt.yaml b/.github/workflows/sunnypilot-build-prebuilt.yaml index 8c954a7273..3288963ab4 100644 --- a/.github/workflows/sunnypilot-build-prebuilt.yaml +++ b/.github/workflows/sunnypilot-build-prebuilt.yaml @@ -424,109 +424,16 @@ jobs: mkdir -p ${{ env.OUTPUT_DIR }} tar xzf prebuilt.tar.gz -C ${{ env.OUTPUT_DIR }} - - name: Download default model chunks from HF - env: - HF_REPO: sunnypilot/sunnypilot_models_v1 - run: | - set -o pipefail - MODELS_DIR="${{ env.OUTPUT_DIR }}/openpilot/selfdrive/modeld/models" - - download_model_chunks() { - local DEFAULTS_PATH="$1" - local ONNX_HASH="$2" - local CANONICAL="$3" - local JSON_URL="https://huggingface.co/datasets/${HF_REPO}/resolve/main/${DEFAULTS_PATH}/default_models.json" - local DEFAULTS=$(curl -fsSL "$JSON_URL") - BUNDLE=$(echo "$DEFAULTS" | jq --arg hash "$ONNX_HASH" '.bundles[] | select(.onnx_sha256 == $hash)') - ARTIFACT=$(echo "$BUNDLE" | jq -r '.models[0].artifact') - BASE_URL=$(echo "$ARTIFACT" | jq -r '.download_uri.url' | sed 's|/[^/]*$||') - NUM_CHUNKS=$(echo "$ARTIFACT" | jq -r '.chunks | length') - - echo "$ARTIFACT" | jq -r '.chunks[].file_name' | while read CHUNK_NAME; do - CHUNK_IDX=$(echo "$CHUNK_NAME" | grep -oP 'chunk\K[0-9]+of[0-9]+' || true) - if [ -z "$CHUNK_IDX" ]; then - echo "::error::Failed to parse chunk index from: $CHUNK_NAME" - exit 1 - fi - CANONICAL_CHUNK="${CANONICAL}.chunk${CHUNK_IDX}" - ENCODED_URL=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${BASE_URL}/${CHUNK_NAME}', safe=':/'))") - echo "Downloading $CHUNK_NAME -> $CANONICAL_CHUNK" - curl -fsSL -o "${MODELS_DIR}/${CANONICAL_CHUNK}" "$ENCODED_URL" - done - echo "$NUM_CHUNKS" > "${MODELS_DIR}/${CANONICAL}.chunkmanifest" - } - - download_model_chunks "models/defaults/small" "${{ needs.prepare_small_model.outputs.driving_onnx_sha256 }}" "driving_tinygrad.pkl" - download_model_chunks "models/defaults/dm" "${{ needs.prepare_dm_model.outputs.dm_onnx_sha256 }}" "dmonitoring_model_tinygrad.pkl" - - - name: Prepare chestnut output - if: ${{ needs.prepare_chestnut.result == 'success' }} - run: | - mkdir -p "${{ github.workspace }}/chestnut_output" - tar xzf prebuilt.tar.gz -C "${{ github.workspace }}/chestnut_output" - - - name: Download big model chunks from HF - if: ${{ needs.prepare_chestnut.result == 'success' }} - env: - HF_REPO: sunnypilot/sunnypilot_models_v1 - HF_DEFAULTS_PATH: models/defaults/big - run: | - ONNX_HASH="${{ needs.prepare_chestnut.outputs.onnx_sha256 }}" - JSON_URL="https://huggingface.co/datasets/${HF_REPO}/resolve/main/${HF_DEFAULTS_PATH}/default_models.json" - DEFAULTS=$(curl -fsSL "$JSON_URL") - BUNDLE=$(echo "$DEFAULTS" | jq --arg hash "$ONNX_HASH" '.bundles[] | select(.onnx_sha256 == $hash)') - - mkdir -p big_model_chunks - ARTIFACT=$(echo "$BUNDLE" | jq -r '.models[0].artifact') - BASE_URL=$(echo "$ARTIFACT" | jq -r '.download_uri.url' | sed 's|/[^/]*$||') - NUM_CHUNKS=$(echo "$ARTIFACT" | jq -r '.chunks | length') - - CANONICAL="big_driving_tinygrad.pkl" - echo "$ARTIFACT" | jq -r '.chunks[].file_name' | while read CHUNK_NAME; do - CHUNK_IDX=$(echo "$CHUNK_NAME" | grep -oP 'chunk\K[0-9]+of[0-9]+') - CANONICAL_CHUNK="${CANONICAL}.chunk${CHUNK_IDX}" - ENCODED_URL=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${BASE_URL}/${CHUNK_NAME}', safe=':/'))") - echo "Downloading $CHUNK_NAME -> $CANONICAL_CHUNK" - curl -fsSL -o "big_model_chunks/${CANONICAL_CHUNK}" "$ENCODED_URL" - done - - echo "$NUM_CHUNKS" > "big_model_chunks/${CANONICAL}.chunkmanifest" - - - name: Inject models into chestnut - if: ${{ needs.prepare_chestnut.result == 'success' }} - env: - HF_REPO: sunnypilot/sunnypilot_models_v1 - run: | - CHESTNUT_MODELS="${{ github.workspace }}/chestnut_output/openpilot/selfdrive/modeld/models" - cp big_model_chunks/* "$CHESTNUT_MODELS/" - - download_model_chunks() { - local DEFAULTS_PATH="$1" - local ONNX_HASH="$2" - local CANONICAL="$3" - local JSON_URL="https://huggingface.co/datasets/${HF_REPO}/resolve/main/${DEFAULTS_PATH}/default_models.json" - local DEFAULTS=$(curl -fsSL "$JSON_URL") - BUNDLE=$(echo "$DEFAULTS" | jq --arg hash "$ONNX_HASH" '.bundles[] | select(.onnx_sha256 == $hash)') - ARTIFACT=$(echo "$BUNDLE" | jq -r '.models[0].artifact') - BASE_URL=$(echo "$ARTIFACT" | jq -r '.download_uri.url' | sed 's|/[^/]*$||') - NUM_CHUNKS=$(echo "$ARTIFACT" | jq -r '.chunks | length') - - echo "$ARTIFACT" | jq -r '.chunks[].file_name' | while read CHUNK_NAME; do - CHUNK_IDX=$(echo "$CHUNK_NAME" | grep -oP 'chunk\K[0-9]+of[0-9]+' || true) - if [ -z "$CHUNK_IDX" ]; then - echo "::error::Failed to parse chunk index from: $CHUNK_NAME" - exit 1 - fi - CANONICAL_CHUNK="${CANONICAL}.chunk${CHUNK_IDX}" - ENCODED_URL=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${BASE_URL}/${CHUNK_NAME}', safe=':/'))") - echo "Downloading $CHUNK_NAME -> $CANONICAL_CHUNK" - curl -fsSL -o "${CHESTNUT_MODELS}/${CANONICAL_CHUNK}" "$ENCODED_URL" - done - echo "$NUM_CHUNKS" > "${CHESTNUT_MODELS}/${CANONICAL}.chunkmanifest" - } - - download_model_chunks "models/defaults/small" "${{ needs.prepare_small_model.outputs.driving_onnx_sha256 }}" "driving_tinygrad.pkl" - download_model_chunks "models/defaults/dm" "${{ needs.prepare_dm_model.outputs.dm_onnx_sha256 }}" "dmonitoring_model_tinygrad.pkl" + - name: Download model chunks from HF + uses: ./.github/workflows/download-hf-model-chunks + with: + hf_repo: sunnypilot/sunnypilot_models_v1 + dest_dir: ${{ env.OUTPUT_DIR }}/openpilot/selfdrive/modeld/models + models: | + [ + {"hf_path": "models/defaults/small", "onnx_hash": "${{ needs.prepare_small_model.outputs.driving_onnx_sha256 }}", "canonical": "driving_tinygrad.pkl"}, + {"hf_path": "models/defaults/dm", "onnx_hash": "${{ needs.prepare_dm_model.outputs.dm_onnx_sha256 }}", "canonical": "dmonitoring_model_tinygrad.pkl"} + ] - name: Configure Git run: | @@ -548,22 +455,6 @@ jobs: "https://x-access-token:${{github.token}}@github.com/sunnypilot/sunnypilot.git" \ "${{ needs.prepare_strategy.outputs.extra_version_identifier }}" - - name: Publish chestnut branch - if: ${{ needs.prepare_chestnut.result == 'success' }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - CHESTNUT_BRANCH="${{ needs.prepare_strategy.outputs.new_branch }}-chestnut" - CHESTNUT_DIR="${{ github.workspace }}/chestnut_output" - - ${{ env.CI_DIR }}/publish.sh \ - "${{ github.workspace }}" \ - "$CHESTNUT_DIR" \ - "$CHESTNUT_BRANCH" \ - "${{ needs.prepare_strategy.outputs.version }}" \ - "https://x-access-token:${{github.token}}@github.com/sunnypilot/sunnypilot.git" \ - "${{ needs.prepare_strategy.outputs.extra_version_identifier }}" - - name: Tag ${{ needs.prepare_strategy.outputs.environment }} if: ${{ needs.prepare_strategy.outputs.is_stable_branch == 'true' && (github.event_name != 'push' || !startsWith(github.ref, 'refs/tags/')) }} run: | @@ -571,11 +462,71 @@ jobs: git tag -f -a ${TAG} -m "${{ needs.prepare_strategy.outputs.environment }} @ ${{ needs.prepare_strategy.outputs.version }} of build ${{ needs.prepare_strategy.outputs.build }}." git push -f origin ${TAG} + publish_chestnut: + concurrency: + group: ${{ needs.prepare_strategy.outputs.publish_concurrency_group }}-chestnut + cancel-in-progress: ${{ needs.prepare_strategy.outputs.cancel_publish_in_progress == 'true' }} + if: ${{ + always() && !cancelled() && + needs.build.result == 'success' && + needs.prepare_strategy.result == 'success' && + needs.prepare_small_model.result == 'success' && + needs.prepare_dm_model.result == 'success' && + needs.prepare_chestnut.result == 'success' && + (!contains(github.event_name, 'pull_request') || (github.event.action == 'labeled' && github.event.label.name == 'prebuilt')) + }} + needs: [ build, prepare_strategy, prepare_chestnut, prepare_small_model, prepare_dm_model ] + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: Download prebuilt artifact + uses: actions/download-artifact@v4 + with: + name: prebuilt + + - name: Untar prebuilt + run: | + mkdir -p ${{ env.OUTPUT_DIR }} + tar xzf prebuilt.tar.gz -C ${{ env.OUTPUT_DIR }} + + - name: Download model chunks from HF + uses: ./.github/workflows/download-hf-model-chunks + with: + hf_repo: sunnypilot/sunnypilot_models_v1 + dest_dir: ${{ env.OUTPUT_DIR }}/openpilot/selfdrive/modeld/models + models: | + [ + {"hf_path": "models/defaults/small", "onnx_hash": "${{ needs.prepare_small_model.outputs.driving_onnx_sha256 }}", "canonical": "driving_tinygrad.pkl"}, + {"hf_path": "models/defaults/dm", "onnx_hash": "${{ needs.prepare_dm_model.outputs.dm_onnx_sha256 }}", "canonical": "dmonitoring_model_tinygrad.pkl"}, + {"hf_path": "models/defaults/big", "onnx_hash": "${{ needs.prepare_chestnut.outputs.onnx_sha256 }}", "canonical": "big_driving_tinygrad.pkl"} + ] + + - name: Configure Git + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + + - name: Publish chestnut branch + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + CHESTNUT_BRANCH="${{ needs.prepare_strategy.outputs.new_branch }}-chestnut" + + ${{ env.CI_DIR }}/publish.sh \ + "${{ github.workspace }}" \ + "${{ env.OUTPUT_DIR }}" \ + "$CHESTNUT_BRANCH" \ + "${{ needs.prepare_strategy.outputs.version }}" \ + "https://x-access-token:${{github.token}}@github.com/sunnypilot/sunnypilot.git" \ + "${{ needs.prepare_strategy.outputs.extra_version_identifier }}" + notify: needs: - prepare_strategy - build - publish + - publish_chestnut - prepare_chestnut - prepare_small_model - prepare_dm_model From 19f83b274fceeaa56ec5090d0497046d604be027 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Mon, 24 Aug 2026 22:06:20 -0400 Subject: [PATCH 07/67] ci: identical environment for publish_chestnut prebuilt --- .github/workflows/sunnypilot-build-prebuilt.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/sunnypilot-build-prebuilt.yaml b/.github/workflows/sunnypilot-build-prebuilt.yaml index 3288963ab4..ac1dd3eca9 100644 --- a/.github/workflows/sunnypilot-build-prebuilt.yaml +++ b/.github/workflows/sunnypilot-build-prebuilt.yaml @@ -477,6 +477,7 @@ jobs: }} needs: [ build, prepare_strategy, prepare_chestnut, prepare_small_model, prepare_dm_model ] runs-on: ubuntu-24.04 + environment: ${{ needs.prepare_strategy.outputs.environment }} steps: - uses: actions/checkout@v4 From 2ba91d2be5cc91813762ffe6af3243728e7799ed Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Mon, 24 Aug 2026 22:42:45 -0400 Subject: [PATCH 08/67] ci: add tinygrad ref check to prepare_chestnut and even faster prebuilt stages (#1957) * ci: faster prebuilt stages * tg check chestnut * zoomer! --- .../workflows/sunnypilot-build-prebuilt.yaml | 87 ++++++++++--------- release/ci/publish.sh | 2 +- 2 files changed, 48 insertions(+), 41 deletions(-) diff --git a/.github/workflows/sunnypilot-build-prebuilt.yaml b/.github/workflows/sunnypilot-build-prebuilt.yaml index ac1dd3eca9..c93dbe02c1 100644 --- a/.github/workflows/sunnypilot-build-prebuilt.yaml +++ b/.github/workflows/sunnypilot-build-prebuilt.yaml @@ -39,6 +39,8 @@ jobs: include_big_model: ${{ steps.strategy.outputs.include_big_model }} steps: - uses: actions/checkout@v4 + with: + fetch-depth: 1 - name: Extract deploy strategy id: strategy run: | @@ -96,6 +98,8 @@ jobs: }} steps: - uses: actions/checkout@v4 + with: + fetch-depth: 1 - name: Wait for Tests uses: ./.github/workflows/wait-for-action # Path to where you place the action with: @@ -119,6 +123,7 @@ jobs: steps: - uses: actions/checkout@v4 with: + fetch-depth: 1 submodules: recursive ref: ${{ env.SOURCE_BRANCH }} repository: ${{ github.event.pull_request.head.repo.fork && github.event.pull_request.head.repo.full_name || github.repository }} @@ -214,42 +219,44 @@ jobs: outputs: onnx_sha256: ${{ steps.resolve.outputs.onnx_sha256 }} env: + GH_REPO: ${{ github.repository }} HF_REPO: sunnypilot/sunnypilot_models_v1 HF_DEFAULTS_PATH: models/defaults/big steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.head_ref || github.ref_name }} - - - run: git lfs pull -I "openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx" - - - name: Check HF defaults and build if needed + - name: Resolve ONNX hash and tinygrad ref via API id: resolve run: | - ACTUAL_ONNX_HASH=$(sha256sum "openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx" | cut -d' ' -f1) - echo "Repo ONNX hash: $ACTUAL_ONNX_HASH" - echo "onnx_sha256=$ACTUAL_ONNX_HASH" >> $GITHUB_OUTPUT + REF="${{ github.head_ref || github.ref_name }}" + + ONNX_HASH=$(gh api "repos/${GH_REPO}/contents/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx?ref=${REF}" --jq '.content' | base64 -d | grep '^oid sha256:' | cut -d: -f2) + echo "ONNX hash: $ONNX_HASH" + echo "onnx_sha256=$ONNX_HASH" >> $GITHUB_OUTPUT + + TINYGRAD_REF=$(gh api "repos/${GH_REPO}/contents/tinygrad_repo?ref=${REF}" --jq '.sha') + echo "tinygrad ref: $TINYGRAD_REF" JSON_URL="https://huggingface.co/datasets/${HF_REPO}/resolve/main/${HF_DEFAULTS_PATH}/default_models.json" - check_hash() { + check_defaults() { DEFAULTS=$(curl -fsSL "$JSON_URL" 2>/dev/null) || return 1 - BUNDLE=$(echo "$DEFAULTS" | jq --arg hash "$ACTUAL_ONNX_HASH" '.bundles[] | select(.onnx_sha256 == $hash)' 2>/dev/null) + TINYGRAD_MATCH=$(echo "$DEFAULTS" | jq -r --arg ref "$TINYGRAD_REF" '.tinygrad_ref == $ref' 2>/dev/null) + [ "$TINYGRAD_MATCH" = "true" ] || return 1 + BUNDLE=$(echo "$DEFAULTS" | jq --arg hash "$ONNX_HASH" '.bundles[] | select(.onnx_sha256 == $hash)' 2>/dev/null) [ -n "$BUNDLE" ] && [ "$BUNDLE" != "null" ] } - if check_hash; then - echo "HF defaults match repo ONNX" + if check_defaults; then + echo "HF defaults match repo ONNX hash and tinygrad ref" exit 0 fi echo "No matching model on HF — dispatching build" - gh workflow run build-default-models.yaml --ref "${{ github.head_ref || github.ref_name }}" -f target=big + gh workflow run build-default-models.yaml --ref "$REF" -f target=big echo "Polling HF for big model availability..." for i in $(seq 1 90); do sleep 30 - if check_hash; then + if check_defaults; then echo "Big model available on HF after $((i * 30))s" exit 0 fi @@ -273,23 +280,20 @@ jobs: outputs: driving_onnx_sha256: ${{ steps.resolve.outputs.driving_onnx_sha256 }} env: + GH_REPO: ${{ github.repository }} HF_REPO: sunnypilot/sunnypilot_models_v1 HF_DEFAULTS_PATH: models/defaults/small steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.head_ref || github.ref_name }} - submodules: recursive - - - run: git lfs pull -I "openpilot/selfdrive/modeld/models/driving_supercombo.onnx" - - - name: Check HF defaults and build if needed + - name: Resolve ONNX hash and tinygrad ref via API id: resolve run: | - DRIVING_HASH=$(sha256sum "openpilot/selfdrive/modeld/models/driving_supercombo.onnx" | cut -d' ' -f1) - TINYGRAD_REF=$(PYTHONPATH=${{ github.workspace }} python3 openpilot/sunnypilot/models/tinygrad_ref.py) - echo "driving_onnx_sha256=$DRIVING_HASH" >> $GITHUB_OUTPUT + REF="${{ github.head_ref || github.ref_name }}" + + DRIVING_HASH=$(gh api "repos/${GH_REPO}/contents/openpilot/selfdrive/modeld/models/driving_supercombo.onnx?ref=${REF}" --jq '.content' | base64 -d | grep '^oid sha256:' | cut -d: -f2) echo "Driving ONNX hash: $DRIVING_HASH" + echo "driving_onnx_sha256=$DRIVING_HASH" >> $GITHUB_OUTPUT + + TINYGRAD_REF=$(gh api "repos/${GH_REPO}/contents/tinygrad_repo?ref=${REF}" --jq '.sha') echo "tinygrad ref: $TINYGRAD_REF" JSON_URL="https://huggingface.co/datasets/${HF_REPO}/resolve/main/${HF_DEFAULTS_PATH}/default_models.json" @@ -308,7 +312,7 @@ jobs: fi echo "No matching model on HF — dispatching build" - gh workflow run build-default-models.yaml --ref "${{ github.head_ref || github.ref_name }}" -f target=small + gh workflow run build-default-models.yaml --ref "$REF" -f target=small echo "Polling HF for model availability..." for i in $(seq 1 60); do @@ -337,23 +341,20 @@ jobs: outputs: dm_onnx_sha256: ${{ steps.resolve.outputs.dm_onnx_sha256 }} env: + GH_REPO: ${{ github.repository }} HF_REPO: sunnypilot/sunnypilot_models_v1 HF_DEFAULTS_PATH: models/defaults/dm steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.head_ref || github.ref_name }} - submodules: recursive - - - run: git lfs pull -I "openpilot/selfdrive/modeld/models/dmonitoring_model.onnx" - - - name: Check HF defaults and build if needed + - name: Resolve ONNX hash and tinygrad ref via API id: resolve run: | - DM_HASH=$(sha256sum "openpilot/selfdrive/modeld/models/dmonitoring_model.onnx" | cut -d' ' -f1) - TINYGRAD_REF=$(PYTHONPATH=${{ github.workspace }} python3 openpilot/sunnypilot/models/tinygrad_ref.py) - echo "dm_onnx_sha256=$DM_HASH" >> $GITHUB_OUTPUT + REF="${{ github.head_ref || github.ref_name }}" + + DM_HASH=$(gh api "repos/${GH_REPO}/contents/openpilot/selfdrive/modeld/models/dmonitoring_model.onnx?ref=${REF}" --jq '.content' | base64 -d | grep '^oid sha256:' | cut -d: -f2) echo "DM ONNX hash: $DM_HASH" + echo "dm_onnx_sha256=$DM_HASH" >> $GITHUB_OUTPUT + + TINYGRAD_REF=$(gh api "repos/${GH_REPO}/contents/tinygrad_repo?ref=${REF}" --jq '.sha') echo "tinygrad ref: $TINYGRAD_REF" JSON_URL="https://huggingface.co/datasets/${HF_REPO}/resolve/main/${HF_DEFAULTS_PATH}/default_models.json" @@ -372,7 +373,7 @@ jobs: fi echo "No matching DM model on HF — dispatching build" - gh workflow run build-default-models.yaml --ref "${{ github.head_ref || github.ref_name }}" -f target=dm + gh workflow run build-default-models.yaml --ref "$REF" -f target=dm echo "Polling HF for DM model availability..." for i in $(seq 1 60); do @@ -413,6 +414,8 @@ jobs: environment: ${{ needs.prepare_strategy.outputs.environment }} steps: - uses: actions/checkout@v4 + with: + fetch-depth: 1 - name: Download prebuilt artifact uses: actions/download-artifact@v4 @@ -480,6 +483,8 @@ jobs: environment: ${{ needs.prepare_strategy.outputs.environment }} steps: - uses: actions/checkout@v4 + with: + fetch-depth: 1 - name: Download prebuilt artifact uses: actions/download-artifact@v4 @@ -538,6 +543,8 @@ jobs: && (fromJSON(vars.DEV_FEEDBACK_NOTIFICATION_BRANCHES_V2)[github.head_ref || github.ref_name] != null) }} steps: - uses: actions/checkout@v4 + with: + fetch-depth: 1 - name: Prepare notification message id: message diff --git a/release/ci/publish.sh b/release/ci/publish.sh index fd1a61a87c..4b328a035c 100755 --- a/release/ci/publish.sh +++ b/release/ci/publish.sh @@ -47,7 +47,7 @@ git rm -rf $OUTPUT_DIR/.git || true # Doing cleanup, but it might fail if the .g git remote remove origin || true # ensure cleanup git remote add origin $GIT_ORIGIN #git push origin -d $DEV_BRANCH || true # Ensuring we delete the remote branch if it exists as we are wiping it out -git fetch origin $DEV_BRANCH || (git checkout -b $DEV_BRANCH && git commit --allow-empty -m "sunnypilot v$VERSION release" && git push -u origin $DEV_BRANCH) +git fetch --depth 1 origin $DEV_BRANCH || (git checkout -b $DEV_BRANCH && git commit --allow-empty -m "sunnypilot v$VERSION release" && git push -u origin $DEV_BRANCH) echo "[-] committing version $VERSION T=$SECONDS" git add -f . From 45814e331381c2c132912fae0807cfb783bec0a2 Mon Sep 17 00:00:00 2001 From: James Vecellio-Grant <159560811+Discountchubbs@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:52:29 -0700 Subject: [PATCH 09/67] modeld_v2: spatial features (#1934) * modeld_v2: spatial features * Update fetcher.py * dont reshape non 4 dim arrays * realize for non compiled * Update compile_modeld.py * god dammit it was realize() * it was fucking frozen tinygrad. just need to recompile * bump * ci: add is_big flag to metadata.json to support backward compat * Update model_generator.py * Update sunnypilot-build-model.yaml * Update helpers.py * Revert "Update helpers.py" This reverts commit 3a955ca11a84486fc143219e5824d8d9b3927895. * Reapply "Update helpers.py" This reverts commit ca9c6e193326dbe99089d099e61979f7b80e0981. * models: use less strict chestnut detection state --------- Co-authored-by: Jason Wen --- .github/workflows/sunnypilot-build-model.yaml | 2 +- .../sunnypilot/modeld_v2/compile_modeld.py | 23 +++--- .../modeld_v2/tests/test_compile_modeld.py | 82 +++++++++++++++++++ openpilot/sunnypilot/models/fetcher.py | 4 +- openpilot/sunnypilot/models/helpers.py | 2 +- release/ci/model_generator.py | 7 +- 6 files changed, 104 insertions(+), 16 deletions(-) diff --git a/.github/workflows/sunnypilot-build-model.yaml b/.github/workflows/sunnypilot-build-model.yaml index bc132ae1bf..5c2e3bf204 100644 --- a/.github/workflows/sunnypilot-build-model.yaml +++ b/.github/workflows/sunnypilot-build-model.yaml @@ -188,7 +188,7 @@ jobs: if [ "${{ inputs.target_hardware }}" == "usbgpu" ]; then echo "USBGPU build" export USBGPU=1 - TG_FLAGS="DEBUG=2 DEV=USB+AMD:LLVM WARP_DEV=QCOM FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2" + TG_FLAGS="DEBUG=1 DEV=USB+AMD:LLVM WARP_DEV=QCOM FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2" OUTPUT_PKL="${{ env.MODELS_DIR }}/big_driving_tinygrad.pkl" else echo "QCOM build" diff --git a/openpilot/sunnypilot/modeld_v2/compile_modeld.py b/openpilot/sunnypilot/modeld_v2/compile_modeld.py index 85ae57c078..17687908c8 100755 --- a/openpilot/sunnypilot/modeld_v2/compile_modeld.py +++ b/openpilot/sunnypilot/modeld_v2/compile_modeld.py @@ -7,6 +7,7 @@ See the LICENSE.md file in the root directory for more details. """ import argparse +import math import os import tempfile import time @@ -66,14 +67,15 @@ def get_policy_npy_shapes(input_shapes: dict, is_supercombo: bool = False) -> tu if desire_key: shapes['desire'] = (input_shapes[desire_key][2],) - if is_supercombo and 'features_buffer' in input_shapes: - fb = input_shapes['features_buffer'] - shapes['prev_feat'] = (fb[0], fb[2]) - for key, shape in input_shapes.items(): if key not in (desire_key, 'features_buffer') and 'img' not in key: shapes[key] = tuple(shape) + if is_supercombo and 'features_buffer' in input_shapes: + fb = input_shapes['features_buffer'] + feat_dim = math.prod(fb[2:]) + shapes['prev_feat'] = (fb[0], feat_dim) + sizes = [int(np.prod(size)) for size in shapes.values()] return shapes, sizes @@ -117,8 +119,9 @@ def generate_queues_and_npy(input_shapes: dict, frame_skip: int, device: str = D } if features_buffer: + feat_dim = math.prod(features_buffer[2:]) feat_q_len = frame_skip * features_buffer[1] if is_supercombo else frame_skip * (features_buffer[1] - 1) + 1 - queues['feat_q'] = Tensor(np.zeros((feat_q_len, features_buffer[0], features_buffer[2]), + queues['feat_q'] = Tensor(np.zeros((feat_q_len, features_buffer[0], feat_dim), dtype=np.float32), device=device).contiguous().realize() queues.update({key: Tensor(value, device='NPY').realize() for key, value in npy_arrays.items() if key in ('tfm', 'big_tfm')}) @@ -183,14 +186,14 @@ def make_run_policy(vision_runner, policy_runners: list, features_slice: slice, warped_dev = warped.to(Device.DEFAULT) Tensor.realize(packed_npy_inputs_dev, warped_dev) - img = shift_and_sample(img_q, warped_dev[0:1], sample_skip_fn).realize() - big_img = shift_and_sample(big_img_q, warped_dev[1:2], sample_skip_fn).realize() + img = shift_and_sample(img_q, warped_dev[0:1], sample_skip_fn) + big_img = shift_and_sample(big_img_q, warped_dev[1:2], sample_skip_fn) unpacked_tensors = [tensor.reshape(shape) for tensor, shape in zip(packed_npy_inputs_dev.split(npy_sizes), npy_shapes.values(), strict=True)] unpacked_dict = dict(zip(npy_shapes.keys(), unpacked_tensors, strict=True)) desire_dev = unpacked_dict['desire'] - desire_buf = shift_and_sample(desire_q, desire_dev.reshape(1, 1, -1), sample_desire_fn).realize() + desire_buf = shift_and_sample(desire_q, desire_dev.reshape(1, 1, -1), sample_desire_fn) inputs = {desire_key: desire_buf} for key, tensor_val in unpacked_dict.items(): @@ -199,7 +202,7 @@ def make_run_policy(vision_runner, policy_runners: list, features_slice: slice, if 'prev_feat' in unpacked_dict: prev_feat_dev = unpacked_dict['prev_feat'] - inputs['features_buffer'] = shift_and_sample(feat_q, prev_feat_dev.reshape(1, 1, -1), sample_skip_fn).realize() + inputs['features_buffer'] = shift_and_sample(feat_q, prev_feat_dev.reshape(1, 1, -1), sample_skip_fn).reshape(input_shapes['features_buffer']) if vision_runner: vision_out_cast = next(iter(vision_runner({road_key: img, wide_key: big_img}).values())).cast('float32').realize() @@ -211,7 +214,7 @@ def make_run_policy(vision_runner, policy_runners: list, features_slice: slice, inputs.update({road_key: img, wide_key: big_img}) if 'features_buffer' not in inputs: - inputs['features_buffer'] = sample_skip_fn(feat_q) + inputs['features_buffer'] = sample_skip_fn(feat_q).reshape(input_shapes['features_buffer']) policy_out = next(iter(policy_runners[0](inputs).values())).cast('float32').realize() if 'features_buffer' not in inputs and features_slice is not None: diff --git a/openpilot/sunnypilot/modeld_v2/tests/test_compile_modeld.py b/openpilot/sunnypilot/modeld_v2/tests/test_compile_modeld.py index 96bfb42638..86974b14f1 100644 --- a/openpilot/sunnypilot/modeld_v2/tests/test_compile_modeld.py +++ b/openpilot/sunnypilot/modeld_v2/tests/test_compile_modeld.py @@ -195,3 +195,85 @@ class TestReadFileChunkedToDisk(OpenpilotTestCase): assert out.parent == Path(d) assert out.read_bytes() == payload + + +class Test4DFeaturesBuffer(OpenpilotTestCase): + def test_get_policy_npy_shapes_4d(self): + from openpilot.sunnypilot.modeld_v2.compile_modeld import get_policy_npy_shapes + input_shapes = { + 'desire_pulse': (1, 25, 8), + 'features_buffer': (1, 24, 32, 512), # compare 4d to 3d for regression + 'traffic_convention': (1, 2), + 'action_t': (1, 2) + } + shapes, sizes = get_policy_npy_shapes(input_shapes, is_supercombo=True) + assert shapes['prev_feat'] == (1, 16384) + assert sizes == [8, 2, 2, 16384] + + def test_get_policy_npy_shapes_3d(self): + from openpilot.sunnypilot.modeld_v2.compile_modeld import get_policy_npy_shapes + input_shapes = { + 'desire_pulse': (1, 25, 8), + 'features_buffer': (1, 24, 512), + 'traffic_convention': (1, 2), + 'action_t': (1, 2) + } + shapes, sizes = get_policy_npy_shapes(input_shapes, is_supercombo=True) + assert shapes['prev_feat'] == (1, 512) + assert sizes == [8, 2, 2, 512] + + +class TestStockCompileModeldEquivalence(OpenpilotTestCase): + def test_get_policy_npy_shapes_matches_stock(self): + from openpilot.selfdrive.modeld.compile_modeld import get_policy_npy_shapes as stock_get_policy_npy_shapes + from openpilot.sunnypilot.modeld_v2.compile_modeld import get_policy_npy_shapes as sunny_get_policy_npy_shapes + + stock_input_shapes = { + 'desire_pulse': (1, 25, 8), + 'features_buffer': (1, 24, 512), # see below comment + 'traffic_convention': (1, 2), + 'action_t': (1, 2), + } + + stock_shapes, stock_sizes = stock_get_policy_npy_shapes(stock_input_shapes) + sunny_shapes, sunny_sizes = sunny_get_policy_npy_shapes(stock_input_shapes, is_supercombo=True) + + assert sunny_shapes == stock_shapes + assert sunny_sizes == stock_sizes + assert sunny_shapes['prev_feat'] == (1, 512) + + def test_make_input_queues_full_stock_equivalence(self): + from openpilot.selfdrive.modeld.compile_modeld import make_input_queues as stock_make_input_queues + from openpilot.sunnypilot.modeld_v2.compile_modeld import make_supercombo_input_queues as sunny_make_supercombo_input_queues + input_shapes = { + 'img': (1, 12, 128, 256), + 'desire_pulse': (1, 25, 8), + 'features_buffer': (1, 24, 512), # when https://github.com/commaai/openpilot/pull/38681 merges, update to 1,24,32,512 + 'traffic_convention': (1, 2), + 'action_t': (1, 2), + } + frame_skip = 4 + + stock_queues, stock_npy = stock_make_input_queues(input_shapes, frame_skip, device='NPY') + sunny_queues, sunny_npy = sunny_make_supercombo_input_queues(input_shapes, frame_skip, device='NPY') + assert set(sunny_queues.keys()) == set(stock_queues.keys()) + for key in stock_queues: + assert sunny_queues[key].shape == stock_queues[key].shape, \ + f"Queue shape mismatch for {key}: sunny {sunny_queues[key].shape} != stock {stock_queues[key].shape}" + assert set(sunny_npy.keys()) == set(stock_npy.keys()) + for key in stock_npy: + assert sunny_npy[key].shape == stock_npy[key].shape, \ + f"Numpy array shape mismatch for {key}: sunny {sunny_npy[key].shape} != stock {stock_npy[key].shape}" + + def test_make_warp_queues_stock_equivalence(self): + from openpilot.selfdrive.modeld.compile_modeld import make_warp_input_queues as stock_make_warp_queues + from openpilot.sunnypilot.modeld_v2.compile_modeld import make_warp_queues as sunny_make_warp_queues + stock_vision_shapes = {'img': (1, 12, 128, 256)} # for now? + stock_queues, stock_npy = stock_make_warp_queues(stock_vision_shapes, frame_skip=4, device='NPY') + sunny_queues, sunny_npy = sunny_make_warp_queues(device='NPY') + + assert set(sunny_npy.keys()) == set(stock_npy.keys()) == {'tfm', 'big_tfm'} + for key in sunny_npy: + assert sunny_npy[key].shape == stock_npy[key].shape == (3, 3) + + diff --git a/openpilot/sunnypilot/models/fetcher.py b/openpilot/sunnypilot/models/fetcher.py index 773eb5b95c..c9e86edd0c 100644 --- a/openpilot/sunnypilot/models/fetcher.py +++ b/openpilot/sunnypilot/models/fetcher.py @@ -138,8 +138,8 @@ class ModelCache: class ModelFetcher: """Handles fetching and caching of model data from remote source""" - MODEL_URL = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_v20.json" - MODEL_URL_USBGPU = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_usbgpu_v21.json" + MODEL_URL = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_v21.json" + MODEL_URL_USBGPU = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_usbgpu_v22.json" def __init__(self, params: Params): self.params = params diff --git a/openpilot/sunnypilot/models/helpers.py b/openpilot/sunnypilot/models/helpers.py index b5c97467d3..d0fb2e37ec 100644 --- a/openpilot/sunnypilot/models/helpers.py +++ b/openpilot/sunnypilot/models/helpers.py @@ -18,7 +18,7 @@ from openpilot.sunnypilot.models.constants import Meta, MetaSimPose, MetaTombRai from openpilot.common.hardware.hw import Paths # SET ME TO THE EXACT JSON VERSION WE SET IN SUNNYPILOT_MODELS REPO -REQUIRED_JSON_VERSION = 17 +REQUIRED_JSON_VERSION = 18 CUSTOM_MODEL_PATH = Paths.model_root() METADATA_PATH = Path(__file__).parent / '../models/supercombo_metadata.pkl' diff --git a/release/ci/model_generator.py b/release/ci/model_generator.py index ff9be64783..2d35d319c2 100755 --- a/release/ci/model_generator.py +++ b/release/ci/model_generator.py @@ -136,7 +136,7 @@ def generate_chunked_model(driving_pkl: Path) -> dict: def create_metadata_json(models: list, output_dir: Path, custom_name=None, short_name=None, is_20hz=False, upstream_branch="unknown", - onnx_sha256=None) -> None: + onnx_sha256=None, is_big=False) -> None: bundle_json = { "short_name": short_name, "display_name": custom_name or upstream_branch, @@ -149,6 +149,7 @@ def create_metadata_json(models: list, output_dir: Path, custom_name=None, short "generation": "-1", "build_time": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), "overrides": {}, + "is_big": is_big, "models": models, } @@ -186,6 +187,8 @@ if __name__ == "__main__": print(f"No driving_tinygrad.pkl found in {_output_dir}", file=sys.stderr) sys.exit(1) + is_big = _driving_pkl.name.startswith('big_') + if _pkl: new_pkl = _output_dir / f"driving_{_pkl}_tinygrad.pkl" if not new_pkl.exists(): @@ -196,4 +199,4 @@ if __name__ == "__main__": _model_metadata = generate_chunked_model(_driving_pkl) _onnx_sha256 = _hash_onnx_files(Path(args.model_dir)) create_metadata_json([_model_metadata], _output_dir, args.custom_name, _short_name, args.is_20hz, args.upstream_branch, - onnx_sha256=_onnx_sha256) + onnx_sha256=_onnx_sha256, is_big=is_big) From 760c19d3f91f79e404f36b020c5df027a5291e48 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Mon, 24 Aug 2026 23:31:52 -0400 Subject: [PATCH 10/67] ui/models: handle missing files during cache size calculation (#1958) --- .../selfdrive/ui/sunnypilot/layouts/settings/models.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py index becdceaaf0..e4a6bea6e0 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py @@ -115,8 +115,12 @@ class ModelsLayout(Widget): def calculate_cache_size(): cache_size = 0.0 if os.path.exists(CUSTOM_MODEL_PATH): - cache_size = sum(os.path.getsize(os.path.join(CUSTOM_MODEL_PATH, file)) for file in os.listdir(CUSTOM_MODEL_PATH)) / (1024**2) - return cache_size + 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) def _clear_cache(self): def _callback(response): From cefe5737b9b201af26eafc0c4d426e7e15ecdd4c Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Tue, 25 Aug 2026 00:41:31 -0400 Subject: [PATCH 11/67] models: fix current model not updating on chestnut status (#1959) * models: preserve user model selection across reboots and power cycles * no * again * idk * over --- openpilot/sunnypilot/models/helpers.py | 24 ++++++------------------ openpilot/sunnypilot/models/manager.py | 7 ++++++- 2 files changed, 12 insertions(+), 19 deletions(-) diff --git a/openpilot/sunnypilot/models/helpers.py b/openpilot/sunnypilot/models/helpers.py index d0fb2e37ec..d97d655d5f 100644 --- a/openpilot/sunnypilot/models/helpers.py +++ b/openpilot/sunnypilot/models/helpers.py @@ -23,7 +23,6 @@ REQUIRED_JSON_VERSION = 18 CUSTOM_MODEL_PATH = Paths.model_root() METADATA_PATH = Path(__file__).parent / '../models/supercombo_metadata.pkl' ModelManager = custom.ModelManagerSP -_LAST_VALIDATED_RAW = None def _compute_hash(file_path: str) -> str | None: @@ -86,11 +85,11 @@ def _bundle_needs_reset(active_bundle: custom.ModelManagerSP.ModelBundle, availa if available_bundles is not None: matching_bundle = None for bundle in available_bundles: - if getattr(active_bundle, 'ref', None) and getattr(bundle, 'ref', None): + if active_bundle.ref and bundle.ref: if active_bundle.ref == bundle.ref: matching_bundle = bundle break - elif getattr(active_bundle, 'internalName', None) == getattr(bundle, 'internalName', None): + elif active_bundle.internalName == bundle.internalName: matching_bundle = bundle break @@ -98,36 +97,25 @@ def _bundle_needs_reset(active_bundle: custom.ModelManagerSP.ModelBundle, availa return True if active_bundle.minimumSelectorVersion != matching_bundle.minimumSelectorVersion: return True - - active_runner = getattr(active_bundle, 'runner', None) - matching_runner = getattr(matching_bundle, 'runner', None) - if active_runner is not None and matching_runner is not None: - if getattr(active_runner, 'raw', active_runner) != getattr(matching_runner, 'raw', matching_runner): - return True + if active_bundle.runner.raw != matching_bundle.runner.raw: + return True if set(_bundle_artifacts(active_bundle)) != set(_bundle_artifacts(matching_bundle)): return True - return not _bundle_is_valid_locally(active_bundle) + # missing files trigger re-download, not selection reset + return False def validate_active_bundle(params: Params, available_bundles: list[custom.ModelManagerSP.ModelBundle] | None = None) -> None: - global _LAST_VALIDATED_RAW - raw_bundle = params.get("ModelManager_ActiveBundle") if not raw_bundle: return - if raw_bundle == _LAST_VALIDATED_RAW: - return - active_bundle = get_active_bundle(params, raw_bundle_dict=raw_bundle) if active_bundle is None or _bundle_needs_reset(active_bundle, available_bundles): cloudlog.warning("Active model bundle invalid; resetting to default") params.remove("ModelManager_ActiveBundle") params.put("ModelRunnerTypeCache", int(custom.ModelManagerSP.Runner.stock), block=True) - _LAST_VALIDATED_RAW = None - else: - _LAST_VALIDATED_RAW = raw_bundle def get_active_bundle(params: Params | None = None, raw_bundle_dict: dict | bytes | None = None) -> "custom.ModelManagerSP.ModelBundle | None": diff --git a/openpilot/sunnypilot/models/manager.py b/openpilot/sunnypilot/models/manager.py index 37bcb781cf..60f01d0c81 100644 --- a/openpilot/sunnypilot/models/manager.py +++ b/openpilot/sunnypilot/models/manager.py @@ -257,15 +257,20 @@ class ModelManagerSP: """Main entry point for downloading a model bundle""" asyncio.run(self._download_bundle(model_bundle, destination_path)) + BOOT_SETTLE_TICKS = 10 # seconds at 1 Hz before validating active bundle + def main_thread(self) -> None: """Main thread for model management""" rk = Ratekeeper(1, print_delay_threshold=None) + boot_ticks = 0 while True: try: self.sm.update(0) self.available_models = self.model_fetcher.get_available_bundles(self.sm['deviceState'].chestnutPresent) - validate_active_bundle(self.params, self.available_models) + if boot_ticks >= self.BOOT_SETTLE_TICKS: + validate_active_bundle(self.params, self.available_models) + boot_ticks = min(boot_ticks + 1, self.BOOT_SETTLE_TICKS) self.active_bundle = get_active_bundle(self.params) if (index_to_download := self.params.get("ModelManager_DownloadIndex")) is not None: From 25c25047b890337059f3a6727c1525c49dca2b10 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Tue, 25 Aug 2026 01:12:12 -0400 Subject: [PATCH 12/67] models: persist model selection per catalog across chestnut state changes (#1960) --- openpilot/common/params_keys.h | 2 ++ openpilot/sunnypilot/models/helpers.py | 19 ++++++++++++++++++- openpilot/sunnypilot/models/manager.py | 5 +++-- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index 01c14fb539..111099fce9 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -196,6 +196,8 @@ inline static std::unordered_map keys = { // Model Manager params {"ModelManager_ActiveBundle", {PERSISTENT, JSON}}, {"ModelManager_ActiveJson", {CLEAR_ON_MANAGER_START, STRING}}, + {"ModelManager_PrevBundle", {PERSISTENT, JSON}}, + {"ModelManager_PrevBundle_USBGPU", {PERSISTENT, JSON}}, {"ModelManager_ClearCache", {CLEAR_ON_MANAGER_START, BOOL}}, {"ModelManager_DownloadIndex", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, INT}}, {"ModelManager_Favs", {PERSISTENT | BACKUP, STRING}}, diff --git a/openpilot/sunnypilot/models/helpers.py b/openpilot/sunnypilot/models/helpers.py index d97d655d5f..e33cc445d1 100644 --- a/openpilot/sunnypilot/models/helpers.py +++ b/openpilot/sunnypilot/models/helpers.py @@ -106,14 +106,31 @@ def _bundle_needs_reset(active_bundle: custom.ModelManagerSP.ModelBundle, availa return False -def validate_active_bundle(params: Params, available_bundles: list[custom.ModelManagerSP.ModelBundle] | None = None) -> None: +def _prev_bundle_key(is_usbgpu: bool) -> str: + return "ModelManager_PrevBundle_USBGPU" if is_usbgpu else "ModelManager_PrevBundle" + + +def validate_active_bundle(params: Params, available_bundles: list[custom.ModelManagerSP.ModelBundle] | None = None, + is_usbgpu: bool = False) -> None: raw_bundle = params.get("ModelManager_ActiveBundle") if not raw_bundle: + prev = params.get(_prev_bundle_key(is_usbgpu)) + if prev and (prev_bundle := get_active_bundle(params, raw_bundle_dict=prev)) is not None: + if not _bundle_needs_reset(prev_bundle, available_bundles): + params.put("ModelManager_ActiveBundle", prev, block=True) return active_bundle = get_active_bundle(params, raw_bundle_dict=raw_bundle) if active_bundle is None or _bundle_needs_reset(active_bundle, available_bundles): cloudlog.warning("Active model bundle invalid; resetting to default") + params.put(_prev_bundle_key(not is_usbgpu), raw_bundle, block=True) + + prev = params.get(_prev_bundle_key(is_usbgpu)) + if prev and (prev_bundle := get_active_bundle(params, raw_bundle_dict=prev)) is not None: + if not _bundle_needs_reset(prev_bundle, available_bundles): + params.put("ModelManager_ActiveBundle", prev, block=True) + return + params.remove("ModelManager_ActiveBundle") params.put("ModelRunnerTypeCache", int(custom.ModelManagerSP.Runner.stock), block=True) diff --git a/openpilot/sunnypilot/models/manager.py b/openpilot/sunnypilot/models/manager.py index 60f01d0c81..e47cf7536c 100644 --- a/openpilot/sunnypilot/models/manager.py +++ b/openpilot/sunnypilot/models/manager.py @@ -267,9 +267,10 @@ class ModelManagerSP: while True: try: self.sm.update(0) - self.available_models = self.model_fetcher.get_available_bundles(self.sm['deviceState'].chestnutPresent) + chestnut_present = self.sm['deviceState'].chestnutPresent + self.available_models = self.model_fetcher.get_available_bundles(chestnut_present) if boot_ticks >= self.BOOT_SETTLE_TICKS: - validate_active_bundle(self.params, self.available_models) + validate_active_bundle(self.params, self.available_models, is_usbgpu=chestnut_present) boot_ticks = min(boot_ticks + 1, self.BOOT_SETTLE_TICKS) self.active_bundle = get_active_bundle(self.params) From b742b96c4482aced861525c33c55e1374aa8bb0c Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Tue, 25 Aug 2026 12:04:39 -0400 Subject: [PATCH 13/67] [MICI] ui: four-state eGPU icon for non-default big models (#1945) * ui: four-state eGPU icon for non-default big models * oops * try this out * align --- openpilot/selfdrive/ui/mici/layouts/home.py | 7 +++- .../ui/sunnypilot/mici/layouts/home.py | 38 +++++++++++++++++++ .../ui/sunnypilot/mici/onroad/hud_renderer.py | 3 ++ openpilot/selfdrive/ui/sunnypilot/ui_state.py | 2 + 4 files changed, 48 insertions(+), 2 deletions(-) diff --git a/openpilot/selfdrive/ui/mici/layouts/home.py b/openpilot/selfdrive/ui/mici/layouts/home.py index ff8d350e08..fd74979404 100644 --- a/openpilot/selfdrive/ui/mici/layouts/home.py +++ b/openpilot/selfdrive/ui/mici/layouts/home.py @@ -248,8 +248,11 @@ class MiciHomeLayout(Widget): # ***** Center-aligned bottom section icons ***** self._experimental_icon.set_visible(ui_state.experimental_mode) - self._egpu_icon.set_visible(ui_state.sm["deviceState"].chestnutPresent and ui_state.usbgpu_compiled) - self._egpu_icon_gray.set_visible(ui_state.sm["deviceState"].chestnutPresent and not ui_state.usbgpu_compiled) + if gui_app.sunnypilot_ui(): + self._set_egpu_visibility() + else: + self._egpu_icon.set_visible(ui_state.sm["deviceState"].chestnutPresent and ui_state.usbgpu_compiled) + self._egpu_icon_gray.set_visible(ui_state.sm["deviceState"].chestnutPresent and not ui_state.usbgpu_compiled) self._mic_icon.set_visible(ui_state.recording_audio) self._body_icon.set_visible(bool(ui_state.is_body)) diff --git a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/home.py b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/home.py index d29e579c52..e2f1b4fb67 100644 --- a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/home.py +++ b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/home.py @@ -4,8 +4,14 @@ 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 math + +import pyray as rl + from openpilot.selfdrive.ui.mici.layouts.home import MiciHomeLayout +from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.system.ui.lib.application import FontWeight +from openpilot.system.ui.widgets.icon_widget import IconWidget from openpilot.system.ui.widgets.label import UnifiedLabel @@ -13,3 +19,35 @@ class MiciHomeLayoutSP(MiciHomeLayout): def __init__(self): super().__init__() self._openpilot_label = UnifiedLabel("sunnypilot", font_size=88, font_weight=FontWeight.AUDIOWIDE, max_width=480, wrap_text=False) + self._egpu_icon_default = IconWidget("icons_mici/egpu.png", (50, 37)) + self._egpu_icon_default.set_visible(False) + self._egpu_icon_orange = IconWidget("icons_mici/egpu_orange.png", (50, 37)) + self._egpu_icon_orange.set_visible(False) + gray_idx = self._status_bar_layout.widgets.index(self._egpu_icon_gray) + self._status_bar_layout.widgets.insert(gray_idx + 1, self._egpu_icon_default) + self._status_bar_layout.widgets.insert(gray_idx + 2, self._egpu_icon_orange) + + def _set_egpu_visibility(self): + chestnut = ui_state.sm["deviceState"].chestnutPresent + if not chestnut: + self._egpu_icon.set_visible(False) + self._egpu_icon_default.set_visible(False) + self._egpu_icon_orange.set_visible(False) + self._egpu_icon_gray.set_visible(False) + return + + big_model_selected = ui_state.usbgpu_compiled or ui_state.model_runner_tinygrad + big_model_failed = ui_state.started and (ui_state.usbgpu_active is False) + loading = ui_state.usbgpu_loading or (big_model_selected and ui_state.started and ui_state.usbgpu_active is None) + + if loading: + self._egpu_icon_default._opacity = 0.35 + 0.65 * (0.5 - 0.5 * math.cos(rl.get_time() * 6.0)) + self._egpu_icon_default.set_visible(True) + self._egpu_icon.set_visible(False) + self._egpu_icon_orange.set_visible(False) + self._egpu_icon_gray.set_visible(False) + else: + self._egpu_icon_default.set_visible(False) + self._egpu_icon.set_visible(big_model_selected and not big_model_failed) + self._egpu_icon_orange.set_visible(big_model_selected and big_model_failed) + self._egpu_icon_gray.set_visible(not big_model_selected) diff --git a/openpilot/selfdrive/ui/sunnypilot/mici/onroad/hud_renderer.py b/openpilot/selfdrive/ui/sunnypilot/mici/onroad/hud_renderer.py index 9d39d01727..ad75f7e969 100644 --- a/openpilot/selfdrive/ui/sunnypilot/mici/onroad/hud_renderer.py +++ b/openpilot/selfdrive/ui/sunnypilot/mici/onroad/hud_renderer.py @@ -7,6 +7,7 @@ See the LICENSE.md file in the root directory for more details. import pyray as rl from openpilot.selfdrive.ui.mici.onroad.hud_renderer import HudRenderer +from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.selfdrive.ui.sunnypilot.onroad.blind_spot_indicators import BlindSpotIndicators @@ -21,6 +22,8 @@ class HudRendererSP(HudRenderer): def _render(self, rect: rl.Rectangle) -> None: super()._render(rect) + if ui_state.usbgpu and not ui_state.usbgpu_compiled and ui_state.model_runner_tinygrad: + self._draw_model_source(rect) self.blind_spot_indicators.render(rect) def _has_blind_spot_detected(self) -> bool: diff --git a/openpilot/selfdrive/ui/sunnypilot/ui_state.py b/openpilot/selfdrive/ui/sunnypilot/ui_state.py index 602830a4db..948253d47d 100644 --- a/openpilot/selfdrive/ui/sunnypilot/ui_state.py +++ b/openpilot/selfdrive/ui/sunnypilot/ui_state.py @@ -43,6 +43,7 @@ class UIStateSP: self.screensaver_enabled: bool = False self.active_bundle = None + self.model_runner_tinygrad: bool = False self.blindspot: bool = False self.chevron_metrics = None self.custom_interactive_timeout: int = 0 @@ -151,6 +152,7 @@ class UIStateSP: self._enforce_constraints() self.active_bundle = self.params.get("ModelManager_ActiveBundle") + self.model_runner_tinygrad = self.active_bundle is not None and self.active_bundle.get("runner") == "tinygrad" self.blindspot = self.params.get_bool("BlindSpot") self.chevron_metrics = self.params.get("ChevronInfo") self.custom_interactive_timeout = self.params.get("InteractivityTimeout", return_default=True) From 78a766eb6145a416d8d95e323a13b6a914b6f9ff Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Tue, 25 Aug 2026 20:38:02 -0400 Subject: [PATCH 14/67] ui: fix scrolling label speed at non-60fps refresh rates (#1967) * ui: fix scrolling label speed at non-60fps refresh rates * send it * nope * more --- openpilot/system/ui/sunnypilot/lib/utils.py | 26 +++++++++++++++------ 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/openpilot/system/ui/sunnypilot/lib/utils.py b/openpilot/system/ui/sunnypilot/lib/utils.py index b9ed152aff..6ae30d13ae 100644 --- a/openpilot/system/ui/sunnypilot/lib/utils.py +++ b/openpilot/system/ui/sunnypilot/lib/utils.py @@ -8,12 +8,26 @@ from collections.abc import Callable import pyray as rl -from openpilot.system.ui.lib.application import FontWeight +from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.sunnypilot.lib.styles import style from openpilot.system.ui.sunnypilot.widgets.list_view import ButtonActionSP -from openpilot.system.ui.widgets.label import UnifiedLabel +from openpilot.system.ui.widgets.label import ScrollState, UnifiedLabel from openpilot.system.ui.widgets.list_view import BUTTON_WIDTH, BUTTON_HEIGHT, TEXT_PADDING, _resolve_value +SCROLL_SPEED = 1.2 # stock is 0.8, boosted 50% to compensate for larger font (50 vs 32) +SCROLL_REFERENCE_FPS = 60. + + +class UnifiedLabelSP(UnifiedLabel): + # stock scroll formula (0.8 / 60 * fps) is inverted — pre-correct so speed is constant px/sec + def _render(self, _): + if self._needs_scroll and self._scroll_state == ScrollState.SCROLLING: + fps = gui_app.target_fps + wrong_step = 0.8 / SCROLL_REFERENCE_FPS * fps + correct_step = SCROLL_SPEED * SCROLL_REFERENCE_FPS / fps + self._scroll_offset -= (correct_step - wrong_step) + super()._render(_) + class NoElideButtonAction(ButtonActionSP): def get_width_hint(self): @@ -21,14 +35,12 @@ class NoElideButtonAction(ButtonActionSP): class ScrollingButtonAction(ButtonActionSP): - """ButtonActionSP whose value scrolls instead of eliding when it doesn't fit.""" - def __init__(self, text: str | Callable[[], str], width: int = style.BUTTON_ACTION_WIDTH, enabled: bool | Callable[[], bool] = True): super().__init__(text=text, width=width, enabled=enabled) - self._value_label = UnifiedLabel("", font_size=style.ITEM_TEXT_FONT_SIZE, font_weight=FontWeight.NORMAL, - text_color=self._value_color, scroll=True, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + self._value_label = UnifiedLabelSP("", font_size=style.ITEM_TEXT_FONT_SIZE, font_weight=FontWeight.NORMAL, + text_color=self._value_color, scroll=True, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) def set_value(self, value: str | Callable[[], str], color: rl.Color = style.ITEM_TEXT_VALUE_COLOR): if self.value != _resolve_value(value, ""): From 1d4558c067bde1cfab37c6f865eb9ddf8f1098d7 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Tue, 25 Aug 2026 20:47:52 -0400 Subject: [PATCH 15/67] [TIZI/TICI] sidebar: show eGPU icon when chestnut is present (#1968) * [tizi/tici] sidebar: show eGPU icon when chestnut is present * matchy match * fix --- openpilot/selfdrive/ui/layouts/sidebar.py | 9 ++++- .../ui/sunnypilot/layouts/sidebar.py | 33 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/openpilot/selfdrive/ui/layouts/sidebar.py b/openpilot/selfdrive/ui/layouts/sidebar.py index f950edaa46..5429a35851 100644 --- a/openpilot/selfdrive/ui/layouts/sidebar.py +++ b/openpilot/selfdrive/ui/layouts/sidebar.py @@ -168,9 +168,16 @@ class Sidebar(Widget, SidebarSP): # Home/Flag button flag_pressed = mouse_down and rl.check_collision_point_rec(mouse_pos, HOME_BTN) button_img = self._flag_img if ui_state.started else self._home_img + button_pos = rl.Vector2(HOME_BTN.x, HOME_BTN.y) + icon_opacity = 1.0 + + if gui_app.sunnypilot_ui(): + button_img, button_pos, icon_opacity = SidebarSP._get_home_icon(self, button_img) tint = Colors.BUTTON_PRESSED if (ui_state.started and flag_pressed) else Colors.BUTTON_NORMAL - rl.draw_texture_ex(button_img, rl.Vector2(HOME_BTN.x, HOME_BTN.y), 0.0, 1.0, tint) + if icon_opacity < 1.0: + tint = rl.Color(tint[0], tint[1], tint[2], int(255 * icon_opacity)) + rl.draw_texture_ex(button_img, button_pos, 0.0, 1.0, tint) # Microphone button if self._recording_audio: diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/sidebar.py b/openpilot/selfdrive/ui/sunnypilot/layouts/sidebar.py index 79bb15dbb8..2c670fa221 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/sidebar.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/sidebar.py @@ -4,11 +4,14 @@ 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 math + import pyray as rl import time from dataclasses import dataclass from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.sunnypilot.sunnylink.api import UNREGISTERED_SUNNYLINK_DONGLE_ID +from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.multilang import tr_noop @@ -18,6 +21,9 @@ METRIC_MARGIN = 30 METRIC_START_Y = 300 HOME_BTN = rl.Rectangle(60, 860, 180, 180) +EGPU_ICON_WIDTH = 180 +EGPU_ICON_HEIGHT = 133 + # Color scheme class Colors: @@ -53,6 +59,10 @@ class MetricData: class SidebarSP: def __init__(self): self._sunnylink_status = MetricData(tr_noop("SUNNYLINK"), tr_noop("OFFLINE"), Colors.WARNING) + self._egpu_green_img = gui_app.texture("icons_mici/egpu_green.png", EGPU_ICON_WIDTH, EGPU_ICON_HEIGHT) + self._egpu_default_img = gui_app.texture("icons_mici/egpu.png", EGPU_ICON_WIDTH, EGPU_ICON_HEIGHT) + self._egpu_orange_img = gui_app.texture("icons_mici/egpu_orange.png", EGPU_ICON_WIDTH, EGPU_ICON_HEIGHT) + self._egpu_gray_img = gui_app.texture("icons_mici/egpu_gray.png", EGPU_ICON_WIDTH, EGPU_ICON_HEIGHT) def _update_sunnylink_status(self): if not ui_state.params.get_bool("SunnylinkEnabled"): @@ -78,6 +88,29 @@ class SidebarSP: self._sunnylink_status.update(tr_noop("SUNNYLINK"), status, color) + def _get_home_icon(self, default_img: rl.Texture) -> tuple[rl.Texture, rl.Vector2, float]: + default_pos = rl.Vector2(HOME_BTN.x, HOME_BTN.y) + if not ui_state.sm["deviceState"].chestnutPresent: + return default_img, default_pos, 1.0 + + big_model_selected = ui_state.usbgpu_compiled or ui_state.model_runner_tinygrad + big_model_failed = ui_state.started and (ui_state.usbgpu_active is False) + loading = ui_state.usbgpu_loading or (big_model_selected and ui_state.started and ui_state.usbgpu_active is None) + + if loading: + icon = self._egpu_default_img + opacity = 0.35 + 0.65 * (0.5 - 0.5 * math.cos(rl.get_time() * 6.0)) + elif big_model_selected and big_model_failed: + icon, opacity = self._egpu_orange_img, 1.0 + elif big_model_selected: + icon, opacity = self._egpu_green_img, 1.0 + else: + icon, opacity = self._egpu_gray_img, 1.0 + + x = HOME_BTN.x + (HOME_BTN.width - icon.width) / 2 + y = HOME_BTN.y + (HOME_BTN.height - icon.height) / 2 + return icon, rl.Vector2(x, y), opacity + def _draw_metrics_w_sunnylink(self, rect: rl.Rectangle, _temp, _panda, _connect): metrics = [_temp, _panda, _connect, self._sunnylink_status] start_y = int(rect.y) + METRIC_START_Y From 15f201caeddcf331ab4ff5f2b909ba0f28c3eeee Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Tue, 25 Aug 2026 21:07:44 -0400 Subject: [PATCH 16/67] ui: use full big model failure detection for sidebar and home eGPU icons (#1969) --- openpilot/selfdrive/ui/sunnypilot/layouts/sidebar.py | 2 +- openpilot/selfdrive/ui/sunnypilot/mici/layouts/home.py | 2 +- openpilot/selfdrive/ui/ui_state.py | 9 +++++++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/sidebar.py b/openpilot/selfdrive/ui/sunnypilot/layouts/sidebar.py index 2c670fa221..7c74c48469 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/sidebar.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/sidebar.py @@ -94,7 +94,7 @@ class SidebarSP: return default_img, default_pos, 1.0 big_model_selected = ui_state.usbgpu_compiled or ui_state.model_runner_tinygrad - big_model_failed = ui_state.started and (ui_state.usbgpu_active is False) + big_model_failed = ui_state.started and ui_state.big_model_failed loading = ui_state.usbgpu_loading or (big_model_selected and ui_state.started and ui_state.usbgpu_active is None) if loading: diff --git a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/home.py b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/home.py index e2f1b4fb67..b261373947 100644 --- a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/home.py +++ b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/home.py @@ -37,7 +37,7 @@ class MiciHomeLayoutSP(MiciHomeLayout): return big_model_selected = ui_state.usbgpu_compiled or ui_state.model_runner_tinygrad - big_model_failed = ui_state.started and (ui_state.usbgpu_active is False) + big_model_failed = ui_state.started and ui_state.big_model_failed loading = ui_state.usbgpu_loading or (big_model_selected and ui_state.started and ui_state.usbgpu_active is None) if loading: diff --git a/openpilot/selfdrive/ui/ui_state.py b/openpilot/selfdrive/ui/ui_state.py index e0aca74ff2..7e2a2494ea 100644 --- a/openpilot/selfdrive/ui/ui_state.py +++ b/openpilot/selfdrive/ui/ui_state.py @@ -112,6 +112,15 @@ class UIState(UIStateSP): def add_on_body_changed_callbacks(self, callback: Callable[[], None]): self._on_body_changed_callbacks.append(callback) + @property + def big_model_failed(self) -> bool: + # Mirrors the onroad HUD's four-condition check so sidebar and home icons reflect the same failure states + return (self.usbgpu_active is False or + not self.sm['deviceState'].chestnutPresent or + (self.usbgpu_active is True and self.sm.recv_frame['modelV2'] > self.started_frame and + not self.sm.alive['modelV2']) or + (self.usbgpu_active is None and self.sm.recv_frame['modelV2'] > self.started_frame)) + @property def engaged(self) -> bool: return self.started and (self.sm["selfdriveState"].enabled or self.sm["selfdriveStateSP"].mads.enabled) From da28afca91a1ec0e5919a483d99508512886105c Mon Sep 17 00:00:00 2001 From: Nayan Date: Wed, 26 Aug 2026 02:34:02 -0400 Subject: [PATCH 17/67] models: dual-slot backend (qcom/usbgpu) with ref-based downloads (#1966) * models: dual-slot backend (qcom/usbgpu) with ref-based downloads * models: restore get_active_source and the usbgpu-to-qcom fallback * models: fix per-slot validation and cap mismatched-source refetches * ui/models: select models by ref and seed the usbgpu slot on migration * models: drop defensive attribute guards on capnp bundles * models: remove vestigial fetcher state and dead fallbacks * models: resolve the active bundle from the active source slot only * models: pass the usbgpu kwarg through the modeld test stubs * models: resolve the displayed model from the active slot in ui_state * models: correct the validation memo type hint * models: drop docstrings that restate the function name --------- Co-authored-by: Jason Wen --- openpilot/common/params_keys.h | 7 +- .../ui/sunnypilot/layouts/settings/models.py | 18 +- .../ui/sunnypilot/mici/layouts/models.py | 14 +- openpilot/selfdrive/ui/sunnypilot/ui_state.py | 5 +- openpilot/sunnypilot/modeld_v2/modeld.py | 2 +- .../sunnypilot/modeld_v2/tests/helpers.py | 4 +- .../tests/test_combined_pkl_loader.py | 4 +- openpilot/sunnypilot/models/fetcher.py | 106 +++-- openpilot/sunnypilot/models/helpers.py | 107 +++-- openpilot/sunnypilot/models/manager.py | 62 ++- .../models/tests/test_manager_download.py | 431 ++++++++++++++++++ .../models/tests/test_tinygrad_ref.py | 4 +- openpilot/sunnypilot/sunnylink/statsd.py | 1 + .../sunnypilot/system/params_migration.py | 18 + openpilot/sunnypilot/system/tests/__init__.py | 0 .../system/tests/test_params_migration.py | 36 ++ 16 files changed, 698 insertions(+), 121 deletions(-) create mode 100644 openpilot/sunnypilot/system/tests/__init__.py create mode 100644 openpilot/sunnypilot/system/tests/test_params_migration.py diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index 111099fce9..4d8ffb64eb 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -195,11 +195,10 @@ inline static std::unordered_map keys = { // Model Manager params {"ModelManager_ActiveBundle", {PERSISTENT, JSON}}, - {"ModelManager_ActiveJson", {CLEAR_ON_MANAGER_START, STRING}}, - {"ModelManager_PrevBundle", {PERSISTENT, JSON}}, - {"ModelManager_PrevBundle_USBGPU", {PERSISTENT, JSON}}, + {"ModelManager_ActiveBundleUSBGPU", {PERSISTENT, JSON}}, + {"ModelManager_ActiveJson", {CLEAR_ON_MANAGER_START, JSON}}, {"ModelManager_ClearCache", {CLEAR_ON_MANAGER_START, BOOL}}, - {"ModelManager_DownloadIndex", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, INT}}, + {"ModelManager_DownloadRef", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, STRING}}, {"ModelManager_Favs", {PERSISTENT | BACKUP, STRING}}, {"ModelManager_LastSyncTime", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, INT, "0"}}, {"ModelManager_LastSyncTime_USBGPU", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, INT, "0"}}, diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py index e4a6bea6e0..93668014f6 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py @@ -11,6 +11,8 @@ import pyray as rl from openpilot.cereal import custom from openpilot.sunnypilot.models.default_model import get_default_model +from openpilot.sunnypilot.models.fetcher import ModelFetcher +from openpilot.sunnypilot.models.helpers import ACTIVE_BUNDLE_KEYS from openpilot.common.constants import CV from openpilot.selfdrive.ui.ui_state import device, ui_state from openpilot.system.ui.lib.multilang import tr @@ -68,7 +70,7 @@ class ModelsLayout(Widget): callback=self._clear_cache ) - self.cancel_download_item = button_item(tr("Cancel Download"), tr("Cancel"), "", lambda: ui_state.params.remove("ModelManager_DownloadIndex")) + self.cancel_download_item = button_item(tr("Cancel Download"), tr("Cancel"), "", lambda: ui_state.params.remove("ModelManager_DownloadRef")) self.lane_turn_value_control = option_item_sp(tr("Adjust Lane Turn Speed"), "LaneTurnValue", 500, 2000, tr("Set the maximum speed for lane turn desires. Default is 19 mph."), @@ -146,7 +148,7 @@ class ModelsLayout(Widget): if not bundle: return - self.cancel_download_item.set_visible(bool(self.model_manager.selectedBundle) and ui_state.params.get("ModelManager_DownloadIndex") is not None) + self.cancel_download_item.set_visible(bool(self.model_manager.selectedBundle) and ui_state.params.get("ModelManager_DownloadRef") is not None) if (current_time := time.monotonic()) - self.last_cache_calc_time > 0.5: self.last_cache_calc_time = current_time @@ -187,9 +189,10 @@ class ModelsLayout(Widget): return selected_ref = self.model_dialog.selection_ref if selected_ref == "Default": - ui_state.params.remove("ModelManager_ActiveBundle") + source = ModelFetcher.active_source(ui_state.sm["deviceState"].chestnutPresent) + ui_state.params.remove(ACTIVE_BUNDLE_KEYS[source]) elif selected_bundle := next((bundle for bundle in self.model_manager.availableBundles if bundle.ref == selected_ref), None): - ui_state.params.put("ModelManager_DownloadIndex", selected_bundle.index) + ui_state.params.put("ModelManager_DownloadRef", selected_bundle.ref) self.model_dialog = None @staticmethod @@ -227,7 +230,7 @@ class ModelsLayout(Widget): advanced_controls: bool = ui_state.params.get_bool("ShowAdvancedControls") turn_desire: bool = ui_state.params.get_bool("LaneTurnDesire") live_delay: bool = ui_state.params.get_bool("LagdToggle") - camera_offset: bool = ui_state.params.get("ModelManager_ActiveBundle") is not None + camera_offset: bool = ui_state.active_bundle is not None self.lane_turn_desire_toggle.action_item.set_state(turn_desire) self.lane_turn_value_control.set_visible(turn_desire and advanced_controls) @@ -241,8 +244,9 @@ class ModelsLayout(Widget): self._update_lagd_description(live_delay) self.model_manager = ui_state.sm["modelManagerSP"] self._handle_bundle_download_progress() - default_label = f"{get_default_model()} (Default)" - active_name = self.model_manager.activeBundle.displayName if self.model_manager and self.model_manager.activeBundle.ref else default_label + # read the slot through ui_state, not modelManagerSP: the manager republishes a + # tick after a chestnut change, and the stale bundle flashes the wrong model + active_name = (ui_state.active_bundle or {}).get("displayName") or f"{get_default_model()} (Default)" self.current_model_item.action_item.set_value(active_name) if not ui_state.is_offroad(): diff --git a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py index 6eff456559..87073d531f 100644 --- a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py @@ -8,6 +8,8 @@ import pyray as rl from openpilot.cereal import custom from openpilot.sunnypilot.models.default_model import get_default_model +from openpilot.sunnypilot.models.fetcher import ModelFetcher +from openpilot.sunnypilot.models.helpers import ACTIVE_BUNDLE_KEYS from openpilot.selfdrive.ui.mici.widgets.button import BigButton from openpilot.selfdrive.ui.sunnypilot.layouts.settings.models import ModelsLayout from openpilot.selfdrive.ui.ui_state import ui_state, device @@ -60,7 +62,7 @@ class ModelsLayoutMici(NavScroller): self.select_model_btn.set_click_callback(self._show_folders) self.cancel_download_btn = BigButton(tr("cancel download")) - self.cancel_download_btn.set_click_callback(lambda: ui_state.params.remove("ModelManager_DownloadIndex")) + 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._scroller.add_widgets(self.main_items) @@ -113,11 +115,12 @@ class ModelsLayoutMici(NavScroller): gui_app.pop_widgets_to(self) def _select_model(self, bundle): - ui_state.params.put("ModelManager_DownloadIndex", bundle.index) + ui_state.params.put("ModelManager_DownloadRef", bundle.ref) self._pop_to_main() def _select_default(self): - ui_state.params.remove("ModelManager_ActiveBundle") + source = ModelFetcher.active_source(ui_state.sm["deviceState"].chestnutPresent) + ui_state.params.remove(ACTIVE_BUNDLE_KEYS[source]) self._pop_to_main() def _select_folder(self, folder_name): @@ -162,8 +165,9 @@ class ModelsLayoutMici(NavScroller): self._was_downloading = is_downloading self.current_model_info.current_model_header.set_text(tr("active model")) - default_model_text = f"{get_default_model()} (Default)".lower() - model_text = manager.activeBundle.displayName.lower() if manager.activeBundle.ref else default_model_text + # read the slot through ui_state, not modelManagerSP: the manager republishes a + # tick after a chestnut change, and the stale bundle flashes the wrong model + model_text = ((ui_state.active_bundle or {}).get("displayName") or f"{get_default_model()} (Default)").lower() self.current_model_info.current_model_text.set_text(model_text) self.current_model_info.info_header.set_text(tr("cache size")) self.current_model_info.info_text.set_text(f"{ModelsLayout.calculate_cache_size():.2f} MB") diff --git a/openpilot/selfdrive/ui/sunnypilot/ui_state.py b/openpilot/selfdrive/ui/sunnypilot/ui_state.py index 948253d47d..9bed533d3f 100644 --- a/openpilot/selfdrive/ui/sunnypilot/ui_state.py +++ b/openpilot/selfdrive/ui/sunnypilot/ui_state.py @@ -10,6 +10,7 @@ from openpilot.cereal import messaging, log, custom from opendbc.car.structs import car from openpilot.common.params import Params from openpilot.selfdrive.ui.sunnypilot.layouts.settings.display import OnroadBrightness +from openpilot.sunnypilot.models.helpers import ACTIVE_BUNDLE_KEYS, get_active_source from openpilot.sunnypilot.sunnylink.sunnylink_state import SunnylinkState from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.sunnypilot.widgets.screen_saver import ScreenSaverSP @@ -151,7 +152,9 @@ class UIStateSP: self.has_icbm = self.CP_SP.intelligentCruiseButtonManagementAvailable and self.params.get_bool("IntelligentCruiseButtonManagement") self._enforce_constraints() - self.active_bundle = self.params.get("ModelManager_ActiveBundle") + source = get_active_source(usbgpu=self.usbgpu, usbgpu_active=self.usbgpu_active, + usbgpu_loading=self.usbgpu_loading, offroad=self.is_offroad()) + self.active_bundle = self.params.get(ACTIVE_BUNDLE_KEYS[source]) self.model_runner_tinygrad = self.active_bundle is not None and self.active_bundle.get("runner") == "tinygrad" self.blindspot = self.params.get_bool("BlindSpot") self.chevron_metrics = self.params.get("ChevronInfo") diff --git a/openpilot/sunnypilot/modeld_v2/modeld.py b/openpilot/sunnypilot/modeld_v2/modeld.py index 9f3d709537..d180012279 100755 --- a/openpilot/sunnypilot/modeld_v2/modeld.py +++ b/openpilot/sunnypilot/modeld_v2/modeld.py @@ -91,7 +91,7 @@ class ModelState(ModelStateBase): if env_pkl and os.path.exists(env_pkl): model_bundle = None else: - model_bundle = get_active_bundle() + model_bundle = get_active_bundle(usbgpu=usbgpu) self.generation = model_bundle.generation if model_bundle is not None else None overrides = {override.key: override.value for override in model_bundle.overrides} if model_bundle else {} diff --git a/openpilot/sunnypilot/modeld_v2/tests/helpers.py b/openpilot/sunnypilot/modeld_v2/tests/helpers.py index ee59e82785..6e66bf771a 100644 --- a/openpilot/sunnypilot/modeld_v2/tests/helpers.py +++ b/openpilot/sunnypilot/modeld_v2/tests/helpers.py @@ -190,8 +190,8 @@ def tmp_path(): def patch_modeld(monkeypatch): def _patch(bundle): - monkeypatch.setattr(helpers, 'get_active_bundle', lambda params=None: bundle) - monkeypatch.setattr(modeld_module, 'get_active_bundle', lambda params=None: bundle) + monkeypatch.setattr(helpers, 'get_active_bundle', lambda params=None, *, usbgpu=None: bundle) + monkeypatch.setattr(modeld_module, 'get_active_bundle', lambda params=None, *, usbgpu=None: bundle) return _patch diff --git a/openpilot/sunnypilot/modeld_v2/tests/test_combined_pkl_loader.py b/openpilot/sunnypilot/modeld_v2/tests/test_combined_pkl_loader.py index 3396649a1d..ccd8cbc7f3 100644 --- a/openpilot/sunnypilot/modeld_v2/tests/test_combined_pkl_loader.py +++ b/openpilot/sunnypilot/modeld_v2/tests/test_combined_pkl_loader.py @@ -59,8 +59,8 @@ class TestFindDrivingPkl(OpenpilotTestCase): class TestModelStateCombinedInit(OpenpilotTestCase): def test_asserts_when_no_pkl(self, monkeypatch): bundle = DummyBundle(models=[], is_20hz=True) - monkeypatch.setattr(helpers, 'get_active_bundle', lambda params=None: bundle) - monkeypatch.setattr(modeld_module, 'get_active_bundle', lambda params=None: bundle) + monkeypatch.setattr(helpers, 'get_active_bundle', lambda params=None, *, usbgpu=None: bundle) + monkeypatch.setattr(modeld_module, 'get_active_bundle', lambda params=None, *, usbgpu=None: bundle) with self.assertRaisesRegex(AssertionError, "No driving pkl found"): ModelState(cam_w=CAM_W, cam_h=CAM_H) diff --git a/openpilot/sunnypilot/models/fetcher.py b/openpilot/sunnypilot/models/fetcher.py index c9e86edd0c..1bbfb02f70 100644 --- a/openpilot/sunnypilot/models/fetcher.py +++ b/openpilot/sunnypilot/models/fetcher.py @@ -141,41 +141,50 @@ class ModelFetcher: MODEL_URL = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_v21.json" MODEL_URL_USBGPU = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_usbgpu_v22.json" + MODEL_SOURCES = { + "qcom": (MODEL_URL, ""), + "usbgpu": (MODEL_URL_USBGPU, "_USBGPU"), + } + def __init__(self, params: Params): self.params = params self.model_parser = ModelParser() - self._is_usbgpu: bool | None = None - self.model_cache = ModelCache(params) - self.model_url = self.MODEL_URL + self.model_caches = { + source: ModelCache(params, suffix=suffix) + for source, (_, suffix) in self.MODEL_SOURCES.items() + } + self._refetched: set[str] = set() + self.params.put("ModelManager_ActiveJson", { + "qcom": self.MODEL_URL, + "usbgpu": self.MODEL_URL_USBGPU, + }, block=True) - def _update_model_source(self, chestnut_present: bool) -> None: - """Updates what json to use based on chestnut hardware presence via deviceState""" - is_usbgpu = chestnut_present - if is_usbgpu != self._is_usbgpu: - self._is_usbgpu = is_usbgpu - self.model_cache = ModelCache(self.params, suffix="_USBGPU" if is_usbgpu else "") - self.model_url = self.MODEL_URL_USBGPU if is_usbgpu else self.MODEL_URL - self.params.put("ModelManager_ActiveJson", self.model_url, block=True) + @staticmethod + def active_source(chestnut_present: bool) -> str: + return "usbgpu" if chestnut_present else "qcom" - def _fetch_and_cache_models(self) -> list[custom.ModelManagerSP.ModelBundle] | None: + def _fetch_and_cache_models(self, source: str) -> list[custom.ModelManagerSP.ModelBundle] | None: """Fetches fresh model data from remote and updates cache. Returns None on transport errors. Raises on 404 and other fatal HTTP errors. """ + model_url, _ = self.MODEL_SOURCES[source] try: - response = requests.get(self.model_url, timeout=10) + response = requests.get(model_url, timeout=10) # Explicitly handle 404 differently if response.status_code == 404: - cloudlog.error(f"Models URL returned 404 Not Found: {self.model_url}") - raise HTTPError(f"404 Not Found: {self.model_url}", response=response) + cloudlog.error(f"Models URL returned 404 Not Found: {model_url}") + raise HTTPError(f"404 Not Found: {model_url}", response=response) # Raise for any other 4xx/5xx response.raise_for_status() json_data = response.json() - self.model_cache.set(json_data) - cloudlog.debug("Successfully updated models cache") - return self.model_parser.parse_models(json_data) + parsed = self.model_parser.parse_models(json_data) + if parsed: + self.model_caches[source].set(json_data) + cloudlog.debug(f"Successfully updated models cache for {source}") + return parsed except ConnectionError as e: cloudlog.warning(f"DNS/connection error while fetching models: {e}") @@ -188,16 +197,40 @@ class ModelFetcher: return None - def get_available_bundles(self, chestnut_present: bool = False) -> list[custom.ModelManagerSP.ModelBundle]: - """Gets the list of available models, with smart cache handling""" - self._update_model_source(chestnut_present) - cached_data, is_expired = self.model_cache.get() + @staticmethod + def _cache_matches_source(source: str, cached_data: dict) -> bool: + bundles = cached_data.get("bundles", []) + if source == "usbgpu": + return any(bundle.get("is_big") is True for bundle in bundles) + return not any(bundle.get("is_big") is True for bundle in bundles) + + def get_bundles_for_source(self, source: str) -> list[custom.ModelManagerSP.ModelBundle]: + if source not in self.MODEL_SOURCES: + cloudlog.warning(f"Unknown model source: {source}") + return [] + + cached_data, is_expired = self.model_caches[source].get() if cached_data and not is_expired: - cloudlog.debug("Using valid cached models data") - return self.model_parser.parse_models(cached_data) + # a source is refetched over a mismatch at most once per process: if the fresh + # manifest still mismatches, the URL is authoritative and the cache is trusted + if self._cache_matches_source(source, cached_data) or source in self._refetched: + try: + parsed = self.model_parser.parse_models(cached_data) + except Exception: + cloudlog.warning(f"Failed to parse cached models for {source}; refetching", exc_info=True) + else: + if parsed: + cloudlog.debug(f"Using valid cached models data for source {source}") + return parsed + # a source-matching cache that yields no valid bundles is stale (e.g. an old + # manifest version) - do not trust it, refetch so the source is repopulated + cloudlog.warning(f"Cached models for {source} have no valid bundles; refetching") + else: + self._refetched.add(source) + cloudlog.warning(f"Cached models for {source} not valid; refetching once") - fetched_bundles = self._fetch_and_cache_models() + fetched_bundles = self._fetch_and_cache_models(source) if fetched_bundles is not None: return fetched_bundles @@ -205,14 +238,33 @@ class ModelFetcher: cloudlog.warning("Failed to fetch fresh data and no cache available") cloudlog.warning("Failed to fetch fresh data. Using expired cache as fallback") - return self.model_parser.parse_models(cached_data) + try: + return self.model_parser.parse_models(cached_data) + except Exception: + return [] + + +def get_cached_bundles(params: Params, source: str) -> list[custom.ModelManagerSP.ModelBundle]: + + if source not in ModelFetcher.MODEL_SOURCES: + cloudlog.warning(f"Unknown model source: {source}") + return [] + _, suffix = ModelFetcher.MODEL_SOURCES[source] + cached_data = params.get(f"ModelManager_ModelsCache{suffix}") + if not cached_data: + return [] + try: + return ModelParser.parse_models(cached_data) + except Exception as e: + cloudlog.warning(f"Failed to parse cached models for source {source}: {e}") + return [] if __name__ == "__main__": from openpilot.selfdrive.modeld.helpers import usbgpu_present params = Params() model_fetcher = ModelFetcher(params) - bundles = model_fetcher.get_available_bundles(chestnut_present=usbgpu_present()) + bundles = model_fetcher.get_bundles_for_source(ModelFetcher.active_source(usbgpu_present())) for bundle in bundles: for model in bundle.models: model_overrides = {override.key: override.value for override in bundle.overrides} diff --git a/openpilot/sunnypilot/models/helpers.py b/openpilot/sunnypilot/models/helpers.py index e33cc445d1..707b86f722 100644 --- a/openpilot/sunnypilot/models/helpers.py +++ b/openpilot/sunnypilot/models/helpers.py @@ -16,6 +16,7 @@ from openpilot.common.params import Params from openpilot.common.swaglog import cloudlog from openpilot.sunnypilot.models.constants import Meta, MetaSimPose, MetaTombRaider from openpilot.common.hardware.hw import Paths +from openpilot.selfdrive.modeld.helpers import usbgpu_present # SET ME TO THE EXACT JSON VERSION WE SET IN SUNNYPILOT_MODELS REPO REQUIRED_JSON_VERSION = 18 @@ -24,6 +25,12 @@ CUSTOM_MODEL_PATH = Paths.model_root() METADATA_PATH = Path(__file__).parent / '../models/supercombo_metadata.pkl' ModelManager = custom.ModelManagerSP +ACTIVE_BUNDLE_KEYS = { + "qcom": "ModelManager_ActiveBundle", + "usbgpu": "ModelManager_ActiveBundleUSBGPU", +} +_LAST_VALIDATED_RAW: dict[str, dict | None] = {} + def _compute_hash(file_path: str) -> str | None: from openpilot.common.file_chunker import open_file_chunked @@ -97,55 +104,81 @@ def _bundle_needs_reset(active_bundle: custom.ModelManagerSP.ModelBundle, availa return True if active_bundle.minimumSelectorVersion != matching_bundle.minimumSelectorVersion: return True - if active_bundle.runner.raw != matching_bundle.runner.raw: + if active_bundle.runner != matching_bundle.runner: return True if set(_bundle_artifacts(active_bundle)) != set(_bundle_artifacts(matching_bundle)): return True - # missing files trigger re-download, not selection reset - return False + return not _bundle_is_valid_locally(active_bundle) -def _prev_bundle_key(is_usbgpu: bool) -> str: - return "ModelManager_PrevBundle_USBGPU" if is_usbgpu else "ModelManager_PrevBundle" - - -def validate_active_bundle(params: Params, available_bundles: list[custom.ModelManagerSP.ModelBundle] | None = None, - is_usbgpu: bool = False) -> None: - raw_bundle = params.get("ModelManager_ActiveBundle") - if not raw_bundle: - prev = params.get(_prev_bundle_key(is_usbgpu)) - if prev and (prev_bundle := get_active_bundle(params, raw_bundle_dict=prev)) is not None: - if not _bundle_needs_reset(prev_bundle, available_bundles): - params.put("ModelManager_ActiveBundle", prev, block=True) - return - - active_bundle = get_active_bundle(params, raw_bundle_dict=raw_bundle) - if active_bundle is None or _bundle_needs_reset(active_bundle, available_bundles): - cloudlog.warning("Active model bundle invalid; resetting to default") - params.put(_prev_bundle_key(not is_usbgpu), raw_bundle, block=True) - - prev = params.get(_prev_bundle_key(is_usbgpu)) - if prev and (prev_bundle := get_active_bundle(params, raw_bundle_dict=prev)) is not None: - if not _bundle_needs_reset(prev_bundle, available_bundles): - params.put("ModelManager_ActiveBundle", prev, block=True) - return - - params.remove("ModelManager_ActiveBundle") - params.put("ModelRunnerTypeCache", int(custom.ModelManagerSP.Runner.stock), block=True) - - -def get_active_bundle(params: Params | None = None, raw_bundle_dict: dict | bytes | None = None) -> "custom.ModelManagerSP.ModelBundle | None": - params = params or Params() +def _parse_active_bundle(raw_bundle) -> "custom.ModelManagerSP.ModelBundle | None": try: - active_bundle_dict = raw_bundle_dict if raw_bundle_dict is not None else (params.get("ModelManager_ActiveBundle") or {}) - if isinstance(active_bundle_dict, dict) and active_bundle_dict and is_bundle_version_compatible(active_bundle_dict): - return custom.ModelManagerSP.ModelBundle(**active_bundle_dict) + if isinstance(raw_bundle, dict) and raw_bundle and is_bundle_version_compatible(raw_bundle): + return custom.ModelManagerSP.ModelBundle(**raw_bundle) except Exception: pass return None +def get_selected_bundle(params: Params | None = None, source: str = "qcom") -> "custom.ModelManagerSP.ModelBundle | None": + params = params or Params() + return _parse_active_bundle(params.get(ACTIVE_BUNDLE_KEYS[source])) + + +def get_active_source(usbgpu: bool | None = None, usbgpu_active: bool | None = None, + usbgpu_loading: bool | None = None, offroad: bool | None = None) -> str: + if usbgpu is None: + usbgpu = usbgpu_present() + state_valid = usbgpu_active is not None or usbgpu_loading is not None or offroad is not None + big_active = usbgpu and (not state_valid or usbgpu_active or usbgpu_loading or offroad) + return "usbgpu" if big_active else "qcom" + + +def get_active_bundle(params: Params | None = None, *, usbgpu: bool | None = None) -> "custom.ModelManagerSP.ModelBundle | None": + # no cross-slot fallback: an empty active slot means the hardware default, which + # only stock modeld can run - modeld_v2 requires a real bundle + params = params or Params() + return get_selected_bundle(params, get_active_source(usbgpu=usbgpu)) + + +def resolve_bundle_by_ref( + ref: str, source_bundles: dict[str, list[custom.ModelManagerSP.ModelBundle]], +) -> "tuple[custom.ModelManagerSP.ModelBundle, str] | None": + for source, bundles in source_bundles.items(): + for bundle in bundles: + if bundle.ref == ref: + return bundle, source + return None + + +def _validate_active_bundle(params: Params, source: str, available_bundles: list[custom.ModelManagerSP.ModelBundle] | None = None) -> None: + global _LAST_VALIDATED_RAW + + key = ACTIVE_BUNDLE_KEYS[source] + raw_bundle = params.get(key) + if not raw_bundle: + return + + if _LAST_VALIDATED_RAW.get(key) == raw_bundle: + return + + active_bundle = _parse_active_bundle(raw_bundle) + if active_bundle is None or _bundle_needs_reset(active_bundle, available_bundles): + cloudlog.warning(f"Active model bundle invalid for {source}; resetting to default") + params.remove(key) + _LAST_VALIDATED_RAW[key] = None + else: + _LAST_VALIDATED_RAW[key] = raw_bundle + + +def validate_active_bundles(params: Params, source_bundles: dict[str, list[custom.ModelManagerSP.ModelBundle]]) -> None: + # an empty list means the fetch failed, not that the catalog dropped the bundle + for source, bundles in source_bundles.items(): + _validate_active_bundle(params, source, bundles or None) + get_active_model_runner(params, force_check=True) + + def get_active_model_runner(params: Params | None = None, force_check: bool = False) -> int: params = params or Params() cached_runner_type = params.get("ModelRunnerTypeCache") diff --git a/openpilot/sunnypilot/models/manager.py b/openpilot/sunnypilot/models/manager.py index e47cf7536c..2405566d55 100644 --- a/openpilot/sunnypilot/models/manager.py +++ b/openpilot/sunnypilot/models/manager.py @@ -17,7 +17,8 @@ from openpilot.common.hardware.hw import Paths from openpilot.cereal import messaging, custom from openpilot.sunnypilot.models.fetcher import ModelFetcher -from openpilot.sunnypilot.models.helpers import get_active_bundle, validate_active_bundle, verify_file +from openpilot.sunnypilot.models.helpers import (ACTIVE_BUNDLE_KEYS, get_active_bundle, get_selected_bundle, + resolve_bundle_by_ref, validate_active_bundles, verify_file) # (connect, read) seconds. read is per-request inactivity, not a total cap DOWNLOAD_TIMEOUT = (30, 30) @@ -31,9 +32,11 @@ class ModelManagerSP: self.model_fetcher = ModelFetcher(self.params) self.pm = messaging.PubMaster(["modelManagerSP"]) self.sm = messaging.SubMaster(["deviceState"]) + self.chestnut_present = False self.available_models: list[custom.ModelManagerSP.ModelBundle] = [] + self.source_models: dict[str, list[custom.ModelManagerSP.ModelBundle]] = {} self.selected_bundle: custom.ModelManagerSP.ModelBundle = None - self.active_bundle: custom.ModelManagerSP.ModelBundle = get_active_bundle(self.params) + self.active_bundle: custom.ModelManagerSP.ModelBundle = get_active_bundle(self.params, usbgpu=self.chestnut_present) self._chunk_size = 128 * 1000 # 128 KB chunks self._download_start_times: dict[str, float] = {} # Track start time per model @@ -77,7 +80,7 @@ class ModelManagerSP: f.write(chunk) bytes_downloaded += len(chunk) - if self.params.get("ModelManager_DownloadIndex") is None: + if self.params.get("ModelManager_DownloadRef") is None: raise Exception("Download cancelled") if total_size > 0: @@ -115,7 +118,7 @@ class ModelManagerSP: for data in response.iter_content(chunk_size=self._chunk_size): f.write(data) chunk_downloaded += len(data) - if self.params.get("ModelManager_DownloadIndex") is None: + if self.params.get("ModelManager_DownloadRef") is None: raise Exception("Download cancelled") intra = chunk_downloaded / max(chunk_size, 1) progress = min(99.0, ((i + intra) / num_chunks) * 100) @@ -217,8 +220,7 @@ class ModelManagerSP: model_manager_state.availableBundles = self.available_models self.pm.send('modelManagerSP', msg) - async def _download_bundle(self, model_bundle: custom.ModelManagerSP.ModelBundle, destination_path: str) -> None: - """Downloads all models in a bundle""" + async def _download_bundle(self, model_bundle: custom.ModelManagerSP.ModelBundle, destination_path: str, source: str) -> None: self.selected_bundle = model_bundle self.selected_bundle.status = custom.ModelManagerSP.DownloadStatus.downloading for model in self.selected_bundle.models: @@ -240,10 +242,9 @@ class ModelManagerSP: seen_artifacts.add(artifact.fileName) await self._process_artifact(artifact, destination_path) - self.active_bundle = self.selected_bundle - self.active_bundle.status = custom.ModelManagerSP.DownloadStatus.downloaded - self.params.put("ModelManager_ActiveBundle", self.active_bundle.to_dict(), block=True) - self.selected_bundle = None + self.selected_bundle.status = custom.ModelManagerSP.DownloadStatus.downloaded + self.params.put(ACTIVE_BUNDLE_KEYS[source], model_bundle.to_dict(), block=True) + self.active_bundle = get_active_bundle(self.params, usbgpu=self.chestnut_present) except Exception: if self.selected_bundle is not None: @@ -253,37 +254,32 @@ class ModelManagerSP: finally: self._report_status() - def download(self, model_bundle: custom.ModelManagerSP.ModelBundle, destination_path: str) -> None: + def download(self, model_bundle: custom.ModelManagerSP.ModelBundle, destination_path: str, source: str) -> None: """Main entry point for downloading a model bundle""" - asyncio.run(self._download_bundle(model_bundle, destination_path)) - - BOOT_SETTLE_TICKS = 10 # seconds at 1 Hz before validating active bundle + asyncio.run(self._download_bundle(model_bundle, destination_path, source)) def main_thread(self) -> None: """Main thread for model management""" rk = Ratekeeper(1, print_delay_threshold=None) - boot_ticks = 0 while True: try: self.sm.update(0) - chestnut_present = self.sm['deviceState'].chestnutPresent - self.available_models = self.model_fetcher.get_available_bundles(chestnut_present) - if boot_ticks >= self.BOOT_SETTLE_TICKS: - validate_active_bundle(self.params, self.available_models, is_usbgpu=chestnut_present) - boot_ticks = min(boot_ticks + 1, self.BOOT_SETTLE_TICKS) - self.active_bundle = get_active_bundle(self.params) + self.chestnut_present = self.sm['deviceState'].chestnutPresent + self.source_models = {source: self.model_fetcher.get_bundles_for_source(source) for source in ModelFetcher.MODEL_SOURCES} + self.available_models = self.source_models[ModelFetcher.active_source(self.chestnut_present)] + validate_active_bundles(self.params, self.source_models) + self.active_bundle = get_active_bundle(self.params, usbgpu=self.chestnut_present) - if (index_to_download := self.params.get("ModelManager_DownloadIndex")) is not None: - if self.active_bundle and self.active_bundle.index == index_to_download: - self.params.remove("ModelManager_DownloadIndex") - elif model_to_download := next((model for model in self.available_models if model.index == index_to_download), None): + if (ref_to_download := self.params.get("ModelManager_DownloadRef")) is not None: + if resolved := resolve_bundle_by_ref(ref_to_download, self.source_models): + model_to_download, source = resolved try: - self.download(model_to_download, Paths.model_root()) + self.download(model_to_download, Paths.model_root(), source) except Exception as e: cloudlog.exception(e) finally: - self.params.remove("ModelManager_DownloadIndex") + self.params.remove("ModelManager_DownloadRef") self.selected_bundle = None if self.params.get("ModelManager_ClearCache"): @@ -302,12 +298,14 @@ class ModelManagerSP: Clears the model cache directory of all files except those in the active model bundle. """ - # Get list of files used by active model bundle + # Get list of files used by both slots' selected bundles (either may become + # the truly active bundle depending on hardware availability) active_files = [] - if self.active_bundle is not None: # When the default model is active - for model in self.active_bundle.models: - if hasattr(model, 'artifact') and model.artifact.fileName: - active_files.append(model.artifact.fileName) + for source in ACTIVE_BUNDLE_KEYS: + if selected_bundle := get_selected_bundle(self.params, source): + for model in selected_bundle.models: + if model.artifact.fileName: + active_files.append(model.artifact.fileName) # Remove all files except active ones (including their chunk files) model_dir = Paths.model_root() diff --git a/openpilot/sunnypilot/models/tests/test_manager_download.py b/openpilot/sunnypilot/models/tests/test_manager_download.py index 67fb9023af..d74deb03e6 100644 --- a/openpilot/sunnypilot/models/tests/test_manager_download.py +++ b/openpilot/sunnypilot/models/tests/test_manager_download.py @@ -11,6 +11,7 @@ import http.server import os import tempfile import threading +import time import unittest from typing import Any from unittest import mock @@ -23,6 +24,10 @@ from openpilot.common.test import OpenpilotTestCase from openpilot.common.file_chunker import get_chunk_name, get_manifest_path from openpilot.selfdrive.test.helpers import http_server_context from openpilot.sunnypilot.models import manager as manager_module +from openpilot.sunnypilot.models.fetcher import ModelFetcher, get_cached_bundles +from openpilot.sunnypilot.models import helpers +from openpilot.sunnypilot.models.helpers import (get_active_bundle, get_active_source, get_selected_bundle, + resolve_bundle_by_ref, validate_active_bundles) from openpilot.sunnypilot.models.manager import ModelManagerSP CHUNK_BODIES = [b'A' * 5000, b'B' * 5000, b'C' * 3000] @@ -103,6 +108,7 @@ class ManagerDownloadTestBase(OpenpilotTestCase): self.manager.selected_bundle = None self.manager.active_bundle = None self.manager.available_models = [] + self.manager.chestnut_present = False self.manager._chunk_size = 1024 self.manager._download_start_times = {} @@ -249,6 +255,85 @@ class TestManagerDownload(ManagerDownloadTestBase): assert self.manager._download_start_times == {} self.run_with_server(body) + def test_download_ref_present_keeps_download_alive(self): + """A pending download request (DownloadRef set) must not be cancelled mid-transfer.""" + def body(): + artifact = self.make_artifact(chunked=True) + base_path = os.path.join(self.dest, artifact.fileName) + self.manager.params.get.side_effect = lambda key: b"ref" if key == "ModelManager_DownloadRef" else None + asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact)) + assert os.path.isfile(get_manifest_path(base_path)) + self.run_with_server(body) + + def test_cancellation_via_download_ref(self): + """Removing DownloadRef mid-transfer cancels the download.""" + def body(): + artifact = self.make_artifact(chunked=True) + base_path = os.path.join(self.dest, artifact.fileName) + checks = {"n": 0} + + def get(key): + if key == "ModelManager_DownloadRef": + checks["n"] += 1 + return b"ref" if checks["n"] <= 2 else None + return b"0" + + self.manager.params.get.side_effect = get + with self.assertRaises(Exception) as ctx: + asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact)) + assert 'cancelled' in str(ctx.exception).lower() + assert not os.path.isfile(get_manifest_path(base_path)) + self.run_with_server(body) + + def _make_params_with_store(self): + params = mock.MagicMock() + store = {} + + def get(key, *args, **kwargs): + return store.get(key, b"0") # b"0" -> download not cancelled + + def put(key, value, *args, **kwargs): + store[key] = value + + params.get.side_effect = get + params.put.side_effect = put + return params, store + + def test_download_writes_qcom_slot(self): + """A download resolved to the qcom source writes the qcom active bundle slot only.""" + def body(): + artifact = self.make_artifact(chunked=True) + self._bundle.ref = "test-ref" + self._bundle.minimumSelectorVersion = 18 + params, store = self._make_params_with_store() + self.manager.params = params + asyncio.run(self.manager._download_bundle(self._bundle, self.dest, "qcom")) + + assert "ModelManager_ActiveBundle" in store, "qcom download must write the qcom slot" + assert "ModelManager_ActiveBundleUSBGPU" not in store, "qcom download must not touch the usbgpu slot" + assert self.manager.selected_bundle.status == custom.ModelManagerSP.DownloadStatus.downloaded + assert self.manager.active_bundle is not None and self.manager.active_bundle.ref == "test-ref" + assert self.manager.active_bundle.status == custom.ModelManagerSP.DownloadStatus.downloaded + chunk_names = [get_chunk_name(artifact.fileName, i, len(artifact.chunks)) for i in range(len(artifact.chunks))] + missing = [c for c in chunk_names if not os.path.isfile(os.path.join(self.dest, c))] + assert missing == [], f"chunks missing from the cache: {missing}" + self.run_with_server(body) + + def test_download_writes_usbgpu_slot(self): + """A download resolved to the usbgpu source writes the usbgpu active bundle slot only.""" + def body(): + self.make_artifact(chunked=True) + self._bundle.ref = "big-ref" + self._bundle.minimumSelectorVersion = 18 + params, store = self._make_params_with_store() + self.manager.params = params + asyncio.run(self.manager._download_bundle(self._bundle, self.dest, "usbgpu")) + + assert "ModelManager_ActiveBundleUSBGPU" in store, "usbgpu download must write the usbgpu slot" + assert "ModelManager_ActiveBundle" not in store, "usbgpu download must not touch the qcom slot" + assert self.manager.selected_bundle.status == custom.ModelManagerSP.DownloadStatus.downloaded + self.run_with_server(body) + class TestManagerImports(OpenpilotTestCase): """Catches undeclared dependencies. aiohttp lived only in the AGNOS venv; 19.6 dropped @@ -267,6 +352,352 @@ class TestManagerImports(OpenpilotTestCase): assert connect > 0 and read > 0, "requests defaults to no timeout; downloads would hang forever" +class TestResolveBundleByRef(OpenpilotTestCase): + """A ref resolves to (bundle, source) across both hardware manifests. Refs are + unique per manifest and never overlap across sources, so a ref maps to exactly + one slot. Shared by the manager's download flow and the settings UI.""" + + @staticmethod + def _bundle(ref: str): + bundle = custom.ModelManagerSP.ModelBundle.new_message() + bundle.ref = ref + return bundle + + def test_qcom_ref_resolves_to_qcom_slot(self): + small = self._bundle("small") + assert resolve_bundle_by_ref("small", {"qcom": [small], "usbgpu": []}) == (small, "qcom") + + def test_usbgpu_ref_resolves_to_usbgpu_slot(self): + big = self._bundle("big") + assert resolve_bundle_by_ref("big", {"qcom": [], "usbgpu": [big]}) == (big, "usbgpu") + + def test_unknown_ref_returns_none(self): + source_bundles = {"qcom": [self._bundle("small")], "usbgpu": []} + assert resolve_bundle_by_ref("nope", source_bundles) is None + + +def manifest_bundle(short_name: str, ref: str, index: int = 0, is_big: bool = False) -> dict: + """Minimal manifest bundle dict, version-compatible (no chunks to avoid disk side effects). + Big (usbgpu) bundles carry `is_big: true` in the manifest JSON.""" + return { + "index": index, + "short_name": short_name, + "display_name": short_name.upper(), + "generation": 1, + "environment": "release", + "runner": "tinygrad", + "is_big": is_big, + "minimum_selector_version": "18", + "ref": ref, + "models": [{ + "type": "supercombo", + "artifact": { + "file_name": f"{short_name}.pkl", + "download_uri": {"url": f"https://example.com/{short_name}.pkl", "sha256": "s"}, + }, + }], + } + + +def fresh_sync_time() -> int: + return int(time.monotonic() * 1e9) + + +class TestModelFetcherSources(OpenpilotTestCase): + """Both manifests are always maintained: get_bundles_for_source exposes either + source by name, and active_source picks which one matches the attached hardware.""" + + def _make_params(self, qcom_manifest, usbgpu_manifest): + params = mock.MagicMock() + + def get(key): + if key == "ModelManager_ModelsCache": + return qcom_manifest + if key == "ModelManager_ModelsCache_USBGPU": + return usbgpu_manifest + if key in ("ModelManager_LastSyncTime", "ModelManager_LastSyncTime_USBGPU"): + return fresh_sync_time() + return None + + params.get.side_effect = get + return params + + def test_active_source_follows_chestnut_presence(self): + assert ModelFetcher.active_source(False) == "qcom" + assert ModelFetcher.active_source(True) == "usbgpu" + + def test_get_bundles_for_source_returns_each_source(self): + params = self._make_params({"bundles": [manifest_bundle("small", "aaa")]}, + {"bundles": [manifest_bundle("big", "bbb", is_big=True)]}) + fetcher = ModelFetcher(params) + assert [bundle.ref for bundle in fetcher.get_bundles_for_source("qcom")] == ["aaa"] + assert [bundle.ref for bundle in fetcher.get_bundles_for_source("usbgpu")] == ["bbb"] + + def test_get_bundles_for_source_unknown(self): + assert ModelFetcher(mock.MagicMock()).get_bundles_for_source("bogus") == [] + + def test_get_cached_bundles_parses_source(self): + params = self._make_params({"bundles": [manifest_bundle("small", "aaa")]}, + {"bundles": [manifest_bundle("big", "bbb", is_big=True)]}) + qcom_bundles = get_cached_bundles(params, "qcom") + usbgpu_bundles = get_cached_bundles(params, "usbgpu") + assert [b.ref for b in qcom_bundles] == ["aaa"] + assert [b.ref for b in usbgpu_bundles] == ["bbb"] + assert qcom_bundles[0].displayName == "SMALL" + + def test_get_cached_bundles_empty_when_missing(self): + params = mock.MagicMock() + params.get.return_value = None + assert get_cached_bundles(params, "qcom") == [] + assert get_cached_bundles(params, "usbgpu") == [] + + def test_get_cached_bundles_unknown_source(self): + assert get_cached_bundles(mock.MagicMock(), "bogus") == [] + + def test_active_json_has_both_urls(self): + params = mock.MagicMock() + ModelFetcher(params) + active_json_calls = [call for call in params.put.call_args_list if call.args[0] == "ModelManager_ActiveJson"] + assert active_json_calls, "expected ModelManager_ActiveJson to be written" + assert active_json_calls[-1].args[1] == { + "qcom": ModelFetcher.MODEL_URL, + "usbgpu": ModelFetcher.MODEL_URL_USBGPU, + } + + + +class TestSourceCacheIntegrity(OpenpilotTestCase): + """Each source's cached manifest must contain only that source's models; the + `is_big` flag in the JSON marks the big (usbgpu) models. A mismatched cache is + legacy data from before the per-source split (the active manifest was cached + under the unsuffixed key regardless of hardware) and is refetched. This + replaces the old one-time bundle migration.""" + + def _make_params(self, qcom_manifest, usbgpu_manifest): + params = mock.MagicMock() + + def get(key): + if key == "ModelManager_ModelsCache": + return qcom_manifest + if key == "ModelManager_ModelsCache_USBGPU": + return usbgpu_manifest + if key in ("ModelManager_LastSyncTime", "ModelManager_LastSyncTime_USBGPU"): + return fresh_sync_time() + return None + + params.get.side_effect = get + return params + + def _fetched(self, *bundles): + return ModelFetcher(mock.MagicMock()).model_parser.parse_models({"bundles": list(bundles)}) + + def test_qcom_cache_with_big_models_is_refetched(self): + """Legacy: the unsuffixed cache holds the big manifest. is_big confirms it is + the wrong set for qcom, so a fresh fetch replaces it.""" + params = self._make_params({"bundles": [manifest_bundle("big", "bbb", is_big=True)]}, + {"bundles": [manifest_bundle("big2", "ccc", is_big=True)]}) + fetcher = ModelFetcher(params) + fetched = self._fetched(manifest_bundle("small", "aaa")) + with mock.patch.object(fetcher, "_fetch_and_cache_models", return_value=fetched): + bundles = fetcher.get_bundles_for_source("qcom") + assert [bundle.ref for bundle in bundles] == ["aaa"] + + def test_usbgpu_cache_without_big_models_is_refetched(self): + params = self._make_params({"bundles": [manifest_bundle("small", "aaa")]}, + {"bundles": [manifest_bundle("big2", "ccc")]}) + fetcher = ModelFetcher(params) + fetched = self._fetched(manifest_bundle("big", "bbb", is_big=True)) + with mock.patch.object(fetcher, "_fetch_and_cache_models", return_value=fetched): + bundles = fetcher.get_bundles_for_source("usbgpu") + assert [bundle.ref for bundle in bundles] == ["bbb"] + + def test_matching_caches_are_used_without_fetch(self): + params = self._make_params({"bundles": [manifest_bundle("small", "aaa")]}, + {"bundles": [manifest_bundle("big", "bbb", is_big=True)]}) + fetcher = ModelFetcher(params) + with mock.patch.object(fetcher, "_fetch_and_cache_models", side_effect=AssertionError("cache should be used")): + assert [bundle.ref for bundle in fetcher.get_bundles_for_source("qcom")] == ["aaa"] + assert [bundle.ref for bundle in fetcher.get_bundles_for_source("usbgpu")] == ["bbb"] + + def test_stale_version_cache_is_refetched(self): + """A source-matching cache whose bundles are all filtered by the selector version + check parses to zero valid bundles; it is stale (e.g. an old manifest) and must be + refetched instead of silently returning an empty list forever.""" + stale = manifest_bundle("small", "aaa") + stale["minimum_selector_version"] = "16" + params = self._make_params({"bundles": [stale]}, + {"bundles": [manifest_bundle("big", "bbb", is_big=True)]}) + fetcher = ModelFetcher(params) + fetched = self._fetched(manifest_bundle("small2", "ddd")) + with mock.patch.object(fetcher, "_fetch_and_cache_models", return_value=fetched) as fetch: + bundles = fetcher.get_bundles_for_source("qcom") + fetch.assert_called_once_with("qcom") + assert [bundle.ref for bundle in bundles] == ["ddd"] + + def test_mismatched_refetch_happens_once(self): + """If the fresh manifest still fails the source check, the URL is authoritative: + trust it instead of refetching at 1 Hz forever.""" + params = self._make_params({"bundles": [manifest_bundle("big", "bbb", is_big=True)]}, + {"bundles": [manifest_bundle("big2", "ccc", is_big=True)]}) + fetcher = ModelFetcher(params) + fetched = self._fetched(manifest_bundle("big", "bbb", is_big=True)) + with mock.patch.object(fetcher, "_fetch_and_cache_models", return_value=fetched) as fetch: + first = fetcher.get_bundles_for_source("qcom") + second = fetcher.get_bundles_for_source("qcom") + fetch.assert_called_once_with("qcom") + assert [bundle.ref for bundle in first] == ["bbb"] + assert [bundle.ref for bundle in second] == ["bbb"] + + def test_corrupt_cache_is_refetched(self): + """A cache that fails to parse (e.g. truncated/foreign JSON) must trigger a + refetch instead of raising every loop and never recovering.""" + corrupt = {"bundles": [{"short_name": "broken"}]} # missing required fields + params = self._make_params(corrupt, {"bundles": [manifest_bundle("big", "bbb", is_big=True)]}) + fetcher = ModelFetcher(params) + fetched = self._fetched(manifest_bundle("small", "aaa")) + with mock.patch.object(fetcher, "_fetch_and_cache_models", return_value=fetched) as fetch: + bundles = fetcher.get_bundles_for_source("qcom") + fetch.assert_called_once_with("qcom") + assert [bundle.ref for bundle in bundles] == ["aaa"] + + +class TestActiveBundleValidation(OpenpilotTestCase): + """Validation is per-slot: a failed fetch (empty bundle list) must not reset a slot, + and resetting one slot must not stomp the runner cache derived from the other.""" + + def setUp(self): + super().setUp() + helpers._LAST_VALIDATED_RAW.clear() + + @staticmethod + def _raw_bundle(ref: str, runner: int | None = None) -> dict: + bundle = custom.ModelManagerSP.ModelBundle.new_message() + bundle.ref = ref + bundle.minimumSelectorVersion = 18 + if runner is not None: + bundle.runner = runner + return bundle.to_dict() + + def _params(self, qcom=None, usbgpu=None): + params = mock.MagicMock() + + def get(key, *args, **kwargs): + return {"ModelManager_ActiveBundle": qcom, "ModelManager_ActiveBundleUSBGPU": usbgpu}.get(key) + + params.get.side_effect = get + return params + + def test_empty_catalog_does_not_reset_slot(self): + params = self._params(qcom=self._raw_bundle("small")) + with mock.patch("openpilot.sunnypilot.models.helpers.usbgpu_present", return_value=False): + validate_active_bundles(params, {"qcom": [], "usbgpu": []}) + params.remove.assert_not_called() + + def test_reset_recomputes_runner_from_surviving_slot(self): + tinygrad = int(custom.ModelManagerSP.Runner.tinygrad) + big_raw = self._raw_bundle("big", runner=tinygrad) + params = self._params(qcom=self._raw_bundle("gone"), usbgpu=big_raw) + catalog = {"qcom": [custom.ModelManagerSP.ModelBundle(**self._raw_bundle("other"))], + "usbgpu": [custom.ModelManagerSP.ModelBundle(**big_raw)]} + with mock.patch("openpilot.sunnypilot.models.helpers.usbgpu_present", return_value=True): + validate_active_bundles(params, catalog) + params.remove.assert_called_once_with("ModelManager_ActiveBundle") + runner_puts = [call for call in params.put.call_args_list if call.args[0] == "ModelRunnerTypeCache"] + assert [call.args[1] for call in runner_puts] == [tinygrad] + + +class TestActiveBundleSelection(OpenpilotTestCase): + """The effective active bundle is the active source's slot: usbgpu when a GPU is + present, qcom otherwise. An empty active slot means the hardware default (stock + runner), never the other slot's pick - modeld_v2 requires a real bundle.""" + + @staticmethod + def _raw_bundle(ref: str) -> dict: + bundle = custom.ModelManagerSP.ModelBundle.new_message() + bundle.ref = ref + bundle.minimumSelectorVersion = 18 + return bundle.to_dict() + + def _params(self, qcom=None, usbgpu=None): + params = mock.MagicMock() + + def get(key, *args, **kwargs): + if key == "ModelManager_ActiveBundle": + return qcom + if key == "ModelManager_ActiveBundleUSBGPU": + return usbgpu + return None + + params.get.side_effect = get + return params + + def test_selected_bundle_is_per_slot(self): + params = self._params(qcom=self._raw_bundle("small"), usbgpu=self._raw_bundle("big")) + assert get_selected_bundle(params, "qcom").ref == "small" + assert get_selected_bundle(params, "usbgpu").ref == "big" + + def test_no_gpu_uses_qcom_slot(self): + params = self._params(qcom=self._raw_bundle("small"), usbgpu=self._raw_bundle("big")) + with mock.patch("openpilot.sunnypilot.models.helpers.usbgpu_present", return_value=False): + assert get_active_bundle(params).ref == "small" + + def test_gpu_uses_usbgpu_slot(self): + params = self._params(qcom=self._raw_bundle("small"), usbgpu=self._raw_bundle("big")) + with mock.patch("openpilot.sunnypilot.models.helpers.usbgpu_present", return_value=True): + assert get_active_bundle(params).ref == "big" + + def test_gpu_without_big_selection_is_hardware_default(self): + params = self._params(qcom=self._raw_bundle("small"), usbgpu=None) + with mock.patch("openpilot.sunnypilot.models.helpers.usbgpu_present", return_value=True): + assert get_active_bundle(params) is None + + +class TestEffectiveSource(OpenpilotTestCase): + """One gate decides the active source. With no flags it is runtime truth (GPU + attached); display callers (mici) pass the ui_state flags, which additionally + require the big model to be loading, active, or the device offroad. The active + bundle is simply the selected bundle of that source.""" + + @staticmethod + def _raw_bundle(ref: str) -> dict: + bundle = custom.ModelManagerSP.ModelBundle.new_message() + bundle.ref = ref + bundle.minimumSelectorVersion = 18 + return bundle.to_dict() + + def test_runtime_no_gpu(self): + with mock.patch("openpilot.sunnypilot.models.helpers.usbgpu_present", return_value=False): + assert get_active_source() == "qcom" + + def test_runtime_gpu_present(self): + with mock.patch("openpilot.sunnypilot.models.helpers.usbgpu_present", return_value=True): + assert get_active_source() == "usbgpu" + + def test_display_offroad_gpu_present_shows_big(self): + assert get_active_source(usbgpu=True, usbgpu_active=False, usbgpu_loading=False, offroad=True) == "usbgpu" + + def test_display_onroad_gpu_loading_shows_big(self): + assert get_active_source(usbgpu=True, usbgpu_active=False, usbgpu_loading=True, offroad=False) == "usbgpu" + + def test_display_onroad_gpu_active_shows_big(self): + assert get_active_source(usbgpu=True, usbgpu_active=True, usbgpu_loading=False, offroad=False) == "usbgpu" + + def test_display_onroad_gpu_idle_shows_small(self): + assert get_active_source(usbgpu=True, usbgpu_active=False, usbgpu_loading=False, offroad=False) == "qcom" + + def test_display_active_none_is_idle(self): + assert get_active_source(usbgpu=True, usbgpu_active=None, usbgpu_loading=False, offroad=False) == "qcom" + + def test_active_bundle_follows_source(self): + params = mock.MagicMock() + params.get.side_effect = lambda key: {"ModelManager_ActiveBundle": self._raw_bundle("small"), + "ModelManager_ActiveBundleUSBGPU": self._raw_bundle("big")}.get(key) + with mock.patch("openpilot.sunnypilot.models.helpers.usbgpu_present", return_value=False): + assert get_active_bundle(params).ref == "small" + assert get_selected_bundle(params, get_active_source(usbgpu=True, usbgpu_active=False, + usbgpu_loading=False, offroad=True)).ref == "big" + + @unittest.skipUnless(os.environ.get('RUN_INTEGRATION_TESTS'), 'requires external network') class TestLiveModelManifest(OpenpilotTestCase): """Every artifact and chunk URL in the published manifest must resolve.""" diff --git a/openpilot/sunnypilot/models/tests/test_tinygrad_ref.py b/openpilot/sunnypilot/models/tests/test_tinygrad_ref.py index fd389f93c0..d6d82dfb32 100644 --- a/openpilot/sunnypilot/models/tests/test_tinygrad_ref.py +++ b/openpilot/sunnypilot/models/tests/test_tinygrad_ref.py @@ -1,13 +1,11 @@ import requests -from openpilot.common.params import Params from openpilot.sunnypilot.models.tinygrad_ref import get_tinygrad_ref from openpilot.sunnypilot.models.fetcher import ModelFetcher from openpilot.common.test import OpenpilotTestCase def fetch_tinygrad_ref(): - fetcher = ModelFetcher(Params()) - response = requests.get(fetcher.model_url, timeout=10) + response = requests.get(ModelFetcher.MODEL_URL, timeout=10) response.raise_for_status() json_data = response.json() return json_data.get("tinygrad_ref") diff --git a/openpilot/sunnypilot/sunnylink/statsd.py b/openpilot/sunnypilot/sunnylink/statsd.py index 7e8faf6327..a221fc084f 100755 --- a/openpilot/sunnypilot/sunnylink/statsd.py +++ b/openpilot/sunnypilot/sunnylink/statsd.py @@ -65,6 +65,7 @@ def sp_stats(end_event): 'MadsSteeringMode', 'MadsUnifiedEngagementMode', 'ModelManager_ActiveBundle', + 'ModelManager_ActiveBundleUSBGPU', 'ModelManager_Favs', 'EnableSunnylinkUploader', 'SunnylinkEnabled', diff --git a/openpilot/sunnypilot/system/params_migration.py b/openpilot/sunnypilot/system/params_migration.py index 130fd64310..f0f0d7248a 100644 --- a/openpilot/sunnypilot/system/params_migration.py +++ b/openpilot/sunnypilot/system/params_migration.py @@ -84,6 +84,21 @@ def _migrate_tesla_mads_screen_button(_params): cloudlog.exception(f"Error migrating TeslaMadsScreenButton: {e}") +def _migrate_model_bundle_slots(_params): + # Pre-split, a chestnut user's big-model selection lived in the single + # ActiveBundle. Seed both slots; validation drops whichever does not match + # its own manifest. + try: + if _params.get("ModelManager_ActiveBundleUSBGPU") is not None: + return + if (bundle := _params.get("ModelManager_ActiveBundle")) is None: + return + _params.put("ModelManager_ActiveBundleUSBGPU", bundle, block=True) + cloudlog.info("params_migration: seeded ModelManager_ActiveBundleUSBGPU from ModelManager_ActiveBundle") + except Exception as e: + cloudlog.exception(f"Error migrating model bundle slots: {e}") + + def run_migration(_params): # migrate OnroadScreenOffBrightness if _params.get("OnroadScreenOffBrightnessMigrated") != ONROAD_BRIGHTNESS_MIGRATION_VERSION: @@ -120,3 +135,6 @@ def run_migration(_params): # seed TeslaMadsScreenButton for existing Tesla installs _migrate_tesla_mads_screen_button(_params) + + # seed the usbgpu model slot from the pre-split single slot + _migrate_model_bundle_slots(_params) diff --git a/openpilot/sunnypilot/system/tests/__init__.py b/openpilot/sunnypilot/system/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpilot/sunnypilot/system/tests/test_params_migration.py b/openpilot/sunnypilot/system/tests/test_params_migration.py new file mode 100644 index 0000000000..328a7a65af --- /dev/null +++ b/openpilot/sunnypilot/system/tests/test_params_migration.py @@ -0,0 +1,36 @@ +""" +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. +""" + +from openpilot.common.params import Params +from openpilot.common.test import OpenpilotTestCase +from openpilot.sunnypilot.system.params_migration import _migrate_model_bundle_slots + + +class TestModelBundleSlotMigration(OpenpilotTestCase): + """Pre-split, a chestnut user's big-model selection lived in the single ActiveBundle. + The migration seeds both slots; per-source validation later drops whichever does not + match its own manifest.""" + + def test_seeds_usbgpu_slot_from_active_bundle(self): + params = Params() + bundle = {"ref": "big", "minimumSelectorVersion": 18} + params.put("ModelManager_ActiveBundle", bundle, block=True) + _migrate_model_bundle_slots(params) + assert params.get("ModelManager_ActiveBundleUSBGPU") == bundle + assert params.get("ModelManager_ActiveBundle") == bundle + + def test_noop_when_usbgpu_slot_already_set(self): + params = Params() + params.put("ModelManager_ActiveBundle", {"ref": "small"}, block=True) + params.put("ModelManager_ActiveBundleUSBGPU", {"ref": "big"}, block=True) + _migrate_model_bundle_slots(params) + assert params.get("ModelManager_ActiveBundleUSBGPU") == {"ref": "big"} + + def test_noop_when_no_selection(self): + params = Params() + _migrate_model_bundle_slots(params) + assert params.get("ModelManager_ActiveBundleUSBGPU") is None From d40df6f829c18bf17ae2e4a825220778e2780e98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Wed, 26 Aug 2026 12:06:53 -0700 Subject: [PATCH 18/67] modeld: fall back on invalid big model outputs (#38700) --- openpilot/selfdrive/modeld/modeld.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index 7f049912dd..783231b7b8 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -163,7 +163,7 @@ class ModelState: return parsed_model_outputs def run(self, bufs: dict[str, VisionBuf], transforms: dict[str, np.ndarray], - inputs: dict[str, np.ndarray]) -> dict[str, np.ndarray] | None: + inputs: dict[str, np.ndarray]) -> dict[str, np.ndarray]: for key in bufs.keys(): ptr = np.frombuffer(bufs[key].data, dtype=np.uint8).ctypes.data yuv_size = self.frame_buf_params[key][3] @@ -189,9 +189,7 @@ class ModelState: ) model_output = outs.numpy()[0] if self.usbgpu and not np.all(np.isfinite(model_output)): - # TODO remove with prev_feat - cloudlog.error("model output not finite, dropping frame") - return None + raise RuntimeError("model output not finite") outputs_dict = self.parser.parse_outputs(self.slice_outputs(model_output, self.output_slices)) self.npy['prev_feat'][:] = model_output[self.output_slices['hidden_state']] From 980fb79c1af4980a45c291107fd7f71f558fd556 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Wed, 26 Aug 2026 12:21:14 -0700 Subject: [PATCH 19/67] update orange GPU icon (#38701) mici: update failed eGPU icon --- openpilot/selfdrive/assets/icons_mici/egpu_orange.png | 4 ++-- openpilot/selfdrive/ui/mici/onroad/hud_renderer.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openpilot/selfdrive/assets/icons_mici/egpu_orange.png b/openpilot/selfdrive/assets/icons_mici/egpu_orange.png index c2fc06e34e..d3982abf87 100644 --- a/openpilot/selfdrive/assets/icons_mici/egpu_orange.png +++ b/openpilot/selfdrive/assets/icons_mici/egpu_orange.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:58bd6155433f623b1f75d134bd8ca4745d9aa71f6767eb807cdbcf7deb3089a1 -size 10876 +oid sha256:845c40ff0d37612e8f2f482a36845744b5ae91ce2fcfc8117990d7d278b59820 +size 13079 diff --git a/openpilot/selfdrive/ui/mici/onroad/hud_renderer.py b/openpilot/selfdrive/ui/mici/onroad/hud_renderer.py index 0d1532057e..ad8f67809c 100644 --- a/openpilot/selfdrive/ui/mici/onroad/hud_renderer.py +++ b/openpilot/selfdrive/ui/mici/onroad/hud_renderer.py @@ -126,7 +126,7 @@ class HudRenderer(Widget): self._txt_exclamation_point: rl.Texture = gui_app.texture('icons_mici/exclamation_point.png', 9, 44) self._txt_egpu: rl.Texture = gui_app.texture('icons_mici/egpu.png', 60, 44) self._txt_egpu_green: rl.Texture = gui_app.texture('icons_mici/egpu_green.png', 60, 44) - self._txt_egpu_orange: rl.Texture = gui_app.texture('icons_mici/egpu_orange.png', 60, 44) + self._txt_egpu_orange: rl.Texture = gui_app.texture('icons_mici/egpu_orange.png', 75, 44) self._txt_egpu_crossed: rl.Texture = gui_app.texture('icons_mici/egpu_crossed.png', 60, 52) self._egpu_icon: rl.Texture | None = None From 63548ce10d10d5725cbcce559d23b2e47d82a281 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Wed, 26 Aug 2026 15:20:04 -0700 Subject: [PATCH 20/67] bump tinygrad (#38702) --- tinygrad_repo | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tinygrad_repo b/tinygrad_repo index 138fb4a783..c015351ac5 160000 --- a/tinygrad_repo +++ b/tinygrad_repo @@ -1 +1 @@ -Subproject commit 138fb4a783d82f4e877ad2fe3692aaf8d1de2e46 +Subproject commit c015351ac5c00c10c58dbdaf530ef5b2883ab948 From 5cfdb2f4dac82ba89ebf310db13ff08d49cf4916 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Wed, 26 Aug 2026 15:42:59 -0700 Subject: [PATCH 21/67] rename usbgpu to chestnut (#38703) chestnut: rename eGPU interfaces --- openpilot/common/params_keys.h | 4 +- .../icons_mici/{egpu.png => chestnut.png} | 0 ...{egpu_crossed.png => chestnut_crossed.png} | 0 .../{egpu_gray.png => chestnut_gray.png} | 0 .../{egpu_green.png => chestnut_green.png} | 0 .../{egpu_orange.png => chestnut_orange.png} | 0 openpilot/selfdrive/modeld/SConscript | 26 +++++------ .../selfdrive/modeld/dmonitoringmodeld.py | 2 +- openpilot/selfdrive/modeld/helpers.py | 14 +++--- openpilot/selfdrive/modeld/modeld.py | 40 ++++++++--------- openpilot/selfdrive/selfdrived/selfdrived.py | 8 ++-- openpilot/selfdrive/ui/mici/layouts/home.py | 12 ++--- .../selfdrive/ui/mici/onroad/hud_renderer.py | 44 +++++++++---------- openpilot/selfdrive/ui/ui_state.py | 22 +++++----- openpilot/system/hardware/hardwared.py | 4 +- 15 files changed, 88 insertions(+), 88 deletions(-) rename openpilot/selfdrive/assets/icons_mici/{egpu.png => chestnut.png} (100%) rename openpilot/selfdrive/assets/icons_mici/{egpu_crossed.png => chestnut_crossed.png} (100%) rename openpilot/selfdrive/assets/icons_mici/{egpu_gray.png => chestnut_gray.png} (100%) rename openpilot/selfdrive/assets/icons_mici/{egpu_green.png => chestnut_green.png} (100%) rename openpilot/selfdrive/assets/icons_mici/{egpu_orange.png => chestnut_orange.png} (100%) diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index 2a49690b7e..21b7463d92 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -127,7 +127,7 @@ inline static std::unordered_map keys = { {"UpdaterLastFetchTime", {PERSISTENT, TIME}}, {"UptimeOffroad", {PERSISTENT, FLOAT, "0.0"}}, {"UptimeOnroad", {PERSISTENT, FLOAT, "0.0"}}, - {"UsbGpuActive", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}}, - {"UsbGpuLoading", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}}, + {"ChestnutActive", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}}, + {"ChestnutLoading", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}}, {"Version", {PERSISTENT, STRING}}, }; diff --git a/openpilot/selfdrive/assets/icons_mici/egpu.png b/openpilot/selfdrive/assets/icons_mici/chestnut.png similarity index 100% rename from openpilot/selfdrive/assets/icons_mici/egpu.png rename to openpilot/selfdrive/assets/icons_mici/chestnut.png diff --git a/openpilot/selfdrive/assets/icons_mici/egpu_crossed.png b/openpilot/selfdrive/assets/icons_mici/chestnut_crossed.png similarity index 100% rename from openpilot/selfdrive/assets/icons_mici/egpu_crossed.png rename to openpilot/selfdrive/assets/icons_mici/chestnut_crossed.png diff --git a/openpilot/selfdrive/assets/icons_mici/egpu_gray.png b/openpilot/selfdrive/assets/icons_mici/chestnut_gray.png similarity index 100% rename from openpilot/selfdrive/assets/icons_mici/egpu_gray.png rename to openpilot/selfdrive/assets/icons_mici/chestnut_gray.png diff --git a/openpilot/selfdrive/assets/icons_mici/egpu_green.png b/openpilot/selfdrive/assets/icons_mici/chestnut_green.png similarity index 100% rename from openpilot/selfdrive/assets/icons_mici/egpu_green.png rename to openpilot/selfdrive/assets/icons_mici/chestnut_green.png diff --git a/openpilot/selfdrive/assets/icons_mici/egpu_orange.png b/openpilot/selfdrive/assets/icons_mici/chestnut_orange.png similarity index 100% rename from openpilot/selfdrive/assets/icons_mici/egpu_orange.png rename to openpilot/selfdrive/assets/icons_mici/chestnut_orange.png diff --git a/openpilot/selfdrive/modeld/SConscript b/openpilot/selfdrive/modeld/SConscript index 30a31aae27..f046be9915 100644 --- a/openpilot/selfdrive/modeld/SConscript +++ b/openpilot/selfdrive/modeld/SConscript @@ -7,7 +7,7 @@ from openpilot.common.file_chunker import chunk_file, get_chunk_targets, get_exi from openpilot.common.transformations.camera import _ar_ox_fisheye, _os_fisheye from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE, DM_INPUT_SIZE from openpilot.selfdrive.modeld.constants import ModelConstants -from openpilot.selfdrive.modeld.helpers import TG_INPUT_DEVICES_PATH, usbgpu_present, modeld_pkl_path +from openpilot.selfdrive.modeld.helpers import TG_INPUT_DEVICES_PATH, chestnut_present, modeld_pkl_path CAMERA_CONFIGS = [ @@ -36,18 +36,18 @@ else: tg_devices = { # which device to put jit inputs to at runtime 'openpilot.selfdrive.modeld.modeld': { 'default': {'WARP_DEV': tg_backend, 'QUEUE_DEV': tg_backend}, - 'usbgpu': {'WARP_DEV': tg_backend, 'QUEUE_DEV': 'AMD'} + 'chestnut': {'WARP_DEV': tg_backend, 'QUEUE_DEV': 'AMD'} }, 'openpilot.selfdrive.modeld.dmonitoringmodeld': { 'default': {'DEV': tg_backend} }, } -USBGPU = usbgpu_present() -if USBGPU: - usbgpu_tg_flags = f'DEBUG=2 DEV=USB+AMD:LLVM WARP_DEV={tg_backend} FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2' +CHESTNUT = chestnut_present() +if CHESTNUT: + chestnut_tg_flags = f'DEBUG=2 DEV=USB+AMD:LLVM WARP_DEV={tg_backend} FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2' # the USB+AMD GPU takes an exclusive flock; serialize all targets that touch it - usbgpu_lock = File("models/.usb_gpu.lock").abspath + chestnut_lock = File("models/.chestnut.lock").abspath def write_tg_devices(target, source, env): with open(str(target[0]), "w") as f: @@ -73,10 +73,10 @@ compile_modeld_script = [ model_w, model_h = MEDMODEL_INPUT_SIZE frame_skip = ModelConstants.MODEL_RUN_FREQ // ModelConstants.MODEL_CONTEXT_FREQ -for usbgpu in [False, True] if USBGPU else [False]: - target_pkl_path = File(modeld_pkl_path(usbgpu)).abspath - # BIG_INTO_SMALL=1 builds the default target from the big model, e.g. to test it without a USB GPU - file_prefix, cmd_flags = ('big_', usbgpu_tg_flags) if usbgpu else ('big_' if os.getenv('BIG_INTO_SMALL') else '', tg_flags) +for chestnut in [False, True] if CHESTNUT else [False]: + target_pkl_path = File(modeld_pkl_path(chestnut)).abspath + # BIG_INTO_SMALL=1 builds the default target from the big model, e.g. to test it without a chestnut + file_prefix, cmd_flags = ('big_', chestnut_tg_flags) if chestnut else ('big_' if os.getenv('BIG_INTO_SMALL') else '', tg_flags) driving_onnx_deps = get_existing_chunks(File(f"models/{file_prefix}driving_supercombo.onnx").abspath) camera_res_args = ' '.join(f'{cw}x{ch}' for cw, ch in CAMERA_CONFIGS) # CPU 7 is isolated with isolcpus on AGNOS, so explicitly pin the compiler to it. @@ -103,14 +103,14 @@ for usbgpu in [False, True] if USBGPU else [False]: chunk_file(pkl, chunks) def do_chunk(target, source, env, pkl=target_pkl_path, chunks=chunk_targets): chunk_file(pkl, chunks) - actions = Action(do_compile, " [USBGPU] $TARGET") if usbgpu else [cmd, Action(do_chunk, " [CHUNK] $TARGET")] + actions = Action(do_compile, " [CHESTNUT] $TARGET") if chestnut else [cmd, Action(do_chunk, " [CHUNK] $TARGET")] node = lenv.Command( chunk_targets, tinygrad_files + compile_modeld_script + driving_onnx_deps + [Value(chunk_targets), chunker_file], actions, ) - if usbgpu: - lenv.SideEffect(usbgpu_lock, node) + if chestnut: + lenv.SideEffect(chestnut_lock, node) # get model metadata fn = File(f"models/dmonitoring_model").abspath diff --git a/openpilot/selfdrive/modeld/dmonitoringmodeld.py b/openpilot/selfdrive/modeld/dmonitoringmodeld.py index 67161c35bd..4010725b89 100755 --- a/openpilot/selfdrive/modeld/dmonitoringmodeld.py +++ b/openpilot/selfdrive/modeld/dmonitoringmodeld.py @@ -29,7 +29,7 @@ class ModelState: output: np.ndarray def __init__(self, cam_w: int, cam_h: int): - self.DEV = get_tg_input_devices(PROCESS_NAME, usbgpu=False)['DEV'] + self.DEV = get_tg_input_devices(PROCESS_NAME, chestnut=False)['DEV'] with open(METADATA_PATH, 'rb') as f: model_metadata = pickle.load(f) self.input_shapes = model_metadata['input_shapes'] diff --git a/openpilot/selfdrive/modeld/helpers.py b/openpilot/selfdrive/modeld/helpers.py index 37ab0b26d7..84236f3fd0 100644 --- a/openpilot/selfdrive/modeld/helpers.py +++ b/openpilot/selfdrive/modeld/helpers.py @@ -13,12 +13,12 @@ MODELS_DIR = Path(__file__).resolve().parent / 'models' TG_INPUT_DEVICES_PATH = MODELS_DIR / 'tg_input_devices.json' -def get_tg_input_devices(process_name: str, usbgpu: bool): +def get_tg_input_devices(process_name: str, chestnut: bool): with open(TG_INPUT_DEVICES_PATH) as f: - return json.load(f)[process_name]['default' if not usbgpu else 'usbgpu'] + return json.load(f)[process_name]['default' if not chestnut else 'chestnut'] -def modeld_pkl_path(usbgpu: bool): - prefix = 'big_' if usbgpu else '' +def modeld_pkl_path(chestnut: bool): + prefix = 'big_' if chestnut else '' return MODELS_DIR / f'{prefix}driving_tinygrad.pkl' def dump_oob(obj, f): @@ -45,7 +45,7 @@ def load_oob(f): yield pb return pickle.load(io.BytesIO(opcodes), buffers=buffers()) -def usbgpu_present() -> bool: +def chestnut_present() -> bool: for d in USB_DEVICES_PATH.glob("*"): try: usb_id = (int((d / "idVendor").read_text(), 16), int((d / "idProduct").read_text(), 16)) @@ -56,5 +56,5 @@ def usbgpu_present() -> bool: pass return False -def usbgpu_compiled() -> bool: - return Path(get_manifest_path(modeld_pkl_path(usbgpu=True))).is_file() +def chestnut_compiled() -> bool: + return Path(get_manifest_path(modeld_pkl_path(chestnut=True))).is_file() diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index 783231b7b8..7a76841516 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 from functools import cached_property import os -os.environ['GMMU'] = '0' # for usbgpu fast loading, noop for qcom +os.environ['GMMU'] = '0' # for chestnut fast loading, noop for qcom from tinygrad.tensor import Tensor from tinygrad.device import Device import struct @@ -30,7 +30,7 @@ from openpilot.selfdrive.modeld.compile_modeld import make_input_queues, WARP_IN from openpilot.selfdrive.modeld.fill_model_msg import fill_model_msg, fill_driving_model_data, fill_pose_msg, PublishState from openpilot.common.file_chunker import open_file_chunked from openpilot.selfdrive.modeld.constants import ModelConstants, Plan -from openpilot.selfdrive.modeld.helpers import usbgpu_present, usbgpu_compiled, modeld_pkl_path, get_tg_input_devices, load_oob +from openpilot.selfdrive.modeld.helpers import chestnut_present, chestnut_compiled, modeld_pkl_path, get_tg_input_devices, load_oob PROCESS_NAME = "openpilot.selfdrive.modeld.modeld" SEND_RAW_PRED = os.getenv('SEND_RAW_PRED') @@ -137,17 +137,17 @@ class FrameMeta: class ModelState: prev_desire: np.ndarray # for tracking the rising edge of the pulse - def __init__(self, cam_w: int, cam_h: int, usbgpu: bool): - input_devices = get_tg_input_devices(PROCESS_NAME, usbgpu) + def __init__(self, cam_w: int, cam_h: int, chestnut: bool): + input_devices = get_tg_input_devices(PROCESS_NAME, chestnut) self.WARP_DEV, self.QUEUE_DEV = input_devices['WARP_DEV'], input_devices['QUEUE_DEV'] - jits = load_oob(open_file_chunked(modeld_pkl_path(usbgpu))) + jits = load_oob(open_file_chunked(modeld_pkl_path(chestnut))) metadata = jits['metadata'] self.input_shapes = metadata['input_shapes'] self.vision_input_names = [k for k in self.input_shapes if 'img' in k] self.output_slices = metadata['output_slices'] self.prev_desire = np.zeros(ModelConstants.DESIRE_LEN, dtype=np.float32) - self.usbgpu = usbgpu + self.chestnut = chestnut self.frame_skip = ModelConstants.MODEL_RUN_FREQ // ModelConstants.MODEL_CONTEXT_FREQ self.input_queues, self.npy = make_input_queues(self.input_shapes, self.frame_skip, device=self.QUEUE_DEV) @@ -188,7 +188,7 @@ class ModelState: **{k: self.input_queues[k] for k in POLICY_INPUTS if k in self.input_queues}, warped=warped ) model_output = outs.numpy()[0] - if self.usbgpu and not np.all(np.isfinite(model_output)): + if self.chestnut and not np.all(np.isfinite(model_output)): raise RuntimeError("model output not finite") outputs_dict = self.parser.parse_outputs(self.slice_outputs(model_output, self.output_slices)) self.npy['prev_feat'][:] = model_output[self.output_slices['hidden_state']] @@ -211,12 +211,12 @@ class ModelState: def main(demo=False): cloudlog.warning("modeld init") - USBGPU = usbgpu_present() and usbgpu_compiled() - if USBGPU: + CHESTNUT = chestnut_present() and chestnut_compiled() + if CHESTNUT: os.environ['HCQDEV_WAIT_TIMEOUT_MS'] = '3000' params = Params() - params.put_bool("UsbGpuLoading", USBGPU) - params.remove("UsbGpuActive") + params.put_bool("ChestnutLoading", CHESTNUT) + params.remove("ChestnutActive") config_realtime_process(7, 54) @@ -246,7 +246,7 @@ def main(demo=False): st = time.monotonic() cloudlog.warning("loading model") model = None - if USBGPU: + if CHESTNUT: big_model = None def load_big(): nonlocal big_model @@ -260,22 +260,22 @@ def main(demo=False): loader.start() loader.join(BIG_MODEL_TIMEOUT) model = big_model - params.put_bool("UsbGpuActive", model is not None) + params.put_bool("ChestnutActive", model is not None) - small_model = ModelState(vipc_client_main.width, vipc_client_main.height, False) if model is None or USBGPU else None + small_model = ModelState(vipc_client_main.width, vipc_client_main.height, False) if model is None or CHESTNUT else None if model is None: model = small_model - params.put_bool("UsbGpuLoading", False) + params.put_bool("ChestnutLoading", False) cloudlog.warning(f"models loaded in {time.monotonic() - st:.1f}s, modeld starting") # messaging - pub_socks = ["modelV2", "drivingModelData", "cameraOdometry"] + (["chestnutState"] if USBGPU else []) + pub_socks = ["modelV2", "drivingModelData", "cameraOdometry"] + (["chestnutState"] if CHESTNUT else []) pm = PubMaster(pub_socks) sm = SubMaster(["deviceState", "carState", "narrowRoadCameraState", "extrinsicsCalibration", "driverMonitoringState", "carControl", "lateralDelay"]) publish_state = PublishState() params = Params() - chestnut_state = ChestnutState(pm, model.usbgpu) if USBGPU else None + chestnut_state = ChestnutState(pm, model.chestnut) if CHESTNUT else None # setup filter to track dropped frames frame_dropped_filter = FirstOrderFilter(0., 10., 1. / ModelConstants.MODEL_RUN_FREQ) @@ -385,11 +385,11 @@ def main(demo=False): try: model_output = model.run(bufs, transforms, inputs) except Exception: - if not params.get_bool("UsbGpuActive"): + if not params.get_bool("ChestnutActive"): raise # fallback to small model cloudlog.exception("big model failed, fall back to small") - params.put_bool("UsbGpuActive", False) + params.put_bool("ChestnutActive", False) model = small_model if chestnut_state is not None: chestnut_state.big = False @@ -408,7 +408,7 @@ def main(demo=False): fill_model_msg(modelv2_send, model_output, action, publish_state, meta_main.frame_id, meta_extra.frame_id, frame_id, frame_drop_ratio, meta_main.timestamp_eof, model_execution_time, extrinsics_calibration_seen) - modelv2_send.modelV2.big = model.usbgpu + modelv2_send.modelV2.big = model.chestnut desire_state = modelv2_send.modelV2.meta.desireState l_lane_change_prob = desire_state[log.Desire.laneChangeLeft] diff --git a/openpilot/selfdrive/selfdrived/selfdrived.py b/openpilot/selfdrive/selfdrived/selfdrived.py index 1f1f6f7349..638448d897 100755 --- a/openpilot/selfdrive/selfdrived/selfdrived.py +++ b/openpilot/selfdrive/selfdrived/selfdrived.py @@ -159,17 +159,17 @@ class SelfdriveD: self.events.add(EventName.joystickDebug) self.startup_event = None - loading = self.params.get_bool("UsbGpuLoading") + loading = self.params.get_bool("ChestnutLoading") if self.big_model_loading and not loading: self.big_model_ready_t = time.monotonic() self.big_model_loading = loading if self.big_model_loading: self.events.add(EventName.bigModelLoading) - big_active = self.params.get("UsbGpuActive") - usbgpu_present = self.sm['deviceState'].chestnutPresent + big_active = self.params.get("ChestnutActive") + chestnut_present = self.sm['deviceState'].chestnutPresent model_unavailable = big_active is True and self.sm.seen['modelV2'] and not self.sm.alive['modelV2'] - big_failed = big_active is False or model_unavailable or (self.big_model_active and not usbgpu_present) + big_failed = big_active is False or model_unavailable or (self.big_model_active and not chestnut_present) if big_failed and not self.big_model_failed: self.events.add(EventName.bigModelFailed) self.big_model_failed = big_failed diff --git a/openpilot/selfdrive/ui/mici/layouts/home.py b/openpilot/selfdrive/ui/mici/layouts/home.py index 8b21a1a98c..50dc95901f 100644 --- a/openpilot/selfdrive/ui/mici/layouts/home.py +++ b/openpilot/selfdrive/ui/mici/layouts/home.py @@ -139,8 +139,8 @@ class MiciHomeLayout(Widget): self._version_text = self._get_version_text() self._experimental_icon = IconWidget("icons_mici/experimental_mode.png", (48, 48)) - self._egpu_icon = IconWidget("icons_mici/egpu_green.png", (50, 37)) - self._egpu_icon_gray = IconWidget("icons_mici/egpu_gray.png", (50, 37)) + self._chestnut_icon = IconWidget("icons_mici/chestnut_green.png", (50, 37)) + self._chestnut_icon_gray = IconWidget("icons_mici/chestnut_gray.png", (50, 37)) self._mic_icon = IconWidget("icons_mici/microphone.png", (32, 46)) self._body_icon = IconWidget("icons_mici/body.png", (54, 37)) @@ -150,8 +150,8 @@ class MiciHomeLayout(Widget): IconWidget("icons_mici/settings.png", (48, 48), opacity=0.9), NetworkIcon(), self._experimental_icon, - self._egpu_icon, - self._egpu_icon_gray, + self._chestnut_icon, + self._chestnut_icon_gray, self._body_icon, self._mic_icon, ], spacing=18) @@ -248,8 +248,8 @@ class MiciHomeLayout(Widget): # ***** Center-aligned bottom section icons ***** self._experimental_icon.set_visible(ui_state.experimental_mode) - self._egpu_icon.set_visible(ui_state.sm["deviceState"].chestnutPresent and ui_state.usbgpu_compiled) - self._egpu_icon_gray.set_visible(ui_state.sm["deviceState"].chestnutPresent and not ui_state.usbgpu_compiled) + self._chestnut_icon.set_visible(ui_state.sm["deviceState"].chestnutPresent and ui_state.chestnut_compiled) + self._chestnut_icon_gray.set_visible(ui_state.sm["deviceState"].chestnutPresent and not ui_state.chestnut_compiled) self._mic_icon.set_visible(ui_state.recording_audio) self._body_icon.set_visible(bool(ui_state.is_body)) diff --git a/openpilot/selfdrive/ui/mici/onroad/hud_renderer.py b/openpilot/selfdrive/ui/mici/onroad/hud_renderer.py index ad8f67809c..e844f5e9e7 100644 --- a/openpilot/selfdrive/ui/mici/onroad/hud_renderer.py +++ b/openpilot/selfdrive/ui/mici/onroad/hud_renderer.py @@ -108,7 +108,7 @@ class HudRenderer(Widget): self.v_ego_cluster_seen: bool = False self._engaged: bool = False self._small_model_engaged: bool = False - self._egpu_fade_time: float = 0 + self._chestnut_fade_time: float = 0 self._can_draw_top_icons = True self._show_wheel_critical = False @@ -124,17 +124,17 @@ class HudRenderer(Widget): self._txt_wheel: rl.Texture = gui_app.texture('icons_mici/wheel.png', 50, 50) self._txt_wheel_critical: rl.Texture = gui_app.texture('icons_mici/wheel_critical.png', 50, 50) self._txt_exclamation_point: rl.Texture = gui_app.texture('icons_mici/exclamation_point.png', 9, 44) - self._txt_egpu: rl.Texture = gui_app.texture('icons_mici/egpu.png', 60, 44) - self._txt_egpu_green: rl.Texture = gui_app.texture('icons_mici/egpu_green.png', 60, 44) - self._txt_egpu_orange: rl.Texture = gui_app.texture('icons_mici/egpu_orange.png', 75, 44) - self._txt_egpu_crossed: rl.Texture = gui_app.texture('icons_mici/egpu_crossed.png', 60, 52) - self._egpu_icon: rl.Texture | None = None + self._txt_chestnut: rl.Texture = gui_app.texture('icons_mici/chestnut.png', 60, 44) + self._txt_chestnut_green: rl.Texture = gui_app.texture('icons_mici/chestnut_green.png', 60, 44) + self._txt_chestnut_orange: rl.Texture = gui_app.texture('icons_mici/chestnut_orange.png', 75, 44) + self._txt_chestnut_crossed: rl.Texture = gui_app.texture('icons_mici/chestnut_crossed.png', 60, 52) + self._chestnut_icon: rl.Texture | None = None self._wheel_alpha_filter = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps) self._wheel_y_filter = FirstOrderFilter(0, 0.1, 1 / gui_app.target_fps) self._set_speed_alpha_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) - self._egpu_alpha_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) + self._chestnut_alpha_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) def set_wheel_critical_icon(self, critical: bool): """Set the wheel icon to critical or normal state.""" @@ -165,11 +165,11 @@ class HudRenderer(Widget): controls_state.deprecated.vCruise if v_cruise_cluster == 0.0 else v_cruise_cluster ) engaged = sm['selfdriveState'].enabled - if (engaged and not self._engaged and not ui_state.usbgpu_loading and ui_state.usbgpu_active is not True and + if (engaged and not self._engaged and not ui_state.chestnut_loading and ui_state.chestnut_active is not True and ui_state.sm.recv_frame['modelV2'] > ui_state.started_frame): self._small_model_engaged = True if engaged != self._engaged: - self._egpu_fade_time = rl.get_time() if engaged else 0 + self._chestnut_fade_time = rl.get_time() if engaged else 0 if (set_speed != self.set_speed and engaged) or (engaged and not self._engaged): self._set_speed_changed_time = rl.get_time() self._engaged = engaged @@ -191,7 +191,7 @@ class HudRenderer(Widget): if self.is_cruise_set: self._draw_set_speed(rect) - if ui_state.usbgpu and ui_state.usbgpu_compiled: + if ui_state.chestnut and ui_state.chestnut_compiled: self._draw_model_source(rect) self._draw_steering_wheel(rect) @@ -200,30 +200,30 @@ class HudRenderer(Widget): if ui_state.sm.recv_frame['selfdriveState'] < ui_state.started_frame: return - big_failed = (ui_state.usbgpu_active is False or not ui_state.sm['deviceState'].chestnutPresent or - (ui_state.usbgpu_active is True and ui_state.sm.recv_frame['modelV2'] > ui_state.started_frame and + big_failed = (ui_state.chestnut_active is False or not ui_state.sm['deviceState'].chestnutPresent or + (ui_state.chestnut_active is True and ui_state.sm.recv_frame['modelV2'] > ui_state.started_frame and not ui_state.sm.alive['modelV2']) or - (ui_state.usbgpu_active is None and ui_state.sm.recv_frame['modelV2'] > ui_state.started_frame)) + (ui_state.chestnut_active is None and ui_state.sm.recv_frame['modelV2'] > ui_state.started_frame)) self._small_model_engaged &= big_failed - loading = ui_state.usbgpu_loading or (ui_state.usbgpu_active is None and not big_failed) + loading = ui_state.chestnut_loading or (ui_state.chestnut_active is None and not big_failed) if loading: pulse = 0.5 - 0.5 * math.cos(rl.get_time() * 6.0) - icon = self._txt_egpu + icon = self._txt_chestnut opacity = 0.35 + 0.65 * pulse elif self._small_model_engaged: - icon = self._txt_egpu_crossed + icon = self._txt_chestnut_crossed opacity = 0.65 elif big_failed: - icon = self._txt_egpu_orange + icon = self._txt_chestnut_orange opacity = 1.0 else: - icon = self._txt_egpu_green + icon = self._txt_chestnut_green opacity = 1.0 - if icon is not self._egpu_icon: - self._egpu_fade_time = rl.get_time() - self._egpu_icon = icon - alpha = self._egpu_alpha_filter.update(loading or 0 < rl.get_time() - self._egpu_fade_time < SET_SPEED_PERSISTENCE) + if icon is not self._chestnut_icon: + self._chestnut_fade_time = rl.get_time() + self._chestnut_icon = icon + alpha = self._chestnut_alpha_filter.update(loading or 0 < rl.get_time() - self._chestnut_fade_time < SET_SPEED_PERSISTENCE) if alpha < 1e-2: return diff --git a/openpilot/selfdrive/ui/ui_state.py b/openpilot/selfdrive/ui/ui_state.py index 42e8086351..63a71758fa 100644 --- a/openpilot/selfdrive/ui/ui_state.py +++ b/openpilot/selfdrive/ui/ui_state.py @@ -12,7 +12,7 @@ from openpilot.common.swaglog import cloudlog from openpilot.selfdrive.ui.lib.prime_state import PrimeState from openpilot.system.ui.lib.application import gui_app from openpilot.common.hardware import HARDWARE, PC -from openpilot.selfdrive.modeld.helpers import usbgpu_compiled +from openpilot.selfdrive.modeld.helpers import chestnut_compiled BACKLIGHT_OFFROAD = 65 if HARDWARE.get_device_type() == "mici" else 50 PARAM_UPDATE_TIME = 1 / 5.0 @@ -77,10 +77,10 @@ class UIState: self.always_on_dm: bool = self.params.get_bool("AlwaysOnDM") self.experimental_mode: bool = self.params.get_bool("ExperimentalMode") self.experimental_mode_confirmed: bool = self.params.get_bool("ExperimentalModeConfirmed") - self.usbgpu: bool = False - self.usbgpu_compiled: bool = usbgpu_compiled() - self.usbgpu_active: bool | None = self.params.get("UsbGpuActive") - self.usbgpu_loading: bool = self.params.get_bool("UsbGpuLoading") + self.chestnut: bool = False + self.chestnut_compiled: bool = chestnut_compiled() + self.chestnut_active: bool | None = None + self.chestnut_loading: bool = False self.started: bool = False self.ignition: bool = False self.recording_audio: bool = False @@ -208,12 +208,12 @@ class UIState: self.always_on_dm = self.params.get_bool("AlwaysOnDM") self.experimental_mode = self.params.get_bool("ExperimentalMode") self.experimental_mode_confirmed = self.params.get_bool("ExperimentalModeConfirmed") - # keep usbgpu UI active until offroad transition when gpu disappears - self.usbgpu = self.sm["deviceState"].chestnutPresent or (self.usbgpu and self.started) - if not self.usbgpu_compiled: - self.usbgpu_compiled = usbgpu_compiled() - self.usbgpu_active = self.params.get("UsbGpuActive") - self.usbgpu_loading = self.params.get_bool("UsbGpuLoading") + # keep chestnut UI active until offroad transition when gpu disappears + self.chestnut = self.sm["deviceState"].chestnutPresent or (self.chestnut and self.started) + if not self.chestnut_compiled: + self.chestnut_compiled = chestnut_compiled() + self.chestnut_active = self.params.get("ChestnutActive") + self.chestnut_loading = self.params.get_bool("ChestnutLoading") class Device: diff --git a/openpilot/system/hardware/hardwared.py b/openpilot/system/hardware/hardwared.py index 16eb18d153..64ca4415d1 100755 --- a/openpilot/system/hardware/hardwared.py +++ b/openpilot/system/hardware/hardwared.py @@ -16,7 +16,7 @@ from openpilot.common.utils import strip_deprecated_keys from openpilot.common.filter_simple import FirstOrderFilter from openpilot.common.params import Params from openpilot.common.realtime import DT_HW -from openpilot.selfdrive.modeld.helpers import MODELS_DIR, usbgpu_compiled +from openpilot.selfdrive.modeld.helpers import MODELS_DIR, chestnut_compiled from openpilot.selfdrive.selfdrived.alertmanager import set_offroad_alert from openpilot.common.hardware import HARDWARE, COMMA_HARDWARE, PC from openpilot.common.basedir import BASEDIR @@ -238,7 +238,7 @@ def hardware_thread(end_event, hw_queue) -> None: fan_controller = FanController(int(1./DT_HW)) chestnut = Chestnut() - big_model_available = (MODELS_DIR / 'big_driving_supercombo.onnx').is_file() or usbgpu_compiled() + big_model_available = (MODELS_DIR / 'big_driving_supercombo.onnx').is_file() or chestnut_compiled() while not end_event.is_set(): sm.update(PANDA_STATES_TIMEOUT) From fa75fdd852d29eedf454afdcfa76c34451ada8e9 Mon Sep 17 00:00:00 2001 From: YassineYousfi Date: Wed, 26 Aug 2026 17:57:55 -0700 Subject: [PATCH 22/67] chestnut stats: overlap with gpu work (#38704) * chestnut stats: overlap with gpu work * ci --------- Co-authored-by: elkoled --- openpilot/selfdrive/modeld/modeld.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index 7a76841516..0db66ed880 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -1,4 +1,6 @@ #!/usr/bin/env python3 +from collections.abc import Callable +import ctypes from functools import cached_property import os os.environ['GMMU'] = '0' # for chestnut fast loading, noop for qcom @@ -90,8 +92,10 @@ class ChestnutState: if self.big and "AMD" in Device._opened_devices and self.sends % 100 == 1: try: smu = Device["AMD"].iface.dev_impl.smu + metrics_t = smu.smu_mod.SmuMetricsExternal_t smu._send_msg(smu.smu_mod.PPSMC_MSG_TransferTableSmu2Dram, smu.smu_mod.TABLE_SMU_METRICS, timeout=100) - metrics = smu.read_table(smu.smu_mod.SmuMetricsExternal_t, smu.smu_mod.TABLE_SMU_METRICS).SmuMetrics + metrics_buf = bytearray(smu.adev.vram.view(smu.driver_table_paddr, ctypes.sizeof(metrics_t))[:]) + metrics = metrics_t.from_buffer(metrics_buf).SmuMetrics self.metrics = {'tempC': metrics.AvgTemperature[smu.smu_mod.TEMP_HOTSPOT], 'memoryTempC': metrics.AvgTemperature[smu.smu_mod.TEMP_MEM], 'powerDrawW': metrics.AverageSocketPower, @@ -163,7 +167,7 @@ class ModelState: return parsed_model_outputs def run(self, bufs: dict[str, VisionBuf], transforms: dict[str, np.ndarray], - inputs: dict[str, np.ndarray]) -> dict[str, np.ndarray]: + inputs: dict[str, np.ndarray], after_enqueue: Callable[[], None] | None = None) -> dict[str, np.ndarray]: for key in bufs.keys(): ptr = np.frombuffer(bufs[key].data, dtype=np.uint8).ctypes.data yuv_size = self.frame_buf_params[key][3] @@ -187,6 +191,8 @@ class ModelState: outs, = self.run_policy( **{k: self.input_queues[k] for k in POLICY_INPUTS if k in self.input_queues}, warped=warped ) + if after_enqueue is not None: + after_enqueue() model_output = outs.numpy()[0] if self.chestnut and not np.all(np.isfinite(model_output)): raise RuntimeError("model output not finite") @@ -383,7 +389,9 @@ def main(demo=False): mt1 = time.perf_counter() try: - model_output = model.run(bufs, transforms, inputs) + send_chestnut = (chestnut_state is not None and + run_count % round(ModelConstants.MODEL_RUN_FREQ / SERVICE_LIST['chestnutState'].frequency) == 0) + model_output = model.run(bufs, transforms, inputs, chestnut_state.send if send_chestnut else None) except Exception: if not params.get_bool("ChestnutActive"): raise @@ -425,10 +433,6 @@ def main(demo=False): pm.send('cameraOdometry', posenet_send) last_vipc_frame_id = meta_main.frame_id - if chestnut_state is not None and run_count % round(ModelConstants.MODEL_RUN_FREQ / SERVICE_LIST['chestnutState'].frequency) == 0: - chestnut_state.send() - - if __name__ == "__main__": try: import argparse From 4a13639cfd122ccb9113a4d6ce225dcbd8e61914 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Wed, 26 Aug 2026 19:12:49 -0700 Subject: [PATCH 23/67] reduce chestnut states (#38705) ui: unify chestnut status presentation --- .../assets/icons_mici/chestnut_crossed.png | 3 -- .../assets/icons_mici/chestnut_gray.png | 3 -- openpilot/selfdrive/ui/mici/layouts/home.py | 12 +++--- .../selfdrive/ui/mici/onroad/hud_renderer.py | 37 ++++++------------ openpilot/selfdrive/ui/ui_state.py | 38 +++++++++++++++++-- 5 files changed, 53 insertions(+), 40 deletions(-) delete mode 100644 openpilot/selfdrive/assets/icons_mici/chestnut_crossed.png delete mode 100644 openpilot/selfdrive/assets/icons_mici/chestnut_gray.png diff --git a/openpilot/selfdrive/assets/icons_mici/chestnut_crossed.png b/openpilot/selfdrive/assets/icons_mici/chestnut_crossed.png deleted file mode 100644 index 4fc5decb59..0000000000 --- a/openpilot/selfdrive/assets/icons_mici/chestnut_crossed.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8a8c5fece2a1c7587feb41cbe04c6aee08e768ecd9b5d00da6af9832a4ccc842 -size 2034 diff --git a/openpilot/selfdrive/assets/icons_mici/chestnut_gray.png b/openpilot/selfdrive/assets/icons_mici/chestnut_gray.png deleted file mode 100644 index a6aeb84689..0000000000 --- a/openpilot/selfdrive/assets/icons_mici/chestnut_gray.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7409c53d7c72681c24982fd83b56ce70f80797c9c0f936d9296a5c18557ac472 -size 7279 diff --git a/openpilot/selfdrive/ui/mici/layouts/home.py b/openpilot/selfdrive/ui/mici/layouts/home.py index 50dc95901f..519580925f 100644 --- a/openpilot/selfdrive/ui/mici/layouts/home.py +++ b/openpilot/selfdrive/ui/mici/layouts/home.py @@ -9,7 +9,7 @@ from openpilot.system.ui.widgets.layouts import HBoxLayout from openpilot.system.ui.widgets.icon_widget import IconWidget from openpilot.system.ui.widgets.label import UnifiedLabel, gui_label from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos -from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.selfdrive.ui.ui_state import ui_state, ChestnutState from openpilot.common.version import RELEASE_BRANCHES HEAD_BUTTON_FONT_SIZE = 40 @@ -139,8 +139,8 @@ class MiciHomeLayout(Widget): self._version_text = self._get_version_text() self._experimental_icon = IconWidget("icons_mici/experimental_mode.png", (48, 48)) - self._chestnut_icon = IconWidget("icons_mici/chestnut_green.png", (50, 37)) - self._chestnut_icon_gray = IconWidget("icons_mici/chestnut_gray.png", (50, 37)) + self._chestnut_icon = IconWidget("icons_mici/chestnut_green.png", (68, 40)) + self._chestnut_failed_icon = IconWidget("icons_mici/chestnut_orange.png", (68, 40)) self._mic_icon = IconWidget("icons_mici/microphone.png", (32, 46)) self._body_icon = IconWidget("icons_mici/body.png", (54, 37)) @@ -151,7 +151,7 @@ class MiciHomeLayout(Widget): NetworkIcon(), self._experimental_icon, self._chestnut_icon, - self._chestnut_icon_gray, + self._chestnut_failed_icon, self._body_icon, self._mic_icon, ], spacing=18) @@ -248,8 +248,8 @@ class MiciHomeLayout(Widget): # ***** Center-aligned bottom section icons ***** self._experimental_icon.set_visible(ui_state.experimental_mode) - self._chestnut_icon.set_visible(ui_state.sm["deviceState"].chestnutPresent and ui_state.chestnut_compiled) - self._chestnut_icon_gray.set_visible(ui_state.sm["deviceState"].chestnutPresent and not ui_state.chestnut_compiled) + self._chestnut_icon.set_visible(ui_state.chestnut_state in (ChestnutState.READY, ChestnutState.LOADING, ChestnutState.ACTIVE)) + self._chestnut_failed_icon.set_visible(ui_state.chestnut_state in (ChestnutState.UNCOMPILED, ChestnutState.FAILED)) self._mic_icon.set_visible(ui_state.recording_audio) self._body_icon.set_visible(bool(ui_state.is_body)) diff --git a/openpilot/selfdrive/ui/mici/onroad/hud_renderer.py b/openpilot/selfdrive/ui/mici/onroad/hud_renderer.py index e844f5e9e7..3bb2f70b84 100644 --- a/openpilot/selfdrive/ui/mici/onroad/hud_renderer.py +++ b/openpilot/selfdrive/ui/mici/onroad/hud_renderer.py @@ -3,7 +3,7 @@ import pyray as rl from dataclasses import dataclass from openpilot.common.constants import CV from openpilot.selfdrive.ui.mici.onroad.torque_bar import TorqueBar -from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus +from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus, ChestnutState from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.lib.text_measure import measure_text_cached @@ -107,7 +107,6 @@ class HudRenderer(Widget): self.speed: float = 0.0 self.v_ego_cluster_seen: bool = False self._engaged: bool = False - self._small_model_engaged: bool = False self._chestnut_fade_time: float = 0 self._can_draw_top_icons = True @@ -127,9 +126,7 @@ class HudRenderer(Widget): self._txt_chestnut: rl.Texture = gui_app.texture('icons_mici/chestnut.png', 60, 44) self._txt_chestnut_green: rl.Texture = gui_app.texture('icons_mici/chestnut_green.png', 60, 44) self._txt_chestnut_orange: rl.Texture = gui_app.texture('icons_mici/chestnut_orange.png', 75, 44) - self._txt_chestnut_crossed: rl.Texture = gui_app.texture('icons_mici/chestnut_crossed.png', 60, 52) self._chestnut_icon: rl.Texture | None = None - self._wheel_alpha_filter = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps) self._wheel_y_filter = FirstOrderFilter(0, 0.1, 1 / gui_app.target_fps) @@ -165,13 +162,10 @@ class HudRenderer(Widget): controls_state.deprecated.vCruise if v_cruise_cluster == 0.0 else v_cruise_cluster ) engaged = sm['selfdriveState'].enabled - if (engaged and not self._engaged and not ui_state.chestnut_loading and ui_state.chestnut_active is not True and - ui_state.sm.recv_frame['modelV2'] > ui_state.started_frame): - self._small_model_engaged = True - if engaged != self._engaged: - self._chestnut_fade_time = rl.get_time() if engaged else 0 if (set_speed != self.set_speed and engaged) or (engaged and not self._engaged): self._set_speed_changed_time = rl.get_time() + if engaged != self._engaged: + self._chestnut_fade_time = rl.get_time() if engaged else 0 self._engaged = engaged self.set_speed = set_speed self.is_cruise_set = 0 < self.set_speed < SET_SPEED_NA @@ -191,8 +185,7 @@ class HudRenderer(Widget): if self.is_cruise_set: self._draw_set_speed(rect) - if ui_state.chestnut and ui_state.chestnut_compiled: - self._draw_model_source(rect) + self._draw_model_source(rect) self._draw_steering_wheel(rect) @@ -200,30 +193,24 @@ class HudRenderer(Widget): if ui_state.sm.recv_frame['selfdriveState'] < ui_state.started_frame: return - big_failed = (ui_state.chestnut_active is False or not ui_state.sm['deviceState'].chestnutPresent or - (ui_state.chestnut_active is True and ui_state.sm.recv_frame['modelV2'] > ui_state.started_frame and - not ui_state.sm.alive['modelV2']) or - (ui_state.chestnut_active is None and ui_state.sm.recv_frame['modelV2'] > ui_state.started_frame)) - self._small_model_engaged &= big_failed - loading = ui_state.chestnut_loading or (ui_state.chestnut_active is None and not big_failed) + loading = ui_state.chestnut_state == ChestnutState.LOADING if loading: - pulse = 0.5 - 0.5 * math.cos(rl.get_time() * 6.0) icon = self._txt_chestnut - opacity = 0.35 + 0.65 * pulse - elif self._small_model_engaged: - icon = self._txt_chestnut_crossed - opacity = 0.65 - elif big_failed: + opacity = 0.35 + 0.65 * (0.5 - 0.5 * math.cos(rl.get_time() * 6.0)) + elif ui_state.chestnut_state in (ChestnutState.UNCOMPILED, ChestnutState.FAILED): icon = self._txt_chestnut_orange opacity = 1.0 - else: + elif ui_state.chestnut_state == ChestnutState.ACTIVE: icon = self._txt_chestnut_green opacity = 1.0 + else: + return if icon is not self._chestnut_icon: self._chestnut_fade_time = rl.get_time() self._chestnut_icon = icon - alpha = self._chestnut_alpha_filter.update(loading or 0 < rl.get_time() - self._chestnut_fade_time < SET_SPEED_PERSISTENCE) + visible = loading or rl.get_time() - self._chestnut_fade_time < SET_SPEED_PERSISTENCE + alpha = self._chestnut_alpha_filter.update(visible) if alpha < 1e-2: return diff --git a/openpilot/selfdrive/ui/ui_state.py b/openpilot/selfdrive/ui/ui_state.py index 63a71758fa..c169cbeaff 100644 --- a/openpilot/selfdrive/ui/ui_state.py +++ b/openpilot/selfdrive/ui/ui_state.py @@ -24,6 +24,15 @@ class UIStatus(Enum): OVERRIDE = "override" +class ChestnutState(Enum): + DISCONNECTED = "disconnected" + UNCOMPILED = "uncompiled" + READY = "ready" + LOADING = "loading" + ACTIVE = "active" + FAILED = "failed" + + class UIState: _instance: 'UIState | None' = None @@ -77,10 +86,11 @@ class UIState: self.always_on_dm: bool = self.params.get_bool("AlwaysOnDM") self.experimental_mode: bool = self.params.get_bool("ExperimentalMode") self.experimental_mode_confirmed: bool = self.params.get_bool("ExperimentalModeConfirmed") - self.chestnut: bool = False + self.chestnut_present: bool = False self.chestnut_compiled: bool = chestnut_compiled() self.chestnut_active: bool | None = None self.chestnut_loading: bool = False + self.chestnut_state = ChestnutState.DISCONNECTED self.started: bool = False self.ignition: bool = False self.recording_audio: bool = False @@ -126,6 +136,7 @@ class UIState: self.sm.update(0) self._update_state() self._update_status() + self._update_chestnut_state() device.update() def _params_refresh_worker(self): @@ -186,12 +197,35 @@ class UIState: self.status = UIStatus.DISENGAGED self.started_frame = self.sm.frame self.started_time = time.monotonic() + self.chestnut_present = self.sm["deviceState"].chestnutPresent for callback in self._offroad_transition_callbacks: callback() self._started_prev = self.started + def _update_chestnut_state(self) -> None: + detected = self.sm["deviceState"].chestnutPresent + if not self.started: + self.chestnut_present = detected + self.chestnut_state = (ChestnutState.READY if detected and self.chestnut_compiled else + ChestnutState.UNCOMPILED if detected else ChestnutState.DISCONNECTED) + return + + model_seen = self.sm.recv_frame["modelV2"] > self.started_frame + if not self.chestnut_present: + self.chestnut_state = ChestnutState.DISCONNECTED + elif not self.chestnut_compiled: + self.chestnut_state = ChestnutState.UNCOMPILED + elif self.chestnut_state == ChestnutState.FAILED or not detected or (model_seen and (not self.sm.alive["modelV2"] or not self.sm["modelV2"].big)): + self.chestnut_state = ChestnutState.FAILED + elif self.chestnut_loading or not model_seen: + self.chestnut_state = ChestnutState.LOADING + elif self.chestnut_active is False: + self.chestnut_state = ChestnutState.FAILED + else: + self.chestnut_state = ChestnutState.ACTIVE + def update_params(self) -> None: # For slower operations # Update longitudinal control state @@ -208,8 +242,6 @@ class UIState: self.always_on_dm = self.params.get_bool("AlwaysOnDM") self.experimental_mode = self.params.get_bool("ExperimentalMode") self.experimental_mode_confirmed = self.params.get_bool("ExperimentalModeConfirmed") - # keep chestnut UI active until offroad transition when gpu disappears - self.chestnut = self.sm["deviceState"].chestnutPresent or (self.chestnut and self.started) if not self.chestnut_compiled: self.chestnut_compiled = chestnut_compiled() self.chestnut_active = self.params.get("ChestnutActive") From 2d6cc4c065c4d1833dc267fff60ebae48b444817 Mon Sep 17 00:00:00 2001 From: Nayan Date: Thu, 27 Aug 2026 02:03:53 -0400 Subject: [PATCH 24/67] models: Model Selector upgrades (#1953) * uh, i did not commit anything all this time * slideee to the left, cha cha * lint lint * ui: unify model source predicate and per-source bundle lookup in model_info * [TIZI/TICI] ui: disable the other-model row onroad like the active row * [TIZI/TICI] ui: drop docstring that restates the function name * [TIZI/TICI] ui: keep Favorites as the first model folder in the picker * ui: record why model names read the params slots and not modelManagerSP * ui: show the default model's name on the picker Default entries * models: bind a download to its ref so cancel and reselect work everywhere * models: resume partial chunked downloads and verify silently * models: publish a verifying status so cached checks read as verification, not a stuck download * [TIZI/TICI] ui: move download status onto each model's own row * [TIZI/TICI] ui: show the row status description while it has text * [TIZI/TICI] ui: restore the Model Status bar row * models: a cancel interrupts verification immediately and keeps on-disk chunks * models: a selection made mid-download queues instead of cancelling the transfer * [TIZI/TICI] ui: Model Status shows both slots idle and the queued pick while busy * [TIZI/TICI] ui: label the Model Status slots small and big and scroll long names * models: start a queued download in the same tick and label empty slots (Default) * ui: scroll Model Status names at the corrected speed * [TIZI/TICI] ui: Model Status shows the big model failing over to small * [TIZI/TICI] ui: stable model rows and a runner-matched failover note on Model Status * [TIZI/TICI] ui: model rows show full names and the failover note reopens with the page * ui: name the actually driving model runner-matched and bring mici to state parity * fix ugly --------- Co-authored-by: Jason Wen Co-authored-by: James Vecellio-Grant <159560811+Discountchubbs@users.noreply.github.com> --- openpilot/cereal/custom.capnp | 1 + .../ui/sunnypilot/layouts/settings/models.py | 204 +++++++++++++----- .../ui/sunnypilot/mici/layouts/models.py | 118 +++++++--- .../selfdrive/ui/sunnypilot/model_info.py | 88 ++++++++ openpilot/sunnypilot/models/manager.py | 98 ++++++--- .../models/tests/test_manager_download.py | 82 +++++++ .../ui/sunnypilot/widgets/download_status.py | 47 +++- 7 files changed, 521 insertions(+), 117 deletions(-) create mode 100644 openpilot/selfdrive/ui/sunnypilot/model_info.py diff --git a/openpilot/cereal/custom.capnp b/openpilot/cereal/custom.capnp index c20bf923be..086b10c01c 100644 --- a/openpilot/cereal/custom.capnp +++ b/openpilot/cereal/custom.capnp @@ -131,6 +131,7 @@ struct ModelManagerSP @0xaedffd8f31e7b55d { downloaded @2; cached @3; failed @4; + verifying @5; } struct DownloadProgress { diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py index 93668014f6..3aa115139f 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py @@ -10,11 +10,10 @@ import time import pyray as rl from openpilot.cereal import custom -from openpilot.sunnypilot.models.default_model import get_default_model -from openpilot.sunnypilot.models.fetcher import ModelFetcher -from openpilot.sunnypilot.models.helpers import ACTIVE_BUNDLE_KEYS +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.system.ui.lib.multilang import tr from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.widgets import DialogResult, Widget @@ -38,7 +37,10 @@ class ModelsLayout(Widget): super().__init__() self.model_manager = None self.model_dialog = None + self._selection_source = None self._downloading = False + self._verifying = False + self._last_note = None self.last_cache_calc_time = 0 self._initialize_items() @@ -50,17 +52,24 @@ class ModelsLayout(Widget): self._scroller = Scroller(self.items, line_separator=True, spacing=0) def _initialize_items(self): - self.current_model_item = ListItemSP( - title=tr("Current Model"), + self.small_model_item = ListItemSP( + title=tr("Small Model"), description="", action_item=ScrollingButtonAction(tr("SELECT")), - callback=self._handle_current_model_clicked + callback=lambda: self._open_source_dialog("qcom") + ) + + self.big_model_item = ListItemSP( + title=tr("Big Model"), + action_item=ScrollingButtonAction(tr("SELECT")), + callback=lambda: self._open_source_dialog("usbgpu") ) 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_USBGPU", 0), gui_app.push_widget(alert_dialog(tr("Fetching Latest Models"))))) self.clear_cache_item = ListItemSP( @@ -70,7 +79,9 @@ class ModelsLayout(Widget): callback=self._clear_cache ) - self.cancel_download_item = button_item(tr("Cancel Download"), tr("Cancel"), "", lambda: ui_state.params.remove("ModelManager_DownloadRef")) + self.cancel_download_item = button_item(lambda: tr("Cancel Verification") if self._verifying else tr("Cancel Download"), + tr("Cancel"), "", + lambda: ui_state.params.remove("ModelManager_DownloadRef")) self.lane_turn_value_control = option_item_sp(tr("Adjust Lane Turn Speed"), "LaneTurnValue", 500, 2000, tr("Set the maximum speed for lane turn desires. Default is 19 mph."), @@ -95,7 +106,7 @@ class ModelsLayout(Widget): 1, None, True, "", style.BUTTON_ACTION_WIDTH, None, True, lambda v: f"{v / 100:.2f} m") - self.items = [self.current_model_item, self.cancel_download_item, self.download_item, self.refresh_item, self.clear_cache_item, + self.items = [self.small_model_item, self.big_model_item, self.cancel_download_item, self.download_item, self.refresh_item, self.clear_cache_item, self.lane_turn_desire_toggle, self.lane_turn_value_control, self.lagd_toggle, self.delay_control, self.camera_offset] def _update_lagd_description(self, lagd_toggle: bool): @@ -109,10 +120,6 @@ class ModelsLayout(Widget): 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) - def _is_downloading(self): - return (self.model_manager and self.model_manager.selectedBundle and - self.model_manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.downloading) - @staticmethod def calculate_cache_size(): cache_size = 0.0 @@ -135,36 +142,90 @@ class ModelsLayout(Widget): gui_app.push_widget(dialog) def _handle_bundle_download_progress(self): - self.download_item.set_visible(False) self.cancel_download_item.set_visible(False) self._downloading = False - - if not self.model_manager or (not self.model_manager.selectedBundle and not self.model_manager.activeBundle): - return - - bundle = self.model_manager.selectedBundle if self._is_downloading() or ( - self.model_manager.selectedBundle and self.model_manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.failed - ) else self.model_manager.activeBundle - if not bundle: - return - - self.cancel_download_item.set_visible(bool(self.model_manager.selectedBundle) and ui_state.params.get("ModelManager_DownloadRef") is not None) + self._verifying = False + self.download_item.set_visible(True) if (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") + bundle = self.model_manager.selectedBundle if self.model_manager else None + progresses = [model.artifact.downloadProgress for model in bundle.models if model.artifact.fileName] if bundle else [] + if not progresses or bundle.status not in (custom.ModelManagerSP.DownloadStatus.downloading, + custom.ModelManagerSP.DownloadStatus.failed): + self.download_item.action_item.update(name="", segments=self._slot_segments()) + return + + self.cancel_download_item.set_visible(ui_state.params.get("ModelManager_DownloadRef") is not None) if bundle.status == custom.ModelManagerSP.DownloadStatus.downloading: device._reset_interactive_timeout() - # every bundle is a single chunked artifact now - progresses = [model.artifact.downloadProgress for model in bundle.models if model.artifact.fileName] - if not progresses: - return - - self.download_item.set_visible(True) - self.download_item.action_item.update(**self._download_row_state(progresses, bundle.internalName)) + state = self._download_row_state(progresses, bundle.internalName) + if queued := queued_name(bundle.ref): + state["name"] += f" | {queued} {tr('queued')}" + self.download_item.action_item.update(**state) self._downloading = self.download_item.action_item.downloading + ds = custom.ModelManagerSP.DownloadStatus + self._verifying = any(getattr(p.status, 'raw', p.status) == ds.verifying for p in progresses) + + def _slot_segments(self): + """small and big slots side by side; green marks the slot whose pick is actually + driving (runner-matched, so a failed Default big greens neither slot), an empty + slot shows its default.""" + big_state = big_model_state() + carry_source, carry_internal, _ = carrying_model() + segments = [] + for source, label in (("qcom", tr("small")), ("usbgpu", tr("big"))): + if segments: + segments.append(("|", rl.GRAY, None, None)) + bundle = get_selected_bundle(ui_state.params, source) + name = bundle.internalName if bundle else default_model_name(source) + color = ON_COLOR if (source == carry_source and name == carry_internal) else rl.LIGHTGRAY + name = "● " + name + if source == "usbgpu": + if big_state == 'failed': + color = rl.RED + elif big_state == 'loading': + color = rl.GOLD + segments.append((label, rl.GRAY, None, None)) + segments.append((name, color, None, None)) + return segments + + @staticmethod + def _set_item_note(item, text): + # a description renders only while shown; hide before clearing or the + # empty description keeps its visible state + if text: + item.set_description(text) + item.show_description(True) + else: + item.show_description(False) + item.set_description("") + + def _status_note(self) -> str: + """The failover story for the Model Status row. One-way big -> small, and the + fallback is runner-matched: a Default big can only fall back to the Default + small (stock modeld), a custom big has no automatic fallback yet.""" + if not ui_state.usbgpu: + return "" + big_bundle = get_selected_bundle(ui_state.params, "usbgpu") + big_name = big_bundle.internalName if big_bundle else default_model_name("usbgpu") + big_is_default = big_bundle is None + fallback_name = default_model_name("qcom") + state = big_model_state() + if state == 'failed': + if big_is_default: + return tr("Big model unavailable, {} is driving until the next drive.").format(fallback_name) + return tr("Big model unavailable until the next drive.") + if state == 'loading': + if big_is_default: + return tr("{} drives until the big model is ready.").format(fallback_name) + return tr("Getting the big model ready.") + if big_is_default: + return tr("{} will drive. If it fails during a drive, {} takes over until the next drive.").format(big_name, fallback_name) + return tr("{} will drive when the eGPU is ready.").format(big_name) @staticmethod def _download_row_state(progresses, name: str) -> dict: @@ -177,6 +238,8 @@ class ModelsLayout(Widget): if ds.failed in statuses: # close.png is authored black and a tint cannot lift it, hence close2 return {"name": name, "status_text": tr("download failed"), "text_color": rl.RED, "icon": "icons/close2.png"} + if ds.verifying in statuses: + return {"name": name, "downloading": True, "progress": progress, "status_text": tr("verifying")} if ds.downloading in statuses: return {"name": name, "downloading": True, "progress": progress} if statuses <= {ds.downloaded, ds.cached}: @@ -186,46 +249,66 @@ class ModelsLayout(Widget): def _on_model_selected(self, result): if result != DialogResult.CONFIRM: + self.model_dialog = None return selected_ref = self.model_dialog.selection_ref - if selected_ref == "Default": - source = ModelFetcher.active_source(ui_state.sm["deviceState"].chestnutPresent) - ui_state.params.remove(ACTIVE_BUNDLE_KEYS[source]) - elif selected_bundle := next((bundle for bundle in self.model_manager.availableBundles if bundle.ref == selected_ref), None): - ui_state.params.put("ModelManager_DownloadRef", selected_bundle.ref) self.model_dialog = None + if selected_ref == "Default": + if self._selection_source in ACTIVE_BUNDLE_KEYS: + ui_state.params.remove(ACTIVE_BUNDLE_KEYS[self._selection_source]) + return + if selected_bundle := self._resolve_selected_bundle(selected_ref): + ui_state.params.put("ModelManager_DownloadRef", selected_bundle.ref) + + def _resolve_selected_bundle(self, ref): + source_bundles = {source: bundles_for_source(source) for source in ("qcom", "usbgpu")} + resolved = resolve_bundle_by_ref(ref, source_bundles) + return resolved[0] if resolved else None @staticmethod def _bundle_to_node(bundle): return TreeNode(bundle.ref, {'display_name': bundle.displayName, 'short_name': bundle.internalName}) - def _get_folders(self, favorites): - bundles = self.model_manager.availableBundles + def _get_folders(self, favorites, bundles): folders = {} for bundle in bundles: folders.setdefault(next((ov_ride.value for ov_ride in bundle.overrides if ov_ride.key == "folder"), ""), []).append(bundle) - folders_list = [TreeFolder("", [TreeNode("Default", {'display_name': f"{get_default_model()} (Default)", - 'short_name': "Default"})])] + folders_list = [] for folder, folder_bundles in sorted(folders.items(), key=lambda x: max((bundle.index for bundle in x[1]), default=-1), reverse=True): folder_bundles.sort(key=lambda bundle: bundle.index, reverse=True) name = folder + (f" - (Updated: {m.group(1)})" if folder_bundles and (m := re.search(r'\(([^)]*)\)[^(]*$', folder_bundles[0].displayName)) else "") folders_list.append(TreeFolder(name, [self._bundle_to_node(bundle) for bundle in folder_bundles])) if favorites and (fav_bundles := [bundle for bundle in bundles if bundle.ref in favorites]): - folders_list.insert(1, TreeFolder("Favorites", [self._bundle_to_node(bundle) for bundle in fav_bundles])) + folders_list.insert(0, TreeFolder("Favorites", [self._bundle_to_node(bundle) for bundle in fav_bundles])) return folders_list - def _handle_current_model_clicked(self): + def _open_source_dialog(self, source): + self._selection_source = source favs = ui_state.params.get("ModelManager_Favs") favorites = set(favs.split(';')) if favs else set() - folders_list = self._get_folders(favorites) - - active_ref = self.model_manager.activeBundle.ref if self.model_manager.activeBundle else "Default" - self.model_dialog = TreeOptionDialog(tr("Select a Model"), folders_list, active_ref, "ModelManager_Favs", - get_folders_fn=self._get_folders, on_exit=self._on_model_selected) + folders_list = self._source_folders(favorites, source) + if not folders_list: + gui_app.push_widget(alert_dialog(tr("No models are available for this hardware yet. Connect to the internet and refresh the model list."))) + return + self.model_dialog = TreeOptionDialog(tr("Select a Model"), folders_list, self._slot_active_ref(source), "ModelManager_Favs", + get_folders_fn=lambda favs: self._source_folders(favs, source), on_exit=self._on_model_selected) gui_app.push_widget(self.model_dialog) + def _source_folders(self, favorites, source): + bundles = bundles_for_source(source) + if not bundles: + return [] + folders_list = [TreeFolder("", [TreeNode("Default", {'display_name': default_model_name(source)})])] + folders_list.extend(self._get_folders(favorites, bundles)) + return folders_list + + @staticmethod + def _slot_active_ref(source: str) -> str: + bundle = get_selected_bundle(ui_state.params, source) + return bundle.ref if bundle else "Default" + def _update_state(self): advanced_controls: bool = ui_state.params.get_bool("ShowAdvancedControls") turn_desire: bool = ui_state.params.get_bool("LaneTurnDesire") @@ -244,20 +327,27 @@ class ModelsLayout(Widget): self._update_lagd_description(live_delay) self.model_manager = ui_state.sm["modelManagerSP"] self._handle_bundle_download_progress() - # read the slot through ui_state, not modelManagerSP: the manager republishes a - # tick after a chestnut change, and the stale bundle flashes the wrong model - active_name = (ui_state.active_bundle or {}).get("displayName") or f"{get_default_model()} (Default)" - self.current_model_item.action_item.set_value(active_name) - if not ui_state.is_offroad(): - self.current_model_item.action_item.set_enabled(False) - self.current_model_item.set_description(tr("Only available when vehicle is off, or always offroad mode is on")) - else: - self.current_model_item.action_item.set_enabled(True) - self.current_model_item.set_description("") + carry_source, _, carry_display = carrying_model() + for item, item_source in ((self.small_model_item, "qcom"), (self.big_model_item, "usbgpu")): + bundle = get_selected_bundle(ui_state.params, item_source) + name = bundle.displayName if bundle else default_model_name(item_source) + color = ON_COLOR if (item_source == carry_source and name == carry_display) else style.ITEM_TEXT_VALUE_COLOR + item.action_item.set_value(name, color) + + note = self._status_note() + if note != self._last_note: + self._last_note = note + self._set_item_note(self.download_item, note) + + offroad = ui_state.is_offroad() + self.small_model_item.action_item.set_enabled(offroad) + 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")) def _render(self, rect): self._scroller.render(rect) def show_event(self): self._scroller.show_event() + self._last_note = None # re-expand the failover note every time the page opens diff --git a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py index 87073d531f..183b47fa58 100644 --- a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py @@ -7,18 +7,37 @@ See the LICENSE.md file in the root directory for more details. import pyray as rl from openpilot.cereal import custom -from openpilot.sunnypilot.models.default_model import get_default_model -from openpilot.sunnypilot.models.fetcher import ModelFetcher -from openpilot.sunnypilot.models.helpers import ACTIVE_BUNDLE_KEYS +from openpilot.selfdrive.ui.mici.widgets.dialog import 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.sunnypilot.layouts.settings.models import ModelsLayout 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) 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 from openpilot.system.ui.widgets.label import UnifiedLabel from openpilot.system.ui.widgets.scroller import NavScroller +def _model_info() -> tuple[str, str, str]: + """(active model, info header, info text) for the panel. Runner-matched: the + active line names what actually drives, and a notable big-model state takes + the info pair.""" + source, active_name, other_name = model_info() + state = big_model_state() + _, _, carry_display = carrying_model() + if carry_display is None: + big = get_selected_bundle(ui_state.params, "usbgpu") + carry_display = big.displayName if big else default_model_name("usbgpu") + active_text = (carry_display or active_name).lower() + if state == 'failed': + return active_text, tr("big model"), tr("unavailable") + if state == 'loading': + return active_text, tr("big model"), tr("getting ready") + header = tr("small model") if source == "usbgpu" else tr("big model") + return active_text, header, other_name.lower() + + class CurrentModelInfo(Widget): def __init__(self): super().__init__() @@ -28,12 +47,12 @@ class CurrentModelInfo(Widget): header_color = rl.Color(255, 255, 255, int(255 * 0.9)) subheader_color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)) max_width = int(self._rect.width - 20) + active_text, info_header, info_text = _model_info() self.current_model_header = UnifiedLabel(tr("active model"), 48, max_width=max_width, text_color=header_color, font_weight=FontWeight.DISPLAY) - default_text = f"{get_default_model()} (Default)".lower() - self.current_model_text = UnifiedLabel(default_text, 32, max_width=max_width, text_color=subheader_color, font_weight=FontWeight.ROMAN, scroll=True) + self.current_model_text = UnifiedLabel(active_text, 32, max_width=max_width, text_color=subheader_color, font_weight=FontWeight.ROMAN, scroll=True) - self.info_header = UnifiedLabel("cache size", 48, max_width=max_width, text_color=header_color, font_weight=FontWeight.DISPLAY) - self.info_text = UnifiedLabel("0 mb", 32, max_width=max_width, text_color=subheader_color, font_weight=FontWeight.ROMAN) + self.info_header = UnifiedLabel(info_header, 48, max_width=max_width, text_color=header_color, font_weight=FontWeight.DISPLAY) + self.info_text = UnifiedLabel(info_text, 32, max_width=max_width, text_color=subheader_color, font_weight=FontWeight.ROMAN, scroll=True) def _render(self, _): self.current_model_header.set_position(self._rect.x + 20, self._rect.y - 10) @@ -57,6 +76,7 @@ class ModelsLayoutMici(NavScroller): self._download_progress = "." self._download_frame = 0 self._was_downloading = False + self._selection_source: str | None = None self.select_model_btn = BigButton(tr("select model")) self.select_model_btn.set_click_callback(self._show_folders) @@ -71,8 +91,7 @@ class ModelsLayoutMici(NavScroller): def model_manager(self): return ui_state.sm["modelManagerSP"] - def _get_grouped_bundles(self, favorites = None): - bundles = self.model_manager.availableBundles + def _get_grouped_bundles(self, bundles, favorites = None): folders = {} for bundle in bundles: folder = next((override.value for override in bundle.overrides if override.key == "folder"), "") @@ -92,48 +111,70 @@ class ModelsLayoutMici(NavScroller): def _show_folders(self): self.focused_widget = self.select_model_btn + hardware_btns = [] + active = active_source() + for source, label in (("qcom", tr("small models")), ("usbgpu", tr("big models"))): + bundle = get_selected_bundle(ui_state.params, source) + value = (bundle.internalName if bundle else default_model_name(source)).lower() + if source == active: + value += f" ({tr('active')})" + btn = BigButton(label.lower(), value=value) + btn.set_click_callback(lambda s=source: self._select_hardware(s)) + hardware_btns.append(btn) + self._push_selection_view(hardware_btns) + + def _select_hardware(self, source): + self._selection_source = source + favs = ui_state.params.get("ModelManager_Favs") favorites = set(favs.split(';')) if favs else set() - folders = self._get_grouped_bundles(favorites) + bundles = bundles_for_source(source) + if not bundles: + gui_app.push_widget(BigDialog(title=tr("No models available"), + description=tr("No models are available for this hardware yet. Connect to the internet and refresh the model list."))) + return + folders = self._get_grouped_bundles(bundles, favorites) + folder_buttons = [] - default_btn = BigButton(f"{get_default_model()} (Default)".lower()) - default_btn.set_click_callback(self._select_default) + default_btn = BigButton(default_model_name(source).lower()) + default_btn.set_click_callback(lambda s=source: self._select_default(s)) folder_buttons.append(default_btn) for folder in sorted(folders.keys(), key=lambda f: max((bundle.index for bundle in folders[f]), default=-1), reverse=True): - if folder.lower() in ["release models", "master models", "favorites"]: - btn = BigButton(folder.lower()) - btn.set_click_callback(lambda f=folder: self._select_folder(f)) - if folder.lower() == "favorites": - folder_buttons.insert(0, btn) - else: - folder_buttons.append(btn) + btn = BigButton(folder.lower()) + btn.set_click_callback(lambda f=folder: self._select_folder(f)) + if folder.lower() == "favorites": + folder_buttons.insert(0, btn) + else: + folder_buttons.append(btn) self._push_selection_view(folder_buttons) def _pop_to_main(self): gui_app.pop_widgets_to(self) + self._scroller.scroll_panel.set_offset(0.0) def _select_model(self, bundle): ui_state.params.put("ModelManager_DownloadRef", bundle.ref) self._pop_to_main() - def _select_default(self): - source = ModelFetcher.active_source(ui_state.sm["deviceState"].chestnutPresent) + def _select_default(self, source): ui_state.params.remove(ACTIVE_BUNDLE_KEYS[source]) self._pop_to_main() def _select_folder(self, folder_name): + source = self._selection_source + if source is None: # folders are only reachable after picking a hardware + return favs = ui_state.params.get("ModelManager_Favs") favorites = set(favs.split(';')) if favs else set() - folders = self._get_grouped_bundles(favorites) + folders = self._get_grouped_bundles(bundles_for_source(source), favorites) bundles = sorted(folders.get(folder_name, []), key=lambda b: b.index, reverse=True) btns = [] for bundle in bundles: - txt = bundle.displayName.lower() - btn = BigButton(txt) + btn = BigButton(bundle.displayName.lower()) btn.set_click_callback(lambda b=bundle: self._select_model(b)) btns.append(btn) self._push_selection_view(btns) @@ -165,12 +206,10 @@ class ModelsLayoutMici(NavScroller): self._was_downloading = is_downloading self.current_model_info.current_model_header.set_text(tr("active model")) - # read the slot through ui_state, not modelManagerSP: the manager republishes a - # tick after a chestnut change, and the stale bundle flashes the wrong model - model_text = ((ui_state.active_bundle or {}).get("displayName") or f"{get_default_model()} (Default)").lower() - self.current_model_info.current_model_text.set_text(model_text) - self.current_model_info.info_header.set_text(tr("cache size")) - self.current_model_info.info_text.set_text(f"{ModelsLayout.calculate_cache_size():.2f} MB") + active_text, info_header, info_text = _model_info() + self.current_model_info.current_model_text.set_text(active_text) + self.current_model_info.info_header.set_text(info_header) + self.current_model_info.info_text.set_text(info_text) if manager.selectedBundle and manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.failed: self.current_model_info.info_header.set_text(tr("error") + self._download_progress) @@ -181,18 +220,29 @@ class ModelsLayoutMici(NavScroller): device.set_override_interactive_timeout(5) progress = 0.0 count = 0 + verifying = False for model in manager.selectedBundle.models: count += 1 p = model.artifact.downloadProgress - if p.status == custom.ModelManagerSP.DownloadStatus.downloading: + if p.status in (custom.ModelManagerSP.DownloadStatus.downloading, + custom.ModelManagerSP.DownloadStatus.verifying): progress += p.progress + verifying = verifying or p.status == custom.ModelManagerSP.DownloadStatus.verifying elif p.status in (custom.ModelManagerSP.DownloadStatus.downloaded, custom.ModelManagerSP.DownloadStatus.cached): progress += 100.0 - self.current_model_info.current_model_header.set_text(tr("downloading")) + self.current_model_info.current_model_header.set_text(tr("verifying") if verifying else tr("downloading")) + self.cancel_download_btn.set_text(tr("cancel verification") if verifying else tr("cancel download")) self.current_model_info.current_model_header._shimmer = True - self.current_model_info.current_model_text.set_text(f"{manager.selectedBundle.internalName.lower()}") + name_text = manager.selectedBundle.internalName.lower() + if queued := queued_name(manager.selectedBundle.ref): + name_text += f" | {queued.lower()} {tr('queued')}" + self.current_model_info.current_model_text.set_text(name_text) self.current_model_info.info_header.set_text(tr("progress") + self._download_progress) self.current_model_info.info_header._shimmer = True self.current_model_info.info_text.set_text(f"{progress/count:.2f}%") + + elif manager.selectedBundle and manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.downloaded: + self.current_model_info.info_header.set_text(tr("downloaded")) + self.current_model_info.info_text.set_text(tr("downloaded")) diff --git a/openpilot/selfdrive/ui/sunnypilot/model_info.py b/openpilot/selfdrive/ui/sunnypilot/model_info.py new file mode 100644 index 0000000000..a93a06f187 --- /dev/null +++ b/openpilot/selfdrive/ui/sunnypilot/model_info.py @@ -0,0 +1,88 @@ +""" +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. +""" +from openpilot.selfdrive.ui.ui_state import ui_state +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 active_source() -> str: + return get_active_source(usbgpu=ui_state.usbgpu, + usbgpu_active=ui_state.usbgpu_active, usbgpu_loading=ui_state.usbgpu_loading, + offroad=ui_state.is_offroad()) + + +def bundles_for_source(source: str): + if source == active_source(): + return ui_state.sm["modelManagerSP"].availableBundles + return get_cached_bundles(ui_state.params, source) + + +def default_model(source: str) -> str: + return DEFAULT_BIG_MODEL if source == 'usbgpu' else DEFAULT_MODEL + + +def default_model_name(source: str) -> str: + return f"{default_model(source)} (Default)" + + +def big_model_state() -> str | None: + """'failed' | 'loading' | None, mirroring the sidebar's detection (#1969).""" + if ui_state.started and ui_state.usbgpu and ui_state.big_model_failed: + return 'failed' + big_selected = ui_state.usbgpu_compiled or ui_state.model_runner_tinygrad + if ui_state.usbgpu_loading or (big_selected and ui_state.started and ui_state.usbgpu_active is None): + return 'loading' + return None + + +def carrying_model() -> tuple[str | None, str | None, str | None]: + """(source, internal name, display name) of what actually drives. Runner-matched: + when a Default big cannot carry, stock modeld runs the Default small, never the + small slot's pick; a custom big has no automatic fallback yet -> (None, None, None).""" + source = active_source() + if source == "usbgpu": + bundle = get_selected_bundle(ui_state.params, "usbgpu") + if bundle: + return "usbgpu", bundle.internalName, bundle.displayName + name = default_model_name("usbgpu") + return "usbgpu", name, name + if ui_state.usbgpu: + if get_selected_bundle(ui_state.params, "usbgpu") is None: + name = default_model_name("qcom") + return "qcom", name, name + return None, None, None + bundle = get_selected_bundle(ui_state.params, "qcom") + if bundle: + return "qcom", bundle.internalName, bundle.displayName + name = default_model_name("qcom") + return "qcom", name, name + + +def queued_name(current_ref) -> str | None: + ref = ui_state.params.get("ModelManager_DownloadRef") + if ref and ref != current_ref: + source_bundles = {source: bundles_for_source(source) for source in ("qcom", "usbgpu")} + if resolved := resolve_bundle_by_ref(ref, source_bundles): + return resolved[0].internalName + return None + + +def model_info() -> tuple[str, str, str]: + """returns (active source, active model name, other model name) + + Names come from the params slots, never modelManagerSP.activeBundle — the + manager republishes a tick after a chestnut change, so the stale bundle + would flash the wrong model.""" + source = active_source() + other = "qcom" if source == "usbgpu" else "usbgpu" + active_bundle = get_selected_bundle(ui_state.params, source) + other_bundle = get_selected_bundle(ui_state.params, other) + + 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 diff --git a/openpilot/sunnypilot/models/manager.py b/openpilot/sunnypilot/models/manager.py index 2405566d55..178d6c04e5 100644 --- a/openpilot/sunnypilot/models/manager.py +++ b/openpilot/sunnypilot/models/manager.py @@ -24,6 +24,10 @@ from openpilot.sunnypilot.models.helpers import (ACTIVE_BUNDLE_KEYS, get_active_ DOWNLOAD_TIMEOUT = (30, 30) +class DownloadCancelled(Exception): + pass + + class ModelManagerSP: """Manages model downloads and status reporting""" @@ -39,6 +43,17 @@ class ModelManagerSP: self.active_bundle: custom.ModelManagerSP.ModelBundle = get_active_bundle(self.params, usbgpu=self.chestnut_present) self._chunk_size = 128 * 1000 # 128 KB chunks self._download_start_times: dict[str, float] = {} # Track start time per model + self._download_ref: bytes | str | None = None + + def _download_interrupted(self) -> bool: + # only removal cancels: a different ref is a queued selection that + # _release_download_ref leaves in place for the next tick + return self.params.get("ModelManager_DownloadRef") is None + + def _release_download_ref(self) -> None: + if self.params.get("ModelManager_DownloadRef") == self._download_ref: + self.params.remove("ModelManager_DownloadRef") + self._download_ref = None def _sync_artifact_progress(self, source_artifact) -> None: """Mirror download progress to all artifacts sharing the same filename in the selected bundle.""" @@ -80,8 +95,8 @@ class ModelManagerSP: f.write(chunk) bytes_downloaded += len(chunk) - if self.params.get("ModelManager_DownloadRef") is None: - raise Exception("Download cancelled") + if self._download_interrupted(): + raise DownloadCancelled("Download cancelled") if total_size > 0: progress = (bytes_downloaded / total_size) * 100 @@ -94,7 +109,7 @@ class ModelManagerSP: # Clean up start time after download completes del self._download_start_times[model.fileName] - async def _download_chunked(self, base_url: str, base_path: str, artifact) -> None: + async def _download_chunked(self, base_url: str, base_path: str, artifact, skip: frozenset[int] | set[int] = frozenset()) -> None: from openpilot.common.file_chunker import get_chunk_name, get_manifest_path num_chunks = len(artifact.chunks) @@ -106,8 +121,11 @@ class ModelManagerSP: # Shared connection saves a TCP+TLS handshake per chunk. # Keep sequential: the link saturates on one stream and Session is not thread-safe. + completed = len(skip) with requests.Session() as session: for i, _ in enumerate(artifact.chunks): + if i in skip: + continue chunk_url = get_chunk_name(base_url, i, num_chunks) chunk_path = get_chunk_name(base_path, i, num_chunks) chunk_downloaded = 0 @@ -118,15 +136,16 @@ class ModelManagerSP: for data in response.iter_content(chunk_size=self._chunk_size): f.write(data) chunk_downloaded += len(data) - if self.params.get("ModelManager_DownloadRef") is None: - raise Exception("Download cancelled") + if self._download_interrupted(): + raise DownloadCancelled("Download cancelled") intra = chunk_downloaded / max(chunk_size, 1) - progress = min(99.0, ((i + intra) / num_chunks) * 100) + progress = min(99.0, ((completed + intra) / num_chunks) * 100) artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.downloading artifact.downloadProgress.progress = progress artifact.downloadProgress.eta = self._calculate_eta(artifact.fileName, progress) self._sync_artifact_progress(artifact) self._report_status() + completed += 1 with open(manifest_path, 'w') as f: # noqa: ASYNC230 f.write(str(num_chunks)) @@ -137,6 +156,8 @@ class ModelManagerSP: async def _process_artifact(self, artifact, destination_path: str) -> None: if not artifact.downloadUri.uri: return None + if self._download_interrupted(): + raise DownloadCancelled("Download cancelled") url = artifact.downloadUri.uri expected_hash = artifact.downloadUri.sha256 @@ -144,21 +165,23 @@ class ModelManagerSP: full_path = os.path.join(destination_path, filename) try: + # progress counts only valid chunks so a resumed download continues the + # bar from where verification left it, instead of falling back to zero is_cached = False + valid_chunks: set[int] = set() if len(artifact.chunks) > 0: from openpilot.common.file_chunker import get_chunk_name num_chunks = len(artifact.chunks) - chunks_valid = True for i, chunk in enumerate(artifact.chunks): - chunk_path = get_chunk_name(full_path, i, num_chunks) - if not await verify_file(chunk_path, chunk.sha256): - chunks_valid = False - break - artifact.downloadProgress.progress = ((i + 1) / num_chunks) * 100 + if self._download_interrupted(): + raise DownloadCancelled("Download cancelled") + if await verify_file(get_chunk_name(full_path, i, num_chunks), chunk.sha256): + valid_chunks.add(i) + artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.verifying + artifact.downloadProgress.progress = (len(valid_chunks) / num_chunks) * 100 self._sync_artifact_progress(artifact) self._report_status() - if chunks_valid and num_chunks > 0: - is_cached = True + is_cached = len(valid_chunks) == num_chunks else: if await verify_file(full_path, expected_hash): is_cached = True @@ -172,7 +195,7 @@ class ModelManagerSP: return if len(artifact.chunks) > 0: - await self._download_chunked(url, full_path, artifact) + await self._download_chunked(url, full_path, artifact, skip=valid_chunks) from openpilot.common.file_chunker import get_chunk_name for i, chunk in enumerate(artifact.chunks): chunk_path = get_chunk_name(full_path, i, len(artifact.chunks)) @@ -189,6 +212,17 @@ class ModelManagerSP: self._sync_artifact_progress(artifact) self._report_status() + except DownloadCancelled: + # a cancel keeps whatever is on disk: complete chunks resume the next attempt + self._download_start_times.pop(artifact.fileName, None) + artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.failed + artifact.downloadProgress.eta = 0 + self._sync_artifact_progress(artifact) + if self.selected_bundle: + self.selected_bundle.status = custom.ModelManagerSP.DownloadStatus.failed + self._report_status() + raise + except Exception as e: cloudlog.error(f"Error downloading {filename}: {str(e)}") for f in [full_path] + [p for p in (os.path.join(destination_path, f) for f in os.listdir(destination_path)) if filename in p]: @@ -242,6 +276,8 @@ class ModelManagerSP: seen_artifacts.add(artifact.fileName) await self._process_artifact(artifact, destination_path) + if self._download_interrupted(): + raise DownloadCancelled("Download cancelled") self.selected_bundle.status = custom.ModelManagerSP.DownloadStatus.downloaded self.params.put(ACTIVE_BUNDLE_KEYS[source], model_bundle.to_dict(), block=True) self.active_bundle = get_active_bundle(self.params, usbgpu=self.chestnut_present) @@ -258,6 +294,27 @@ class ModelManagerSP: """Main entry point for downloading a model bundle""" asyncio.run(self._download_bundle(model_bundle, destination_path, source)) + def _process_download_requests(self) -> None: + # loops so a ref queued during a download starts in the same tick, without + # the bar dropping to idle for a tick between the two transfers + last_ref = None + while (ref_to_download := self.params.get("ModelManager_DownloadRef")) is not None: + if ref_to_download == last_ref: # a repeating ref falls back to the next tick instead of spinning + return + last_ref = ref_to_download + resolved = resolve_bundle_by_ref(ref_to_download, self.source_models) + if not resolved: + return + model_to_download, source = resolved + self._download_ref = ref_to_download + try: + self.download(model_to_download, Paths.model_root(), source) + except Exception as e: + cloudlog.exception(e) + finally: + self._release_download_ref() + self.selected_bundle = None + def main_thread(self) -> None: """Main thread for model management""" rk = Ratekeeper(1, print_delay_threshold=None) @@ -271,16 +328,7 @@ class ModelManagerSP: validate_active_bundles(self.params, self.source_models) self.active_bundle = get_active_bundle(self.params, usbgpu=self.chestnut_present) - if (ref_to_download := self.params.get("ModelManager_DownloadRef")) is not None: - if resolved := resolve_bundle_by_ref(ref_to_download, self.source_models): - model_to_download, source = resolved - try: - self.download(model_to_download, Paths.model_root(), source) - except Exception as e: - cloudlog.exception(e) - finally: - self.params.remove("ModelManager_DownloadRef") - self.selected_bundle = None + self._process_download_requests() if self.params.get("ModelManager_ClearCache"): self.clear_model_cache() diff --git a/openpilot/sunnypilot/models/tests/test_manager_download.py b/openpilot/sunnypilot/models/tests/test_manager_download.py index d74deb03e6..4d3b7989fb 100644 --- a/openpilot/sunnypilot/models/tests/test_manager_download.py +++ b/openpilot/sunnypilot/models/tests/test_manager_download.py @@ -103,6 +103,7 @@ class ManagerDownloadTestBase(OpenpilotTestCase): self.manager = ModelManagerSP.__new__(ModelManagerSP) self.manager.params = mock.MagicMock() self.manager.params.get.return_value = b'0' # not cancelled + self.manager._download_ref = b'0' self.manager.pm = mock.MagicMock() self.manager.pm.send.side_effect = self._record_progress self.manager.selected_bundle = None @@ -261,6 +262,7 @@ class TestManagerDownload(ManagerDownloadTestBase): artifact = self.make_artifact(chunked=True) base_path = os.path.join(self.dest, artifact.fileName) self.manager.params.get.side_effect = lambda key: b"ref" if key == "ModelManager_DownloadRef" else None + self.manager._download_ref = b"ref" asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact)) assert os.path.isfile(get_manifest_path(base_path)) self.run_with_server(body) @@ -279,12 +281,92 @@ class TestManagerDownload(ManagerDownloadTestBase): return b"0" self.manager.params.get.side_effect = get + self.manager._download_ref = b"ref" with self.assertRaises(Exception) as ctx: asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact)) assert 'cancelled' in str(ctx.exception).lower() assert not os.path.isfile(get_manifest_path(base_path)) self.run_with_server(body) + def test_replaced_download_ref_queues_instead_of_cancelling(self): + """Selecting another model mid-transfer lets the running download finish.""" + def body(): + artifact = self.make_artifact(chunked=True) + base_path = os.path.join(self.dest, artifact.fileName) + self.manager.params.get.side_effect = lambda key: b"other-ref" if key == "ModelManager_DownloadRef" else None + self.manager._download_ref = b"ref" + asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact)) + assert os.path.isfile(get_manifest_path(base_path)) + self.run_with_server(body) + + def test_replaced_download_ref_is_kept(self): + """A selection made during a download must survive that download's cleanup.""" + self.manager.params.get.return_value = b"new-ref" + self.manager._download_ref = b"old-ref" + self.manager._release_download_ref() + self.manager.params.remove.assert_not_called() + + def test_own_download_ref_is_released(self): + self.manager.params.get.return_value = b"ref" + self.manager._download_ref = b"ref" + self.manager._release_download_ref() + self.manager.params.remove.assert_called_once_with("ModelManager_DownloadRef") + + def test_cached_bundle_cancel_skips_slot_write(self): + """A cancel must stop an already-on-disk bundle before it is applied to the slot.""" + def body(): + artifact = self.make_artifact(chunked=True) + base_path = os.path.join(self.dest, artifact.fileName) + for i, data in enumerate(CHUNK_BODIES): + with open(get_chunk_name(base_path, i, len(CHUNK_BODIES)), 'wb') as f: + f.write(data) + self._bundle.ref = "test-ref" + params, store = self._make_params_with_store() + store["ModelManager_DownloadRef"] = None # removed -> cancelled + self.manager.params = params + self.manager._download_ref = b"ref" + with self.assertRaises(Exception) as ctx: + asyncio.run(self.manager._download_bundle(self._bundle, self.dest, "qcom")) + assert 'cancelled' in str(ctx.exception).lower() + assert "ModelManager_ActiveBundle" not in store + assert all(os.path.isfile(p) for p in self.chunk_paths(base_path)), "cancel must not delete cached chunks" + self.run_with_server(body) + + def test_resume_skips_valid_chunks(self): + """A chunk already on disk is kept and not re-downloaded; progress starts above its share.""" + def body(): + artifact = self.make_artifact(chunked=True) + base_path = os.path.join(self.dest, artifact.fileName) + with open(get_chunk_name(base_path, 0, len(CHUNK_BODIES)), 'wb') as f: + f.write(CHUNK_BODIES[0]) + + asyncio.run(self.manager._process_artifact(artifact, self.dest)) + + chunk0_suffix = get_chunk_name('', 0, len(CHUNK_BODIES)) + assert not any(p.endswith(chunk0_suffix) for p in DownloadHandler.request_paths), "valid chunk was re-downloaded" + for i, expected in enumerate(CHUNK_BODIES): + with open(get_chunk_name(base_path, i, len(CHUNK_BODIES)), 'rb') as f: + assert f.read() == expected + assert os.path.isfile(get_manifest_path(base_path)) + assert min(self.reported) >= (1 / len(CHUNK_BODIES)) * 100 - 1, "progress must not restart below the resumed share" + self.run_with_server(body) + + def test_verify_reports_valid_fraction_then_cached(self): + """A fully cached bundle publishes climbing verify progress and ends cached.""" + def body(): + artifact = self.make_artifact(chunked=True) + base_path = os.path.join(self.dest, artifact.fileName) + for i, data in enumerate(CHUNK_BODIES): + with open(get_chunk_name(base_path, i, len(CHUNK_BODIES)), 'wb') as f: + f.write(data) + + asyncio.run(self.manager._process_artifact(artifact, self.dest)) + + assert DownloadHandler.request_paths == [], "cached bundle must not hit the network" + assert [round(p) for p in self.reported[:3]] == [33, 67, 100] + assert artifact.downloadProgress.status == custom.ModelManagerSP.DownloadStatus.cached + self.run_with_server(body) + def _make_params_with_store(self): params = mock.MagicMock() store = {} diff --git a/openpilot/system/ui/sunnypilot/widgets/download_status.py b/openpilot/system/ui/sunnypilot/widgets/download_status.py index b299c464f1..135bd151a5 100644 --- a/openpilot/system/ui/sunnypilot/widgets/download_status.py +++ b/openpilot/system/ui/sunnypilot/widgets/download_status.py @@ -16,6 +16,7 @@ from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.sunnypilot.lib.styles import style from openpilot.system.ui.sunnypilot.widgets.list_view import ListItemSP from openpilot.system.ui.widgets.label import UnifiedLabel +from openpilot.system.ui.sunnypilot.lib.utils import UnifiedLabelSP from openpilot.system.ui.widgets.list_view import ItemAction FONT_SIZE = style.ITEM_TEXT_FONT_SIZE @@ -24,6 +25,8 @@ ICON_PADDING = 12 BAR_WIDTH = 1100 BAR_HEIGHT = 20 +SEGMENT_GAP = 24 +SEGMENT_NAME_MAX_WIDTH = 380 BAR_GAP = 16 BAR_RADIUS = BAR_HEIGHT / 2 CAPSULE_POINTS = 24 @@ -45,6 +48,8 @@ class DownloadStatusAction(ItemAction): super().__init__(width=BAR_WIDTH) self.name = "" self.status_text = "" + self.segments: list[tuple[str, rl.Color, str | None, rl.Color | None]] | None = None + self._segment_labels: list[UnifiedLabelSP] = [] self.downloading = False self.text_color = rl.GRAY self.icon: str | None = None @@ -62,7 +67,8 @@ class DownloadStatusAction(ItemAction): alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) - def update(self, name, downloading=False, progress=0.0, status_text="", text_color=rl.GRAY, icon=None, icon_color=None): + def update(self, name, downloading=False, progress=0.0, status_text="", text_color=rl.GRAY, icon=None, icon_color=None, segments=None): + self.segments = segments if downloading and not self.downloading: self._name_label.reset_shimmer() self._progress.x = progress @@ -85,11 +91,22 @@ class DownloadStatusAction(ItemAction): def get_width_hint(self) -> float: if self.downloading: return BAR_WIDTH + if self.segments: + return sum(total for _, _, total in self._measured_segments()) width = measure_text_cached(self._font, self._idle_text, FONT_SIZE).x if self.icon: width += ICON_SIZE + ICON_PADDING return width + def _measured_segments(self): + """[(segment, text width, total width incl. icon and gap)]""" + out = [] + for i, seg in enumerate(self.segments or []): + text_width = min(measure_text_cached(self._font, seg[0], FONT_SIZE).x, SEGMENT_NAME_MAX_WIDTH) + total = text_width + (ICON_PADDING + ICON_SIZE if seg[2] else 0) + (SEGMENT_GAP if i else 0) + out.append((seg, text_width, total)) + return out + def _render(self, rect: rl.Rectangle): if self.downloading: self._render_downloading(rect) @@ -134,6 +151,8 @@ class DownloadStatusAction(ItemAction): def _render_downloading(self, rect: rl.Rectangle): percent = f"{int(self._progress.x)}%" + if self.status_text: + percent = f"{self.status_text} {percent}" text_height = measure_text_cached(self._font, percent, FONT_SIZE).y top = rect.y + (rect.height - (text_height + BAR_GAP + BAR_HEIGHT)) / 2 @@ -148,6 +167,9 @@ class DownloadStatusAction(ItemAction): self._draw_fill(rail, max(0.0, min(rect.width, rect.width * (self._progress.x / 100.0)))) def _render_idle(self, rect: rl.Rectangle): + if self.segments: + self._render_segments(rect) + return text = self._idle_text text_size = measure_text_cached(self._font, text, FONT_SIZE) right = rect.x + rect.width @@ -161,6 +183,29 @@ class DownloadStatusAction(ItemAction): rl.draw_text_ex(self._font, text, rl.Vector2(right - text_size.x, rect.y + (rect.height - text_size.y) / 2), FONT_SIZE, 0, self.text_color) + def _render_segments(self, rect: rl.Rectangle): + measured = self._measured_segments() + while len(self._segment_labels) < len(measured): + self._segment_labels.append(UnifiedLabelSP("", font_size=FONT_SIZE, max_width=SEGMENT_NAME_MAX_WIDTH, + scroll=True, wrap_text=False)) + x = rect.x + rect.width - sum(total for _, _, total in measured) + for i, ((text, color, icon, icon_color), text_width, _) in enumerate(measured): + if i: + x += SEGMENT_GAP + label = self._segment_labels[i] + if label.text != text: + label.set_text(text) + label.set_text_color(color) + text_height = measure_text_cached(self._font, text, FONT_SIZE).y + label.set_position(x, rect.y + (rect.height - text_height) / 2) + label.render() + x += text_width + if icon: + texture = gui_app.texture(icon, ICON_SIZE, ICON_SIZE, keep_aspect_ratio=True) + rl.draw_texture_v(texture, rl.Vector2(x + ICON_PADDING, rect.y + (rect.height - texture.height) / 2), + icon_color or color) + x += ICON_PADDING + ICON_SIZE + def download_status_item(title): return ListItemSP(title=title, action_item=DownloadStatusAction(), title_color=style.ITEM_TEXT_COLOR) From 9f43d2477d29198f21e528c50cc2a7b20ba82f23 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Thu, 27 Aug 2026 03:52:57 -0400 Subject: [PATCH 25/67] [MICI] ui: move and restyle the sunnylink pill in settings (#1972) --- .../ui/sunnypilot/mici/layouts/settings.py | 18 ++++++++++++++---- .../selfdrive/assets/icons_mici/sunnylink.png | 3 +++ 2 files changed, 17 insertions(+), 4 deletions(-) create mode 100644 openpilot/sunnypilot/selfdrive/assets/icons_mici/sunnylink.png diff --git a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/settings.py b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/settings.py index f14efe51a3..4c0eba41ea 100644 --- a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/settings.py +++ b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/settings.py @@ -12,13 +12,23 @@ from openpilot.selfdrive.ui.mici.widgets.dialog import BigConfirmationDialog, Bi from openpilot.selfdrive.ui.sunnypilot.mici.layouts.sunnylink import SunnylinkLayoutMici from openpilot.selfdrive.ui.sunnypilot.mici.layouts.models import ModelsLayoutMici from openpilot.selfdrive.ui.ui_state import ui_state -from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.multilang import tr ICON_SIZE = 70 BIG_ICON_SIZE = 110 +class SunnylinkBigButton(SettingsBigButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._label.set_font_weight(FontWeight.AUDIOWIDE) + + def _get_label_font_size(self): + # Audiowide runs wider than Inter: "sunnylink" wraps to two lines at 64 + return 56 + + class SettingsLayoutSP(OP.SettingsLayout): def __init__(self): OP.SettingsLayout.__init__(self) @@ -33,7 +43,7 @@ class SettingsLayoutSP(OP.SettingsLayout): self.icon_offroad_slider = gui_app.texture("icons_mici/settings/device/lkas.png", BIG_ICON_SIZE, BIG_ICON_SIZE) sunnylink_panel = SunnylinkLayoutMici() - sunnylink_btn = SettingsBigButton(tr("sunnylink"), "", gui_app.texture("icons_mici/settings/developer/ssh.png", 55, 55)) + sunnylink_btn = SunnylinkBigButton(tr("sunnylink"), "", gui_app.texture("../../sunnypilot/selfdrive/assets/icons_mici/sunnylink.png", 76, 44)) sunnylink_btn.set_click_callback(lambda: gui_app.push_widget(sunnylink_panel)) models_panel = ModelsLayoutMici() @@ -56,8 +66,8 @@ class SettingsLayoutSP(OP.SettingsLayout): items = self._scroller._items.copy() - items.insert(1, sunnylink_btn) - items.insert(2, models_btn) + items.insert(1, models_btn) + items.insert(5, sunnylink_btn) # front slots (only one ever visible at a time): exit-always-offroad, then enable-onroad items.insert(0, self._enable_offroad_btn_onroad) diff --git a/openpilot/sunnypilot/selfdrive/assets/icons_mici/sunnylink.png b/openpilot/sunnypilot/selfdrive/assets/icons_mici/sunnylink.png new file mode 100644 index 0000000000..6639536f9a --- /dev/null +++ b/openpilot/sunnypilot/selfdrive/assets/icons_mici/sunnylink.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:447099e93e303b29e7b3eac237bb0f27f8c5e12786991139aee2432532a75f58 +size 12310 From 4075befc5e5fea9a4fc1e68eaa23ca5bbd01824f Mon Sep 17 00:00:00 2001 From: Nayan Date: Thu, 27 Aug 2026 11:23:26 -0400 Subject: [PATCH 26/67] osm: support map deletion via sunnylink (#1971) delete delete --- openpilot/common/params_keys.h | 1 + .../ui/sunnypilot/layouts/settings/osm.py | 15 ++------------- openpilot/sunnypilot/mapd/mapd_manager.py | 17 +++++++++++++++++ 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index 4d8ffb64eb..5461424c13 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -246,6 +246,7 @@ inline static std::unordered_map keys = { // mapd {"MapAdvisorySpeedLimit", {CLEAR_ON_ONROAD_TRANSITION, FLOAT}}, + {"Mapd_ClearCache", {CLEAR_ON_MANAGER_START, BOOL}}, {"MapdVersion", {PERSISTENT, STRING}}, {"MapSpeedLimit", {CLEAR_ON_ONROAD_TRANSITION, FLOAT, "0.0"}}, {"NextMapSpeedLimit", {CLEAR_ON_ONROAD_TRANSITION, JSON}}, diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/osm.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/osm.py index 7b30e880f9..8e1c4afe72 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/osm.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/osm.py @@ -8,7 +8,6 @@ import datetime import os import platform import requests -import shutil import threading from pathlib import Path from time import monotonic @@ -75,22 +74,12 @@ class OSMLayout(Widget): def _update_map_size(self): threading.Thread(target=self.calculate_size, daemon=True).start() - def _do_delete_maps(self): - if MAP_PATH.exists(): - shutil.rmtree(MAP_PATH) - - for param in ("OsmDownloadedDate", "OsmLocal", "OsmLocationName", "OsmLocationTitle", "OsmStateName", "OsmStateTitle"): - ui_state.params.remove(param) - + def _on_confirm_delete_maps(self): + ui_state.params.put_bool("Mapd_ClearCache", True) self._delete_maps_btn.action_item.set_enabled(True) self._delete_maps_btn.action_item.set_text(tr("DELETE")) self._update_map_size() - def _on_confirm_delete_maps(self): - self._delete_maps_btn.action_item.set_enabled(False) - self._delete_maps_btn.action_item.set_text("DELETING...") - threading.Thread(target=self._do_delete_maps).start() - def _delete_maps(self): self._show_confirm(tr("This will delete ALL downloaded maps\n\nAre you sure you want to delete all maps?"), tr("Yes, delete all maps"), self._on_confirm_delete_maps) diff --git a/openpilot/sunnypilot/mapd/mapd_manager.py b/openpilot/sunnypilot/mapd/mapd_manager.py index 2251289bbe..899b0c2bd9 100755 --- a/openpilot/sunnypilot/mapd/mapd_manager.py +++ b/openpilot/sunnypilot/mapd/mapd_manager.py @@ -55,6 +55,19 @@ def cleanup_old_osm_data(files_to_remove: list[str]) -> None: shutil.rmtree(file, ignore_errors=False) +def clear_downloaded_maps() -> None: + """Deletes downloaded OSM map data and resets params.""" + path = f"{Paths.mapd_root()}/offline" + if os.path.exists(path): + shutil.rmtree(path, ignore_errors=True) + + for param in ("OsmDownloadedDate", "OsmLocal", "OsmLocationName", "OsmLocationTitle", + "OsmStateName", "OsmStateTitle"): + params.remove(param) + + cloudlog.info("mapd: downloaded maps cleared") + + def request_refresh_osm_location_data(nations: list[str], states: list[str] | None = None) -> None: params.put("OsmDownloadedDate", str(datetime.now().timestamp()), block=True) params.put_bool("OsmDbUpdatesCheck", False, block=True) @@ -131,6 +144,10 @@ def main_thread(): show_alert = bool(get_files_for_cleanup() and params.get_bool("OsmLocal")) set_offroad_alert("Offroad_OSMUpdateRequired", show_alert, "This alert will be cleared when new maps are downloaded.") + if params.get("Mapd_ClearCache"): + clear_downloaded_maps() + params.remove("Mapd_ClearCache") + update_osm_db() live_map_sp.tick() rk.keep_time() From 31ea1850f73a0f8049dd5a655b7bc1f03d5389be Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:54:52 -0700 Subject: [PATCH 27/67] ui: remove raygui usage (#38708) * ui: remove raygui usage * match previous gui_text_box line spacing * Revert "match previous gui_text_box line spacing" This reverts commit ffd2fe31725c6d50bffaebc621c1e170d0926c66. * Reapply "match previous gui_text_box line spacing" This reverts commit d41404f09607e225f43868f7747f22dc0bb2cf16. --- openpilot/selfdrive/ui/body/layouts/onroad.py | 6 +- openpilot/selfdrive/ui/layouts/home.py | 4 +- openpilot/selfdrive/ui/layouts/onboarding.py | 8 +- openpilot/selfdrive/ui/mici/layouts/home.py | 6 +- .../ui/mici/layouts/offroad_alerts.py | 14 +-- .../selfdrive/ui/mici/layouts/onboarding.py | 4 +- .../ui/mici/onroad/alert_renderer.py | 6 +- .../ui/mici/onroad/augmented_road_view.py | 6 +- .../ui/mici/onroad/cabin_camera_dialog.py | 18 ++-- openpilot/selfdrive/ui/mici/widgets/button.py | 14 +-- .../selfdrive/ui/onroad/alert_renderer.py | 10 +-- .../ui/onroad/cabin_camera_dialog.py | 4 +- openpilot/system/ui/lib/application.py | 23 ++--- openpilot/system/ui/lib/utils.py | 19 ----- openpilot/system/ui/mici_setup.py | 12 +-- openpilot/system/ui/mici_updater.py | 4 +- openpilot/system/ui/tici_reset.py | 5 +- openpilot/system/ui/tici_setup.py | 26 +++--- openpilot/system/ui/tici_updater.py | 9 +- openpilot/system/ui/widgets/button.py | 6 +- openpilot/system/ui/widgets/keyboard.py | 6 +- openpilot/system/ui/widgets/label.py | 85 ++++++------------- openpilot/system/ui/widgets/list_view.py | 10 +-- openpilot/system/ui/widgets/network.py | 10 +-- openpilot/system/ui/widgets/option_dialog.py | 4 +- openpilot/system/ui/widgets/slider.py | 6 +- 26 files changed, 140 insertions(+), 185 deletions(-) delete mode 100644 openpilot/system/ui/lib/utils.py diff --git a/openpilot/selfdrive/ui/body/layouts/onroad.py b/openpilot/selfdrive/ui/body/layouts/onroad.py index a48e525628..94f1342abd 100644 --- a/openpilot/selfdrive/ui/body/layouts/onroad.py +++ b/openpilot/selfdrive/ui/body/layouts/onroad.py @@ -1,7 +1,7 @@ import time import pyray as rl -from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment, TextAlignmentVertical from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.label import UnifiedLabel from openpilot.selfdrive.ui.ui_state import ui_state @@ -26,8 +26,8 @@ class BodyLayout(Widget): self._last_input_time = time.monotonic() self._was_active = False self._offroad_label = UnifiedLabel("turn on ignition to use", 95 if gui_app.big_ui() else 45, FontWeight.DISPLAY, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + alignment=TextAlignment.CENTER, + alignment_vertical=TextAlignmentVertical.MIDDLE) def draw_dot_grid(self, rect: rl.Rectangle, dots: list[tuple[int, int]], color: rl.Color): spacing = min(rect.height / GRID_ROWS, rect.width / GRID_COLS) diff --git a/openpilot/selfdrive/ui/layouts/home.py b/openpilot/selfdrive/ui/layouts/home.py index 183c2d4588..613fc3de3d 100644 --- a/openpilot/selfdrive/ui/layouts/home.py +++ b/openpilot/selfdrive/ui/layouts/home.py @@ -8,7 +8,7 @@ from openpilot.selfdrive.ui.widgets.exp_mode_button import ExperimentalModeButto from openpilot.selfdrive.ui.widgets.prime import PrimeWidget from openpilot.selfdrive.ui.widgets.setup import SetupWidget from openpilot.system.ui.lib.text_measure import measure_text_cached -from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos +from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, TextAlignment from openpilot.system.ui.lib.multilang import tr, trn from openpilot.system.ui.widgets.label import gui_label from openpilot.system.ui.widgets import Widget @@ -178,7 +178,7 @@ class HomeLayout(Widget): version_rect = rl.Rectangle(self.header_rect.x + self.header_rect.width - version_text_width, self.header_rect.y, version_text_width, self.header_rect.height) - gui_label(version_rect, self._version_text, 48, rl.WHITE, alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT) + gui_label(version_rect, self._version_text, 48, rl.WHITE, alignment=TextAlignment.RIGHT) def _render_home_content(self): self._render_left_column() diff --git a/openpilot/selfdrive/ui/layouts/onboarding.py b/openpilot/selfdrive/ui/layouts/onboarding.py index 97ca89b50c..91b674d52d 100644 --- a/openpilot/selfdrive/ui/layouts/onboarding.py +++ b/openpilot/selfdrive/ui/layouts/onboarding.py @@ -5,7 +5,7 @@ from enum import IntEnum import pyray as rl from openpilot.common.basedir import BASEDIR -from openpilot.system.ui.lib.application import FontWeight, gui_app +from openpilot.system.ui.lib.application import FontWeight, gui_app, TextAlignment from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.button import Button, ButtonStyle @@ -112,9 +112,9 @@ class TermsPage(Widget): self._on_accept = on_accept self._on_decline = on_decline - self._title = Label(tr("Welcome to openpilot"), font_size=90, font_weight=FontWeight.BOLD, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT) + self._title = Label(tr("Welcome to openpilot"), font_size=90, font_weight=FontWeight.BOLD, text_alignment=TextAlignment.LEFT) self._desc = Label(tr("You must accept the Terms and Conditions to use openpilot. Read the latest terms at https://comma.ai/terms before continuing."), - font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT) + font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=TextAlignment.LEFT) self._decline_btn = Button(tr("Decline"), click_callback=on_decline) self._accept_btn = Button(tr("Agree"), button_style=ButtonStyle.PRIMARY, click_callback=on_accept) @@ -147,7 +147,7 @@ class DeclinePage(Widget): def __init__(self, back_callback=None): super().__init__() self._text = Label(tr("You must accept the Terms and Conditions in order to use openpilot."), - font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT) + font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=TextAlignment.LEFT) self._back_btn = Button(tr("Back"), click_callback=back_callback) self._uninstall_btn = Button(tr("Decline, uninstall openpilot"), button_style=ButtonStyle.DANGER, click_callback=self._on_uninstall_clicked) diff --git a/openpilot/selfdrive/ui/mici/layouts/home.py b/openpilot/selfdrive/ui/mici/layouts/home.py index 519580925f..64f2961326 100644 --- a/openpilot/selfdrive/ui/mici/layouts/home.py +++ b/openpilot/selfdrive/ui/mici/layouts/home.py @@ -8,7 +8,7 @@ from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.layouts import HBoxLayout from openpilot.system.ui.widgets.icon_widget import IconWidget from openpilot.system.ui.widgets.label import UnifiedLabel, gui_label -from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos +from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, TextAlignment, TextAlignmentVertical from openpilot.selfdrive.ui.ui_state import ui_state, ChestnutState from openpilot.common.version import RELEASE_BRANCHES @@ -69,8 +69,8 @@ class AlertsPill(Widget): count_rect = rl.Rectangle(self.rect.x + self.COUNT_OFFSET, self.rect.y, pill_w - self.COUNT_OFFSET, pill_h) gui_label(count_rect, str(alert_count), font_size=36, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + alignment=TextAlignment.CENTER, + alignment_vertical=TextAlignmentVertical.MIDDLE) class NetworkIcon(Widget): diff --git a/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py b/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py index c27e05f752..40e074d455 100644 --- a/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py +++ b/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py @@ -11,7 +11,7 @@ from openpilot.common.hardware import HARDWARE from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.label import UnifiedLabel from openpilot.system.ui.widgets.scroller import Scroller -from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment, TextAlignmentVertical from openpilot.system.ui.lib.multilang import tr REFRESH_INTERVAL = 5.0 # seconds @@ -62,12 +62,12 @@ class AlertItem(Widget): self._icon_green = gui_app.texture("icons_mici/offroad_alerts/green_wheel.png", self.ICON_SIZE, self.ICON_SIZE) self._title_label = UnifiedLabel(text="", font_size=32, font_weight=FontWeight.SEMI_BOLD, text_color=self.TEXT_COLOR, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, line_height=0.95) + alignment=TextAlignment.LEFT, + alignment_vertical=TextAlignmentVertical.TOP, line_height=0.95) self._body_label = UnifiedLabel(text="", font_size=28, font_weight=FontWeight.ROMAN, text_color=self.TEXT_COLOR, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, line_height=0.95) + alignment=TextAlignment.LEFT, + alignment_vertical=TextAlignmentVertical.BOTTOM, line_height=0.95) self._title_text = "" self._body_text = "" @@ -200,8 +200,8 @@ class MiciOffroadAlerts(Scroller): # Create empty state label self._empty_label = UnifiedLabel(tr("no alerts"), 65, FontWeight.DISPLAY, rl.WHITE, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + alignment=TextAlignment.CENTER, + alignment_vertical=TextAlignmentVertical.MIDDLE) # Build initial alert list self._build_alerts() diff --git a/openpilot/selfdrive/ui/mici/layouts/onboarding.py b/openpilot/selfdrive/ui/mici/layouts/onboarding.py index 2b448ac73d..0590440c6b 100644 --- a/openpilot/selfdrive/ui/mici/layouts/onboarding.py +++ b/openpilot/selfdrive/ui/mici/layouts/onboarding.py @@ -4,7 +4,7 @@ import pyray as rl from collections.abc import Callable from openpilot.common.filter_simple import FirstOrderFilter from openpilot.common.qrcode import make_texture -from openpilot.system.ui.lib.application import FontWeight, gui_app +from openpilot.system.ui.lib.application import FontWeight, gui_app, TextAlignment from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.button import SmallCircleIconButton from openpilot.system.ui.widgets.scroller import NavScroller, Scroller @@ -33,7 +33,7 @@ class CabinCameraSetupDialog(BaseCabinCameraDialog): if not self._camera_view.frame: gui_label(rect, tr("camera starting"), font_size=64, font_weight=FontWeight.BOLD, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) + alignment=TextAlignment.CENTER) rl.end_scissor_mode() return diff --git a/openpilot/selfdrive/ui/mici/onroad/alert_renderer.py b/openpilot/selfdrive/ui/mici/onroad/alert_renderer.py index d2896e1807..e42ee6c438 100644 --- a/openpilot/selfdrive/ui/mici/onroad/alert_renderer.py +++ b/openpilot/selfdrive/ui/mici/onroad/alert_renderer.py @@ -10,7 +10,7 @@ from opendbc.car.structs import car from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.common.filter_simple import BounceFilter, FirstOrderFilter from openpilot.common.hardware import COMMA_HARDWARE -from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.label import UnifiedLabel @@ -319,7 +319,7 @@ class AlertRenderer(Widget): self._alert_text1_label.set_text(alert_text1) self._alert_text1_label.set_text_color(color) self._alert_text1_label.set_font_size(font_size) - self._alert_text1_label.set_alignment(rl.GuiTextAlignment.TEXT_ALIGN_LEFT if icon_side != 'left' else rl.GuiTextAlignment.TEXT_ALIGN_RIGHT) + self._alert_text1_label.set_alignment(TextAlignment.LEFT if icon_side != 'left' else TextAlignment.RIGHT) self._alert_text1_label.render(text_rect1) alert_text2 = alert.text2.lower() @@ -351,5 +351,5 @@ class AlertRenderer(Widget): self._alert_text2_label.set_text(alert_text2) self._alert_text2_label.set_text_color(color) self._alert_text2_label.set_font_size(small_font_size) - self._alert_text2_label.set_alignment(rl.GuiTextAlignment.TEXT_ALIGN_LEFT if icon_side != 'left' else rl.GuiTextAlignment.TEXT_ALIGN_RIGHT) + self._alert_text2_label.set_alignment(TextAlignment.LEFT if icon_side != 'left' else TextAlignment.RIGHT) self._alert_text2_label.render(text_rect2) diff --git a/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py b/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py index 4f4e4b2f6c..ddc04d7560 100644 --- a/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py +++ b/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py @@ -11,7 +11,7 @@ from openpilot.selfdrive.ui.mici.onroad.hud_renderer import HudRenderer from openpilot.selfdrive.ui.mici.onroad.model_renderer import ModelRenderer from openpilot.selfdrive.ui.mici.onroad.confidence_ball import ConfidenceBall from openpilot.selfdrive.ui.mici.onroad.cameraview import CameraView -from openpilot.system.ui.lib.application import FontWeight, gui_app, MousePos, MouseEvent +from openpilot.system.ui.lib.application import FontWeight, gui_app, MousePos, MouseEvent, TextAlignment, TextAlignmentVertical from openpilot.system.ui.widgets.label import UnifiedLabel from openpilot.system.ui.widgets import Widget from openpilot.common.filter_simple import BounceFilter @@ -154,8 +154,8 @@ class AugmentedRoadView(CameraView): self._confidence_ball = ConfidenceBall() self._offroad_label = UnifiedLabel("start the car to\nuse openpilot", 54, FontWeight.DISPLAY, text_color=rl.Color(255, 255, 255, int(255 * 0.9)), - alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + alignment=TextAlignment.CENTER, + alignment_vertical=TextAlignmentVertical.MIDDLE) self._fade_texture = gui_app.texture("icons_mici/onroad/onroad_fade.png") diff --git a/openpilot/selfdrive/ui/mici/onroad/cabin_camera_dialog.py b/openpilot/selfdrive/ui/mici/onroad/cabin_camera_dialog.py index e86c0aa739..b0bdb9749c 100644 --- a/openpilot/selfdrive/ui/mici/onroad/cabin_camera_dialog.py +++ b/openpilot/selfdrive/ui/mici/onroad/cabin_camera_dialog.py @@ -4,7 +4,7 @@ from openpilot.cereal.visionipc import VisionStreamType from openpilot.selfdrive.ui.mici.onroad.cameraview import CameraView from openpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer from openpilot.selfdrive.ui.ui_state import ui_state, device -from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment, TextAlignmentVertical from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.nav_widget import NavWidget @@ -76,7 +76,7 @@ class BaseCabinCameraDialog(Widget): if not self._camera_view.frame: gui_label(rect, tr("camera starting"), font_size=54, font_weight=FontWeight.BOLD, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) + alignment=TextAlignment.CENTER) rl.end_scissor_mode() self._publish_alert_sound(None) return @@ -124,12 +124,12 @@ class BaseCabinCameraDialog(Widget): awareness_pct = dm_state.visionPolicyState.awarenessPercent if is_vision else dm_state.wheeltouchPolicyState.awarenessPercent gui_label(rl.Rectangle(rect.x + 2, rect.y + 2, rect.width, rect.height), f"Awareness: {awareness_pct:.0f}%", font_size=44, font_weight=FontWeight.MEDIUM, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, + alignment=TextAlignment.RIGHT, + alignment_vertical=TextAlignmentVertical.TOP, color=rl.Color(0, 0, 0, 180)) gui_label(rect, f"Awareness: {awareness_pct:.0f}%", font_size=44, font_weight=FontWeight.MEDIUM, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, + alignment=TextAlignment.RIGHT, + alignment_vertical=TextAlignmentVertical.TOP, color=rl.Color(255, 255, 255, int(255 * 0.9))) if dm_state.alertLevel == log.DriverMonitoringState.AlertLevel.none: @@ -137,16 +137,16 @@ class BaseCabinCameraDialog(Widget): # Show alert level alert_level_str = f"{'Pay Attention' if is_vision else 'Touch Wheel'} - level {dm_state.alertLevel}" - alignment = rl.GuiTextAlignment.TEXT_ALIGN_RIGHT if self.driver_state_renderer.is_rhd else rl.GuiTextAlignment.TEXT_ALIGN_LEFT + alignment = TextAlignment.RIGHT if self.driver_state_renderer.is_rhd else TextAlignment.LEFT shadow_rect = rl.Rectangle(rect.x + 2, rect.y + 2, rect.width, rect.height) gui_label(shadow_rect, alert_level_str, font_size=40, font_weight=FontWeight.BOLD, alignment=alignment, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, + alignment_vertical=TextAlignmentVertical.BOTTOM, color=rl.Color(0, 0, 0, 180)) gui_label(rect, alert_level_str, font_size=40, font_weight=FontWeight.BOLD, alignment=alignment, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, + alignment_vertical=TextAlignmentVertical.BOTTOM, color=rl.Color(255, 255, 255, int(255 * 0.9))) def _load_eye_textures(self): diff --git a/openpilot/selfdrive/ui/mici/widgets/button.py b/openpilot/selfdrive/ui/mici/widgets/button.py index e2912d00cf..59a3f95191 100644 --- a/openpilot/selfdrive/ui/mici/widgets/button.py +++ b/openpilot/selfdrive/ui/mici/widgets/button.py @@ -6,7 +6,7 @@ from collections.abc import Callable from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.label import UnifiedLabel from openpilot.system.ui.widgets.scroller import DO_ZOOM -from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos +from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, TextAlignmentVertical from openpilot.common.filter_simple import BounceFilter if TYPE_CHECKING: @@ -125,10 +125,10 @@ class BigButton(Widget): self._rotate_icon_t: float | None = None self._label = UnifiedLabel(text, font_size=self._get_label_font_size(), font_weight=FontWeight.BOLD, - text_color=LABEL_COLOR, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, scroll=scroll, + text_color=LABEL_COLOR, alignment_vertical=TextAlignmentVertical.BOTTOM, scroll=scroll, line_height=0.9) self._sub_label = UnifiedLabel(value, font_size=COMPLICATION_SIZE, font_weight=FontWeight.ROMAN, - text_color=COMPLICATION_GREY, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM) + text_color=COMPLICATION_GREY, alignment_vertical=TextAlignmentVertical.BOTTOM) self._update_label_layout() self._load_images() @@ -167,9 +167,9 @@ class BigButton(Widget): def _update_label_layout(self): self._label.set_font_size(self._get_label_font_size()) if self.value: - self._label.set_alignment_vertical(rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP) + self._label.set_alignment_vertical(TextAlignmentVertical.TOP) else: - self._label.set_alignment_vertical(rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM) + self._label.set_alignment_vertical(TextAlignmentVertical.BOTTOM) def set_text(self, text: str): self.text = text @@ -356,8 +356,8 @@ class GreyBigButton(BigButton): self._sub_label.set_font_size(36) self._sub_label.set_text_color(rl.Color(255, 255, 255, int(255 * 0.9))) self._sub_label.set_font_weight(FontWeight.DISPLAY_REGULAR) - self._sub_label.set_alignment_vertical(rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE if not self._label.text else - rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM) + self._sub_label.set_alignment_vertical(TextAlignmentVertical.MIDDLE if not self._label.text else + TextAlignmentVertical.BOTTOM) self._sub_label.set_line_height(0.95) @property diff --git a/openpilot/selfdrive/ui/onroad/alert_renderer.py b/openpilot/selfdrive/ui/onroad/alert_renderer.py index 62511b87db..7add53df5d 100644 --- a/openpilot/selfdrive/ui/onroad/alert_renderer.py +++ b/openpilot/selfdrive/ui/onroad/alert_renderer.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from openpilot.cereal import messaging, log from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.common.hardware import COMMA_HARDWARE -from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment, TextAlignmentVertical from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.widgets import Widget @@ -76,10 +76,10 @@ class AlertRenderer(Widget): self.font_bold: rl.Font = gui_app.font(FontWeight.BOLD) # font size is set dynamically - self._full_text1_label = Label("", font_size=0, font_weight=FontWeight.BOLD, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, - text_alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP) - self._full_text2_label = Label("", font_size=ALERT_FONT_BIG, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, - text_alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP) + self._full_text1_label = Label("", font_size=0, font_weight=FontWeight.BOLD, text_alignment=TextAlignment.CENTER, + text_alignment_vertical=TextAlignmentVertical.TOP) + self._full_text2_label = Label("", font_size=ALERT_FONT_BIG, text_alignment=TextAlignment.CENTER, + text_alignment_vertical=TextAlignmentVertical.TOP) def get_alert(self, sm: messaging.SubMaster) -> Alert | None: """Generate the current alert based on selfdrive state.""" diff --git a/openpilot/selfdrive/ui/onroad/cabin_camera_dialog.py b/openpilot/selfdrive/ui/onroad/cabin_camera_dialog.py index 7bb1917c35..58a645381b 100644 --- a/openpilot/selfdrive/ui/onroad/cabin_camera_dialog.py +++ b/openpilot/selfdrive/ui/onroad/cabin_camera_dialog.py @@ -4,7 +4,7 @@ from openpilot.cereal.visionipc import VisionStreamType from openpilot.selfdrive.ui.onroad.cameraview import CameraView from openpilot.selfdrive.ui.onroad.driver_state import DriverStateRenderer from openpilot.selfdrive.ui.ui_state import ui_state, device -from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.widgets.label import gui_label @@ -38,7 +38,7 @@ class CabinCameraDialog(CameraView): tr("camera starting"), font_size=100, font_weight=FontWeight.BOLD, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, + alignment=TextAlignment.CENTER, ) return -1 diff --git a/openpilot/system/ui/lib/application.py b/openpilot/system/ui/lib/application.py index 5e980937d7..230ddc635e 100644 --- a/openpilot/system/ui/lib/application.py +++ b/openpilot/system/ui/lib/application.py @@ -13,7 +13,7 @@ import subprocess from contextlib import contextmanager from collections.abc import Callable from collections import deque -from enum import StrEnum +from enum import IntEnum, StrEnum from pathlib import Path from typing import NamedTuple from importlib.resources import as_file, files @@ -115,6 +115,18 @@ class FontWeight(StrEnum): DISPLAY = "Inter-Bold.ttf" +class TextAlignment(IntEnum): + LEFT = 0 + CENTER = 1 + RIGHT = 2 + + +class TextAlignmentVertical(IntEnum): + TOP = 0 + MIDDLE = 1 + BOTTOM = 2 + + def font_fallback(font: rl.Font) -> rl.Font: """Use a Noto fallback for languages not covered by Inter.""" if multilang.requires_font_fallback(): @@ -330,7 +342,6 @@ class GuiApplication: rl.set_target_fps(0 if OFFSCREEN or vblank_control else fps) self._target_fps = fps - self._set_styles() self._load_fonts() self._patch_text_functions() self._patch_scissor_mode() @@ -731,14 +742,6 @@ class GuiApplication: self._fonts[font_weight_file] = font if multilang.requires_font_fallback(): self.fallback_font() - rl.gui_set_font(self._fonts[FontWeight.NORMAL]) - - def _set_styles(self): - rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiControlProperty.BORDER_WIDTH, 0) - rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_SIZE, DEFAULT_TEXT_SIZE) - rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.BACKGROUND_COLOR, rl.color_to_int(rl.BLACK)) - rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiControlProperty.TEXT_COLOR_NORMAL, rl.color_to_int(DEFAULT_TEXT_COLOR)) - rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiControlProperty.BASE_COLOR_NORMAL, rl.color_to_int(rl.Color(50, 50, 50, 255))) def _patch_text_functions(self): # Wrap pyray text APIs to apply a global text size scale so our px sizes match Qt diff --git a/openpilot/system/ui/lib/utils.py b/openpilot/system/ui/lib/utils.py deleted file mode 100644 index e97b3ba9d9..0000000000 --- a/openpilot/system/ui/lib/utils.py +++ /dev/null @@ -1,19 +0,0 @@ -import pyray as rl -from collections.abc import Sequence - - -class GuiStyleContext: - def __init__(self, styles: Sequence[tuple[int, int, int]]): - """styles is a list of tuples (control, prop, new_value)""" - self.styles = styles - self.prev_styles: list[tuple[int, int, int]] = [] - - def __enter__(self): - for control, prop, new_value in self.styles: - prev_value = rl.gui_get_style(control, prop) - self.prev_styles.append((control, prop, prev_value)) - rl.gui_set_style(control, prop, new_value) - - def __exit__(self, exc_type, exc_value, traceback): - for control, prop, prev_value in self.prev_styles: - rl.gui_set_style(control, prop, prev_value) diff --git a/openpilot/system/ui/mici_setup.py b/openpilot/system/ui/mici_setup.py index e4977ffdf4..45d63f5d2c 100755 --- a/openpilot/system/ui/mici_setup.py +++ b/openpilot/system/ui/mici_setup.py @@ -18,7 +18,7 @@ from openpilot.common.realtime import config_realtime_process, set_core_affinity from openpilot.common.swaglog import cloudlog from openpilot.common.time_helpers import system_time_valid from openpilot.common.utils import run_cmd -from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment, TextAlignmentVertical from openpilot.system.ui.lib.wifi_manager import WifiManager, ConnectStatus from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.nav_widget import NavWidget @@ -105,8 +105,8 @@ class StartPage(Widget): super().__init__() self._title = UnifiedLabel("start", 64, text_color=rl.Color(255, 255, 255, int(255 * 0.9)), - font_weight=FontWeight.DISPLAY, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + font_weight=FontWeight.DISPLAY, alignment=TextAlignment.CENTER, + alignment_vertical=TextAlignmentVertical.MIDDLE) self._start_bg_txt = gui_app.texture("icons_mici/setup/start_button.png", 500, 224, keep_aspect_ratio=False) self._start_bg_pressed_txt = gui_app.texture("icons_mici/setup/start_button_pressed.png", 500, 224, keep_aspect_ratio=False) @@ -197,7 +197,7 @@ class DownloadingPage(NavWidget): self._title_label = UnifiedLabel("downloading...", 64, text_color=rl.Color(255, 255, 255, int(255 * 0.9)), font_weight=FontWeight.DISPLAY) self._progress_label = UnifiedLabel("", 132, text_color=rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)), - font_weight=FontWeight.ROMAN, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM) + font_weight=FontWeight.ROMAN, alignment_vertical=TextAlignmentVertical.BOTTOM) self._progress = 0 def _back_enabled(self) -> bool: @@ -261,8 +261,8 @@ class BigPillButton(BigButton): super().__init__(*args, **kwargs) self._label.set_font_size(48) - self._label.set_alignment(rl.GuiTextAlignment.TEXT_ALIGN_CENTER) - self._label.set_alignment_vertical(rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + self._label.set_alignment(TextAlignment.CENTER) + self._label.set_alignment_vertical(TextAlignmentVertical.MIDDLE) def _load_images(self): if self._green: diff --git a/openpilot/system/ui/mici_updater.py b/openpilot/system/ui/mici_updater.py index d9009b8259..cc16145793 100755 --- a/openpilot/system/ui/mici_updater.py +++ b/openpilot/system/ui/mici_updater.py @@ -7,7 +7,7 @@ import pyray as rl from openpilot.common.realtime import config_realtime_process, set_core_affinity from openpilot.common.hardware import HARDWARE, COMMA_HARDWARE from openpilot.common.swaglog import cloudlog -from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignmentVertical from openpilot.system.ui.widgets.nav_widget import NavWidget from openpilot.system.ui.widgets.scroller import Scroller from openpilot.system.ui.widgets.label import UnifiedLabel @@ -30,7 +30,7 @@ class ProgressPage(NavWidget): font_weight=FontWeight.DISPLAY, line_height=0.8) self._progress_percent_label = UnifiedLabel("", 132, text_color=rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)), font_weight=FontWeight.ROMAN, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM) + alignment_vertical=TextAlignmentVertical.BOTTOM) def _back_enabled(self) -> bool: return False diff --git a/openpilot/system/ui/tici_reset.py b/openpilot/system/ui/tici_reset.py index 5c175dbfa6..ffe90bbb87 100755 --- a/openpilot/system/ui/tici_reset.py +++ b/openpilot/system/ui/tici_reset.py @@ -11,7 +11,7 @@ from openpilot.common.hardware import PC from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.button import Button, ButtonStyle -from openpilot.system.ui.widgets.label import gui_label, gui_text_box +from openpilot.system.ui.widgets.label import UnifiedLabel, gui_label USERDATA = "/dev/disk/by-partlabel/userdata" TIMEOUT = 3*60 @@ -38,6 +38,7 @@ class Reset(Widget): self._cancel_button = Button("Cancel", gui_app.request_close) self._confirm_button = Button("Confirm", self._confirm, button_style=ButtonStyle.PRIMARY) self._reboot_button = Button("Reboot", self._reboot) + self._body_label = UnifiedLabel(self._get_body_text, 90, line_height=1 / 0.9) @staticmethod def _reboot() -> None: @@ -75,7 +76,7 @@ class Reset(Widget): gui_label(label_rect, "System Reset", 100, font_weight=FontWeight.BOLD) text_rect = rl.Rectangle(content_rect.x + 140, content_rect.y + 140, content_rect.width - 280, content_rect.height - 90 - 100 * FONT_SCALE) - gui_text_box(text_rect, self._get_body_text(), 90) + self._body_label.render(text_rect) button_height = 160 button_spacing = 50 diff --git a/openpilot/system/ui/tici_setup.py b/openpilot/system/ui/tici_setup.py index 36f47ccf1b..17c3cf5e68 100755 --- a/openpilot/system/ui/tici_setup.py +++ b/openpilot/system/ui/tici_setup.py @@ -13,7 +13,7 @@ import pyray as rl from openpilot.cereal import log from openpilot.common.hardware import HARDWARE from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel -from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE +from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE, TextAlignment, TextAlignmentVertical from openpilot.system.ui.widgets import DialogResult, Widget from openpilot.system.ui.widgets.button import Button, ButtonStyle, ButtonRadio from openpilot.system.ui.widgets.keyboard import Keyboard @@ -67,17 +67,17 @@ class Setup(Widget): self.warning = gui_app.texture("icons/warning.png", 150, 150) self.checkmark = gui_app.texture("icons/circled_check.png", 100, 100) - self._low_voltage_title_label = Label("WARNING: Low Voltage", TITLE_FONT_SIZE, FontWeight.MEDIUM, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, + self._low_voltage_title_label = Label("WARNING: Low Voltage", TITLE_FONT_SIZE, FontWeight.MEDIUM, TextAlignment.LEFT, text_color=rl.Color(255, 89, 79, 255), text_padding=20) self._low_voltage_body_label = Label("Power your device in a car with a harness or proceed at your own risk.", BODY_FONT_SIZE, - text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20) + text_alignment=TextAlignment.LEFT, text_padding=20) self._low_voltage_continue_button = Button("Continue", self._low_voltage_continue_button_callback) self._low_voltage_poweroff_button = Button("Power Off", HARDWARE.shutdown) self._getting_started_button = Button("", self._getting_started_button_callback, button_style=ButtonStyle.PRIMARY, border_radius=0) - self._getting_started_title_label = Label("Getting Started", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20) + self._getting_started_title_label = Label("Getting Started", TITLE_FONT_SIZE, FontWeight.BOLD, TextAlignment.LEFT, text_padding=20) self._getting_started_body_label = Label("Before we get on the road, let's finish installation and cover some details.", - BODY_FONT_SIZE, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20) + BODY_FONT_SIZE, text_alignment=TextAlignment.LEFT, text_padding=20) self._software_selection_openpilot_button = ButtonRadio("openpilot", self.checkmark, font_size=BODY_FONT_SIZE, text_padding=80) self._software_selection_custom_software_button = ButtonRadio("Custom Software", self.checkmark, font_size=BODY_FONT_SIZE, text_padding=80) @@ -85,32 +85,32 @@ class Setup(Widget): button_style=ButtonStyle.PRIMARY) self._software_selection_continue_button.set_enabled(False) self._software_selection_back_button = Button("Back", self._software_selection_back_button_callback) - self._software_selection_title_label = Label("Choose Software to Use", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, + self._software_selection_title_label = Label("Choose Software to Use", TITLE_FONT_SIZE, FontWeight.BOLD, TextAlignment.LEFT, text_padding=20) self._download_failed_reboot_button = Button("Reboot device", HARDWARE.reboot) self._download_failed_startover_button = Button("Start over", self._download_failed_startover_button_callback, button_style=ButtonStyle.PRIMARY) - self._download_failed_title_label = Label("Download Failed", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20) - self._download_failed_url_label = Label("", 52, FontWeight.NORMAL, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20) - self._download_failed_body_label = Label("", BODY_FONT_SIZE, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20) + self._download_failed_title_label = Label("Download Failed", TITLE_FONT_SIZE, FontWeight.BOLD, TextAlignment.LEFT, text_padding=20) + self._download_failed_url_label = Label("", 52, FontWeight.NORMAL, TextAlignment.LEFT, text_padding=20) + self._download_failed_body_label = Label("", BODY_FONT_SIZE, text_alignment=TextAlignment.LEFT, text_padding=20) self._network_setup_back_button = Button("Back", self._network_setup_back_button_callback) self._network_setup_continue_button = Button("Waiting for internet", self._network_setup_continue_button_callback, button_style=ButtonStyle.PRIMARY) self._network_setup_continue_button.set_enabled(False) - self._network_setup_title_label = Label("Connect to Wi-Fi", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20) + self._network_setup_title_label = Label("Connect to Wi-Fi", TITLE_FONT_SIZE, FontWeight.BOLD, TextAlignment.LEFT, text_padding=20) self._custom_software_warning_continue_button = Button("Scroll to continue", self._custom_software_warning_continue_button_callback, button_style=ButtonStyle.PRIMARY) self._custom_software_warning_continue_button.set_enabled(False) self._custom_software_warning_back_button = Button("Back", self._custom_software_warning_back_button_callback) - self._custom_software_warning_title_label = Label("WARNING: Custom Software", 81, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, + self._custom_software_warning_title_label = Label("WARNING: Custom Software", 81, FontWeight.BOLD, TextAlignment.LEFT, text_color=rl.Color(255, 89, 79, 255), text_padding=60) self._yellow_warning_icon = gui_app.texture("icons/yellow_warning.png", int(68 * FONT_SCALE), int(68 * FONT_SCALE)) self._custom_software_warning_body_labels = [ - Label(text, 68, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, - text_alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, + Label(text, 68, text_alignment=TextAlignment.LEFT, + text_alignment_vertical=TextAlignmentVertical.TOP, text_padding=60, icon=self._yellow_warning_icon if has_icon else None) for text, has_icon in [ ("Use caution when installing third-party software.", False), diff --git a/openpilot/system/ui/tici_updater.py b/openpilot/system/ui/tici_updater.py index 27cf6579a8..2cb86cca88 100755 --- a/openpilot/system/ui/tici_updater.py +++ b/openpilot/system/ui/tici_updater.py @@ -10,7 +10,7 @@ from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE from openpilot.system.ui.lib.wifi_manager import WifiManager from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.button import Button, ButtonStyle -from openpilot.system.ui.widgets.label import gui_text_box, gui_label +from openpilot.system.ui.widgets.label import UnifiedLabel, gui_label from openpilot.system.ui.widgets.network import WifiManagerUI # Constants @@ -50,6 +50,8 @@ class Updater(Widget): self._install_button = Button("Install", click_callback=self.install_update, button_style=ButtonStyle.PRIMARY) self._back_button = Button("Back", click_callback=lambda: self.set_current_screen(Screen.PROMPT)) self._reboot_button = Button("Reboot", click_callback=lambda: HARDWARE.reboot()) + self._desc_label = UnifiedLabel("An operating system update is required. Connect your device to Wi-Fi for the fastest update experience. " + + "The download size is approximately 1GB.", BODY_FONT_SIZE, line_height=1 / 0.9) def set_current_screen(self, screen: Screen): self.current_screen = screen @@ -99,11 +101,8 @@ class Updater(Widget): gui_label(title_rect, "Update Required", TITLE_FONT_SIZE, font_weight=FontWeight.BOLD) # Description - desc_text = ("An operating system update is required. Connect your device to Wi-Fi for the fastest update experience. " + - "The download size is approximately 1GB.") - desc_rect = rl.Rectangle(MARGIN + 50, 250 + TITLE_FONT_SIZE * FONT_SCALE + 75, rect.width - MARGIN * 2 - 100, BODY_FONT_SIZE * FONT_SCALE * 4) - gui_text_box(desc_rect, desc_text, BODY_FONT_SIZE) + self._desc_label.render(desc_rect) # Buttons at the bottom button_y = rect.height - MARGIN - BUTTON_HEIGHT diff --git a/openpilot/system/ui/widgets/button.py b/openpilot/system/ui/widgets/button.py index 36ef3bedab..c031b2f141 100644 --- a/openpilot/system/ui/widgets/button.py +++ b/openpilot/system/ui/widgets/button.py @@ -3,7 +3,7 @@ from enum import IntEnum import pyray as rl -from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos +from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, TextAlignment from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.label import Label from openpilot.common.filter_simple import FirstOrderFilter @@ -86,7 +86,7 @@ class Button(Widget): font_weight: FontWeight = FontWeight.MEDIUM, button_style: ButtonStyle = ButtonStyle.NORMAL, border_radius: int = 10, - text_alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_CENTER, + text_alignment: int = TextAlignment.CENTER, text_padding: int = 20, icon=None, elide_right: bool = False, @@ -139,7 +139,7 @@ class ButtonRadio(Button): icon, click_callback: Callable[[], None] | None = None, font_size: int = DEFAULT_BUTTON_FONT_SIZE, - text_alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT, + text_alignment: int = TextAlignment.LEFT, border_radius: int = 10, text_padding: int = 20, ): diff --git a/openpilot/system/ui/widgets/keyboard.py b/openpilot/system/ui/widgets/keyboard.py index 49c59a431f..c9d2187cbb 100644 --- a/openpilot/system/ui/widgets/keyboard.py +++ b/openpilot/system/ui/widgets/keyboard.py @@ -5,7 +5,7 @@ from collections.abc import Callable import pyray as rl -from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.widgets import DialogResult, Widget from openpilot.system.ui.widgets.button import ButtonStyle, Button @@ -65,8 +65,8 @@ class Keyboard(Widget): self._layout_name: Literal["lowercase", "uppercase", "numbers", "specials"] = "lowercase" self._caps_lock = False self._last_shift_press_time = 0 - self._title = Label("", 90, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20) - self._sub_title = Label("", 55, FontWeight.NORMAL, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20) + self._title = Label("", 90, FontWeight.BOLD, TextAlignment.LEFT, text_padding=20) + self._sub_title = Label("", 55, FontWeight.NORMAL, TextAlignment.LEFT, text_padding=20) self._max_text_size = max_text_size self._min_text_size = min_text_size diff --git a/openpilot/system/ui/widgets/label.py b/openpilot/system/ui/widgets/label.py index a3e827321c..60fa3e25e0 100644 --- a/openpilot/system/ui/widgets/label.py +++ b/openpilot/system/ui/widgets/label.py @@ -4,10 +4,9 @@ from collections.abc import Callable from typing import Union import pyray as rl -from openpilot.system.ui.lib.application import gui_app, FontWeight, DEFAULT_TEXT_SIZE, DEFAULT_TEXT_COLOR, FONT_SCALE +from openpilot.system.ui.lib.application import gui_app, FontWeight, DEFAULT_TEXT_SIZE, DEFAULT_TEXT_COLOR, FONT_SCALE, TextAlignment, TextAlignmentVertical from openpilot.system.ui.widgets import Widget from openpilot.system.ui.lib.text_measure import measure_text_cached -from openpilot.system.ui.lib.utils import GuiStyleContext from openpilot.system.ui.lib.wrap_text import wrap_text ICON_PADDING = 15 @@ -32,8 +31,8 @@ def gui_label( font_size: int = DEFAULT_TEXT_SIZE, color: rl.Color = DEFAULT_TEXT_COLOR, font_weight: FontWeight = FontWeight.NORMAL, - alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT, - alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, + alignment: int = TextAlignment.LEFT, + alignment_vertical: int = TextAlignmentVertical.MIDDLE, elide_right: bool = True ): font = gui_app.font(font_weight) @@ -57,16 +56,16 @@ def gui_label( # Calculate horizontal position based on alignment text_x = rect.x + { - rl.GuiTextAlignment.TEXT_ALIGN_LEFT: 0, - rl.GuiTextAlignment.TEXT_ALIGN_CENTER: (rect.width - text_size.x) / 2, - rl.GuiTextAlignment.TEXT_ALIGN_RIGHT: rect.width - text_size.x, + TextAlignment.LEFT: 0, + TextAlignment.CENTER: (rect.width - text_size.x) / 2, + TextAlignment.RIGHT: rect.width - text_size.x, }.get(alignment, 0) # Calculate vertical position based on alignment text_y = rect.y + { - rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP: 0, - rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE: (rect.height - text_size.y) / 2, - rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM: rect.height - text_size.y, + TextAlignmentVertical.TOP: 0, + TextAlignmentVertical.MIDDLE: (rect.height - text_size.y) / 2, + TextAlignmentVertical.BOTTOM: rect.height - text_size.y, }.get(alignment_vertical, 0) # Draw the text in the specified rectangle @@ -74,42 +73,14 @@ def gui_label( rl.draw_text_ex(font, display_text, rl.Vector2(text_x, text_y), font_size, 0, color) -def gui_text_box( - rect: rl.Rectangle, - text: str, - font_size: int = DEFAULT_TEXT_SIZE, - color: rl.Color = DEFAULT_TEXT_COLOR, - alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT, - alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, - font_weight: FontWeight = FontWeight.NORMAL, - line_scale: float = 1.0, -): - styles = [ - (rl.GuiControl.DEFAULT, rl.GuiControlProperty.TEXT_COLOR_NORMAL, rl.color_to_int(color)), - (rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_SIZE, round(font_size * FONT_SCALE)), - (rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_LINE_SPACING, round(font_size * FONT_SCALE * line_scale)), - (rl.GuiControl.DEFAULT, rl.GuiControlProperty.TEXT_ALIGNMENT, alignment), - (rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_ALIGNMENT_VERTICAL, alignment_vertical), - (rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_WRAP_MODE, rl.GuiTextWrapMode.TEXT_WRAP_WORD) - ] - if font_weight != FontWeight.NORMAL: - rl.gui_set_font(gui_app.font(font_weight)) - - with GuiStyleContext(styles): - rl.gui_label(rect, text) - - if font_weight != FontWeight.NORMAL: - rl.gui_set_font(gui_app.font(FontWeight.NORMAL)) - - # Non-interactive text area. Can render an optional specified icon. class Label(Widget): def __init__(self, text: str | Callable[[], str], font_size: int = DEFAULT_TEXT_SIZE, font_weight: FontWeight = FontWeight.NORMAL, - text_alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_CENTER, - text_alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, + text_alignment: int = TextAlignment.CENTER, + text_alignment_vertical: int = TextAlignmentVertical.MIDDLE, text_padding: int = 0, text_color: rl.Color = DEFAULT_TEXT_COLOR, icon: Union[rl.Texture, None] = None, @@ -181,10 +152,10 @@ class Label(Widget): self._update_text(self._text) text_size = self._text_size[0] if self._text_size else rl.Vector2(0.0, 0.0) - if self._text_alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE: + if self._text_alignment_vertical == TextAlignmentVertical.MIDDLE: total_text_height = sum(ts.y for ts in self._text_size) or self._font_size * FONT_SCALE text_pos = rl.Vector2(self._rect.x, (self._rect.y + (self._rect.height - total_text_height) // 2)) - elif self._text_alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM: + elif self._text_alignment_vertical == TextAlignmentVertical.BOTTOM: total_text_height = sum(ts.y for ts in self._text_size) or self._font_size * FONT_SCALE text_pos = rl.Vector2(self._rect.x, self._rect.y + self._rect.height - total_text_height) else: @@ -193,10 +164,10 @@ class Label(Widget): if self._icon: icon_y = self._rect.y + (self._rect.height - self._icon.height) / 2 if len(self._text_wrapped) > 0: - if self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_LEFT: + if self._text_alignment == TextAlignment.LEFT: icon_x = self._rect.x + self._text_padding text_pos.x = self._rect.x + self._icon.width + ICON_PADDING - elif self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_CENTER: + elif self._text_alignment == TextAlignment.CENTER: total_width = self._icon.width + ICON_PADDING + text_size.x icon_x = self._rect.x + (self._rect.width - total_width) / 2 text_pos.x = self._rect.x + self._icon.width + ICON_PADDING @@ -208,11 +179,11 @@ class Label(Widget): for text, text_size in zip(self._text_wrapped, self._text_size, strict=True): line_pos = rl.Vector2(text_pos.x, text_pos.y) - if self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_LEFT: + if self._text_alignment == TextAlignment.LEFT: line_pos.x += self._text_padding - elif self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_CENTER: + elif self._text_alignment == TextAlignment.CENTER: line_pos.x += (self._rect.width - text_size.x) // 2 - elif self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_RIGHT: + elif self._text_alignment == TextAlignment.RIGHT: line_pos.x += self._rect.width - text_size.x - self._text_padding rl.draw_text_ex(self._font, text, line_pos, self._font_size, 0, self._text_color) @@ -221,7 +192,7 @@ class Label(Widget): class UnifiedLabel(Widget): """ - Unified label widget that combines functionality from gui_label, gui_text_box, and Label. + Unified label widget that combines functionality from gui_label and Label. Supports: - Text wrapping @@ -241,8 +212,8 @@ class UnifiedLabel(Widget): font_size: int = DEFAULT_TEXT_SIZE, font_weight: FontWeight = FontWeight.NORMAL, text_color: rl.Color = DEFAULT_TEXT_COLOR, - alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT, - alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, + alignment: int = TextAlignment.LEFT, + alignment_vertical: int = TextAlignmentVertical.TOP, text_padding: int = 0, max_width: int | None = None, elide: bool = True, @@ -561,9 +532,9 @@ class UnifiedLabel(Widget): total_visible_height += size.y * self._line_height # Calculate vertical alignment offset - if self._alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP: + if self._alignment_vertical == TextAlignmentVertical.TOP: start_y = self._rect.y - elif self._alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM: + elif self._alignment_vertical == TextAlignmentVertical.BOTTOM: start_y = self._rect.y + self._rect.height - total_visible_height else: # TEXT_ALIGN_MIDDLE start_y = self._rect.y + (self._rect.height - total_visible_height) / 2 @@ -640,11 +611,11 @@ class UnifiedLabel(Widget): def _render_line(self, line, size, current_y, x_offset=0.0): # Calculate horizontal position - if self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_LEFT: + if self._alignment == TextAlignment.LEFT: line_x = self._rect.x + self._text_padding - elif self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_CENTER: + elif self._alignment == TextAlignment.CENTER: line_x = self._rect.x + (self._rect.width - size.x) / 2 - elif self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_RIGHT: + elif self._alignment == TextAlignment.RIGHT: line_x = self._rect.x + self._rect.width - size.x - self._text_padding else: line_x = self._rect.x + self._text_padding @@ -662,9 +633,9 @@ class UnifiedLabel(Widget): def _render_line_shimmer(self, line, line_x, current_y): # Shimmer range based on widest line so sweep is even across all lines max_width = self.text_width - if self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_RIGHT: + if self._alignment == TextAlignment.RIGHT: shimmer_left = self._rect.x + self._rect.width - self._text_padding - max_width - elif self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_CENTER: + elif self._alignment == TextAlignment.CENTER: shimmer_left = self._rect.x + (self._rect.width - max_width) / 2 else: shimmer_left = self._rect.x + self._text_padding diff --git a/openpilot/system/ui/widgets/list_view.py b/openpilot/system/ui/widgets/list_view.py index 705cb476de..d88473a491 100644 --- a/openpilot/system/ui/widgets/list_view.py +++ b/openpilot/system/ui/widgets/list_view.py @@ -3,7 +3,7 @@ import os import pyray as rl from collections.abc import Callable from abc import ABC -from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos +from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, TextAlignment, TextAlignmentVertical from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.widgets import Widget @@ -139,8 +139,8 @@ class ButtonAction(ItemAction): if value_text: value_rect = rl.Rectangle(rect.x, rect.y, rect.width - BUTTON_WIDTH - TEXT_PADDING, rect.height) gui_label(value_rect, value_text, font_size=ITEM_TEXT_FONT_SIZE, color=ITEM_TEXT_VALUE_COLOR, - font_weight=FontWeight.NORMAL, alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + font_weight=FontWeight.NORMAL, alignment=TextAlignment.LEFT, + alignment_vertical=TextAlignmentVertical.MIDDLE) # TODO: just use the generic Widget click callbacks everywhere, no returning from render pressed = self._pressed @@ -168,8 +168,8 @@ class TextAction(ItemAction): def _render(self, rect: rl.Rectangle) -> bool: gui_label(self._rect, self.text, font_size=ITEM_TEXT_FONT_SIZE, color=self.color, - font_weight=FontWeight.NORMAL, alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + font_weight=FontWeight.NORMAL, alignment=TextAlignment.RIGHT, + alignment_vertical=TextAlignmentVertical.MIDDLE) return False def set_text(self, text: str | Callable[[], str]): diff --git a/openpilot/system/ui/widgets/network.py b/openpilot/system/ui/widgets/network.py index 4068d552b9..3aa6701943 100644 --- a/openpilot/system/ui/widgets/network.py +++ b/openpilot/system/ui/widgets/network.py @@ -3,7 +3,7 @@ from functools import partial from typing import cast import pyray as rl -from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.application import gui_app, TextAlignment from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel from openpilot.system.ui.lib.wifi_manager import WifiManager, SecurityType, Network, MeteredType, normalize_ssid @@ -51,7 +51,7 @@ class NavButton(Widget): def _render(self, _): color = rl.Color(74, 74, 74, 255) if self.is_pressed else rl.Color(57, 57, 57, 255) rl.draw_rectangle_rounded(self._rect, 0.6, 10, color) - gui_label(self.rect, self.text, font_size=60, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) + gui_label(self.rect, self.text, font_size=60, alignment=TextAlignment.CENTER) class NetworkUI(Widget): @@ -299,7 +299,7 @@ class WifiManagerUI(Widget): def _render(self, rect: rl.Rectangle): if not self._networks: - gui_label(rect, tr("Scanning Wi-Fi networks..."), 72, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) + gui_label(rect, tr("Scanning Wi-Fi networks..."), 72, alignment=TextAlignment.CENTER) return if self.state == UIState.NEEDS_AUTH and self._state_network: @@ -373,7 +373,7 @@ class WifiManagerUI(Widget): if status_text: status_text_rect = rl.Rectangle(security_icon_rect.x - 410, rect.y, 410, ITEM_HEIGHT) - gui_label(status_text_rect, status_text, font_size=48, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) + gui_label(status_text_rect, status_text, font_size=48, alignment=TextAlignment.CENTER) else: # If the network is saved, show the "Forget" button if self._wifi_manager.is_connection_saved(network.ssid): @@ -439,7 +439,7 @@ class WifiManagerUI(Widget): self._networks = networks for n in self._networks: self._networks_buttons[n.ssid] = Button(normalize_ssid(n.ssid), partial(self._networks_buttons_callback, n), font_size=55, - text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, button_style=ButtonStyle.TRANSPARENT_WHITE_TEXT) + text_alignment=TextAlignment.LEFT, button_style=ButtonStyle.TRANSPARENT_WHITE_TEXT) self._networks_buttons[n.ssid].set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid()) self._forget_networks_buttons[n.ssid] = Button(tr("Forget"), partial(self._forget_networks_buttons_callback, n), button_style=ButtonStyle.FORGET_WIFI, font_size=45) diff --git a/openpilot/system/ui/widgets/option_dialog.py b/openpilot/system/ui/widgets/option_dialog.py index 206400a74f..816503c4a0 100644 --- a/openpilot/system/ui/widgets/option_dialog.py +++ b/openpilot/system/ui/widgets/option_dialog.py @@ -1,6 +1,6 @@ import pyray as rl from collections.abc import Callable -from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.widgets import Widget, DialogResult from openpilot.system.ui.widgets.button import Button, ButtonStyle @@ -29,7 +29,7 @@ class MultiOptionDialog(Widget): # Create scroller with option buttons self.option_buttons = [Button(option, click_callback=lambda opt=option: self._on_option_clicked(opt), font_weight=option_font_weight, - text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, button_style=ButtonStyle.NORMAL, + text_alignment=TextAlignment.LEFT, button_style=ButtonStyle.NORMAL, text_padding=50, elide_right=True) for option in options] self.scroller = Scroller(self.option_buttons, spacing=LIST_ITEM_SPACING) diff --git a/openpilot/system/ui/widgets/slider.py b/openpilot/system/ui/widgets/slider.py index bf965954f2..7bdf056219 100644 --- a/openpilot/system/ui/widgets/slider.py +++ b/openpilot/system/ui/widgets/slider.py @@ -3,7 +3,7 @@ from collections.abc import Callable import pyray as rl -from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment, TextAlignmentVertical from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.label import UnifiedLabel from openpilot.common.filter_simple import FirstOrderFilter, BounceFilter @@ -41,8 +41,8 @@ class SliderBase(Widget, abc.ABC): self._is_dragging_circle = False self._label = self._child(UnifiedLabel(title, font_size=36, font_weight=FontWeight.SEMI_BOLD, text_color=rl.WHITE, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, line_height=0.9, shimmer=True)) + alignment=TextAlignment.RIGHT, + alignment_vertical=TextAlignmentVertical.MIDDLE, line_height=0.9, shimmer=True)) @abc.abstractmethod def _load_assets(self): From 4cdc16031ffd8bd44cb1d3f2fd2bfd49effd66ce Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Thu, 27 Aug 2026 11:21:56 -0700 Subject: [PATCH 28/67] log chestnut supply fault (#38711) * log chestnut INA supply fault * ci --- openpilot/cereal/log.capnp | 1 + openpilot/selfdrive/modeld/modeld.py | 45 ++++++++++++++++++++++++---- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/openpilot/cereal/log.capnp b/openpilot/cereal/log.capnp index fdf022fa63..fb4867b2a6 100644 --- a/openpilot/cereal/log.capnp +++ b/openpilot/cereal/log.capnp @@ -725,6 +725,7 @@ struct ChestnutState { pcieLtssm @7 :UInt8; supplyVoltage @8 :UInt16; # mV supplyCurrent @9 :Int16; # mA + supplyFault @10 :Bool; } struct RadarState @0x9a185389d6fdd05f { diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index 0db66ed880..a795f366ce 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -6,6 +6,7 @@ import os os.environ['GMMU'] = '0' # for chestnut fast loading, noop for qcom from tinygrad.tensor import Tensor from tinygrad.device import Device +import usb1 import struct import threading import time @@ -31,6 +32,7 @@ from openpilot.selfdrive.modeld.parse_model_outputs import Parser from openpilot.selfdrive.modeld.compile_modeld import make_input_queues, WARP_INPUTS, POLICY_INPUTS from openpilot.selfdrive.modeld.fill_model_msg import fill_model_msg, fill_driving_model_data, fill_pose_msg, PublishState from openpilot.common.file_chunker import open_file_chunked +from openpilot.common.hardware.usb import CHESTNUT_USB_IDS from openpilot.selfdrive.modeld.constants import ModelConstants, Plan from openpilot.selfdrive.modeld.helpers import chestnut_present, chestnut_compiled, modeld_pkl_path, get_tg_input_devices, load_oob @@ -79,6 +81,37 @@ class ChestnutState: self.valid = True self.sends = 0 self.metrics = {} + self._asm_usb = None + + def _close_asm_usb(self) -> None: + if self._asm_usb is not None: + self._asm_usb.close() + self._asm_usb = None + + def _open_asm_usb(self): + context = usb1.USBContext() + for vendor_id, product_id in CHESTNUT_USB_IDS: + if (handle := context.openByVendorIDAndProductID(vendor_id, product_id, skip_on_error=True)) is not None: + return handle + context.close() + + def _read_ina(self) -> tuple[int, int, bool]: + if "AMD" in Device._opened_devices and self._asm_usb is None: + try: + raw = Device["AMD"].iface.pci_dev.usb.usb.control_read(0xC0, 5) + return struct.unpack(' int: @@ -114,13 +147,15 @@ class ChestnutState: setattr(state, k, v) asm_valid = False + try: + # ASM runs on USB-C power, these still read without a gpu + state.supplyVoltage, state.supplyCurrent, state.supplyFault = self._read_ina() + asm_valid = True + except Exception: + pass if "AMD" in Device._opened_devices: try: - # ASM runs on USB-C power, these still read without a gpu - asm = Device["AMD"].iface.pci_dev.usb - state.pcieLtssm = asm.read(0xB450, 1)[0] - state.supplyVoltage, state.supplyCurrent = struct.unpack(' Date: Thu, 27 Aug 2026 11:38:53 -0700 Subject: [PATCH 29/67] bump raylib (#38712) --- uv.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/uv.lock b/uv.lock index 20fe78c1fb..89b82b5452 100644 --- a/uv.lock +++ b/uv.lock @@ -209,15 +209,15 @@ wheels = [ [[package]] name = "comma-deps-raylib" -version = "6.0.0.1.post98" +version = "6.0.0.1.post101" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/21/59509b2758e3612c63336df5534ea3b36e472e06c18a50f0b96c32e8e3e0/comma_deps_raylib-6.0.0.1.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:838a6115f508ee4ca0e4c84975c0892d67c10f94b11dd2bc0e023ad65138d010", size = 2004199, upload-time = "2026-07-23T17:03:17.648Z" }, - { url = "https://files.pythonhosted.org/packages/c2/f5/84e135ac611a6dfe8b95ff989097c200770907907afba78455d4c863981e/comma_deps_raylib-6.0.0.1.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:9347fe18799c209dd1af746a44a94433bae69d4644de10e11dcc307aecd87fb1", size = 7203023, upload-time = "2026-07-23T17:03:21.525Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1b/ae0959dac622f7169de230ec98c6555870ae0ad4f61e7b8d32922fa960ed/comma_deps_raylib-6.0.0.1.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:f9fd393fbaf1f16d7785be0b2252ef3b14a953c93d89296ed9a67bc7063de882", size = 5055448, upload-time = "2026-07-23T17:03:25.737Z" }, + { url = "https://files.pythonhosted.org/packages/ff/90/e289acd1725d71c792c33399422f1052c4b0c38aeb4222a866d30c4a2cad/comma_deps_raylib-6.0.0.1.post101-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fa69d5093a92d7d2bfd2714a1afccca63b94d4c55fae88e61b80e8841de6a6cd", size = 1885392, upload-time = "2026-08-27T18:24:37.25Z" }, + { url = "https://files.pythonhosted.org/packages/a1/17/12977631f6d86d1daa4f67a310cfdf2783ba288f42d444d6a89b2297a0a4/comma_deps_raylib-6.0.0.1.post101-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:c93ff9b45df414620b3011280da77151764c027e2f95d384fe255615d78872cc", size = 21707644, upload-time = "2026-08-27T18:24:41.282Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ca/ef33aff790b37dfc925f94fbb3a86da19f67929c15b3725ddb18afb91e96/comma_deps_raylib-6.0.0.1.post101-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:8f2a1ffe5f60cac06170b144d6ce84925c91657e7ada3ae35bc5df6ccbe0b461", size = 20722616, upload-time = "2026-08-27T18:24:45.803Z" }, ] [[package]] From cbf750de20d29cd96c545da028a45c1135475c00 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:54:06 -0700 Subject: [PATCH 30/67] cabana: replace custom non-view Qt signals w/ plain observer (#38713) --- openpilot/tools/cabana/SConscript | 2 +- openpilot/tools/cabana/binaryview.cc | 5 +- openpilot/tools/cabana/binaryview.h | 1 + openpilot/tools/cabana/cabana.cc | 10 +-- openpilot/tools/cabana/cameraview.cc | 22 ++--- openpilot/tools/cabana/cameraview.h | 19 ++--- openpilot/tools/cabana/chart/chart.cc | 9 +- openpilot/tools/cabana/chart/chart.h | 1 + openpilot/tools/cabana/chart/chartswidget.cc | 17 ++-- openpilot/tools/cabana/chart/chartswidget.h | 1 + .../tools/cabana/chart/signalselector.cc | 1 - openpilot/tools/cabana/commands.cc | 22 ++--- openpilot/tools/cabana/commands.h | 28 +------ openpilot/tools/cabana/core/observable.h | 83 +++++++++++++++++++ openpilot/tools/cabana/dbc/dbcmanager.cc | 28 +++---- openpilot/tools/cabana/dbc/dbcmanager.h | 22 ++--- openpilot/tools/cabana/dbc/dbcqt.cc | 18 ---- openpilot/tools/cabana/dbc/dbcqt.h | 27 ------ openpilot/tools/cabana/detailwidget.cc | 11 ++- openpilot/tools/cabana/detailwidget.h | 1 + openpilot/tools/cabana/historylog.cc | 10 ++- openpilot/tools/cabana/historylog.h | 3 +- openpilot/tools/cabana/mainwin.cc | 69 ++++++++------- openpilot/tools/cabana/mainwin.h | 11 ++- openpilot/tools/cabana/messageswidget.cc | 12 +-- openpilot/tools/cabana/messageswidget.h | 3 +- openpilot/tools/cabana/settings.cc | 2 +- openpilot/tools/cabana/settings.h | 8 +- openpilot/tools/cabana/signalview.cc | 19 ++--- openpilot/tools/cabana/signalview.h | 2 + .../tools/cabana/streams/abstractstream.cc | 61 +++++++++----- .../tools/cabana/streams/abstractstream.h | 40 ++++----- .../tools/cabana/streams/devicestream.cc | 16 ++-- openpilot/tools/cabana/streams/devicestream.h | 3 +- openpilot/tools/cabana/streams/livestream.cc | 12 +-- openpilot/tools/cabana/streams/livestream.h | 4 +- openpilot/tools/cabana/streams/pandastream.cc | 4 +- openpilot/tools/cabana/streams/pandastream.h | 3 +- .../tools/cabana/streams/replaystream.cc | 40 +++++---- openpilot/tools/cabana/streams/replaystream.h | 12 ++- .../tools/cabana/streams/socketcanstream.cc | 4 +- .../tools/cabana/streams/socketcanstream.h | 3 +- openpilot/tools/cabana/tests/test_cabana.cc | 9 +- openpilot/tools/cabana/tools/routeinfo.cc | 2 +- openpilot/tools/cabana/utils/util.cc | 26 ++++++ openpilot/tools/cabana/utils/util.h | 13 ++- openpilot/tools/cabana/videowidget.cc | 22 ++--- openpilot/tools/cabana/videowidget.h | 1 + 48 files changed, 403 insertions(+), 339 deletions(-) create mode 100644 openpilot/tools/cabana/core/observable.h delete mode 100644 openpilot/tools/cabana/dbc/dbcqt.cc delete mode 100644 openpilot/tools/cabana/dbc/dbcqt.h diff --git a/openpilot/tools/cabana/SConscript b/openpilot/tools/cabana/SConscript index 387129709e..bc366ab6fb 100644 --- a/openpilot/tools/cabana/SConscript +++ b/openpilot/tools/cabana/SConscript @@ -99,7 +99,7 @@ cabana_env.Command(assets, "assets/assets.qrc", f"rcc $SOURCES -o $TARGET") cabana_env.Depends(assets, Glob('/assets/*', exclude=[assets, "assets/assets.o"])) cabana_srcs = ['mainwin.cc', 'streams/pandastream.cc', 'streams/devicestream.cc', 'streams/livestream.cc', 'streams/abstractstream.cc', 'streams/replaystream.cc', 'binaryview.cc', 'historylog.cc', 'videowidget.cc', 'signalview.cc', - 'streams/routes.cc', 'dbc/dbc.cc', 'dbc/dbcfile.cc', 'dbc/dbcmanager.cc', 'dbc/dbcqt.cc', + 'streams/routes.cc', 'dbc/dbc.cc', 'dbc/dbcfile.cc', 'dbc/dbcmanager.cc', 'utils/export.cc', 'utils/util.cc', 'utils/elidedlabel.cc', 'chart/chartswidget.cc', 'chart/chart.cc', 'chart/signalselector.cc', 'chart/tiplabel.cc', 'chart/sparkline.cc', 'commands.cc', 'messageswidget.cc', 'streamselector.cc', 'settings.cc', 'panda.cc', diff --git a/openpilot/tools/cabana/binaryview.cc b/openpilot/tools/cabana/binaryview.cc index 5e919dc6a3..2f0167107c 100644 --- a/openpilot/tools/cabana/binaryview.cc +++ b/openpilot/tools/cabana/binaryview.cc @@ -1,5 +1,4 @@ #include "tools/cabana/binaryview.h" -#include "tools/cabana/dbc/dbcqt.h" #include @@ -36,8 +35,8 @@ BinaryView::BinaryView(QWidget *parent) : QTableView(parent) { setMouseTracking(true); setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &BinaryView::refresh); - QObject::connect(undoNotifier(), &QtUndoNotifier::indexChanged, this, &BinaryView::refresh); + connections_.push_back(dbc()->fileChanged.connect([this]() { refresh(); })); + connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { refresh(); })); addShortcuts(); setWhatsThis(R"( diff --git a/openpilot/tools/cabana/binaryview.h b/openpilot/tools/cabana/binaryview.h index c49067a1a2..0b0159b422 100644 --- a/openpilot/tools/cabana/binaryview.h +++ b/openpilot/tools/cabana/binaryview.h @@ -100,5 +100,6 @@ private: bool is_message_active = false; const cabana::Signal *resize_sig = nullptr; const cabana::Signal *hovered_sig = nullptr; + Connections connections_; friend class BinaryItemDelegate; }; diff --git a/openpilot/tools/cabana/cabana.cc b/openpilot/tools/cabana/cabana.cc index 8b21143faf..e3a1850346 100644 --- a/openpilot/tools/cabana/cabana.cc +++ b/openpilot/tools/cabana/cabana.cc @@ -146,19 +146,19 @@ int main(int argc, char *argv[]) { AbstractStream *stream = nullptr; if (args.msgq) { - stream = new DeviceStream(&app); + stream = new DeviceStream(); } else if (!args.zmq.empty()) { - stream = new DeviceStream(&app, QString::fromStdString(args.zmq)); + stream = new DeviceStream(QString::fromStdString(args.zmq)); } else if (args.panda || !args.panda_serial.empty()) { try { - stream = new PandaStream(&app, {.serial = args.panda_serial}); + stream = new PandaStream({.serial = args.panda_serial}); } catch (std::exception &e) { fprintf(stderr, "%s\n", e.what()); return 0; } #ifdef __linux__ } else if (SocketCanStream::available() && !args.socketcan.empty()) { - stream = new SocketCanStream(&app, {.device = args.socketcan}); + stream = new SocketCanStream({.device = args.socketcan}); #endif } else { uint32_t replay_flags = REPLAY_FLAG_NONE; @@ -174,7 +174,7 @@ int main(int argc, char *argv[]) { route = DEMO_ROUTE; } if (!route.isEmpty()) { - auto replay_stream = std::make_unique(&app); + auto replay_stream = std::make_unique(); if (!replay_stream->loadRoute(route.toStdString(), args.data_dir, replay_flags, args.auto_source)) { return 0; } diff --git a/openpilot/tools/cabana/cameraview.cc b/openpilot/tools/cabana/cameraview.cc index 9bd6b9be19..b8c70ab1bf 100644 --- a/openpilot/tools/cabana/cameraview.cc +++ b/openpilot/tools/cabana/cameraview.cc @@ -9,13 +9,11 @@ #include #include "common/yuv.h" +#include "tools/cabana/utils/util.h" CameraWidget::CameraWidget(std::string stream_name, VisionStreamType type, QWidget* parent) : stream_name(stream_name), active_stream_type(type), requested_stream_type(type), QWidget(parent) { setAttribute(Qt::WA_OpaquePaintEvent); - qRegisterMetaType>("availableStreams"); - QObject::connect(this, &CameraWidget::vipcThreadFrameReceived, this, &CameraWidget::vipcFrameReceived, Qt::QueuedConnection); - QObject::connect(this, &CameraWidget::vipcAvailableStreamsUpdated, this, &CameraWidget::availableStreamsUpdated, Qt::QueuedConnection); QObject::connect(QApplication::instance(), &QCoreApplication::aboutToQuit, this, &CameraWidget::stopVipcThread); } @@ -38,10 +36,6 @@ void CameraWidget::stopVipcThread() { } } -void CameraWidget::availableStreamsUpdated(std::set streams) { - available_streams = streams; -} - void CameraWidget::paintEvent(QPaintEvent *event) { QPainter p(this); p.fillRect(rect(), bg); @@ -67,10 +61,6 @@ void CameraWidget::paintEvent(QPaintEvent *event) { p.drawImage(video_rect, rgb_frame); } -void CameraWidget::vipcFrameReceived() { - update(); -} - void CameraWidget::vipcThread() { VisionStreamType cur_stream = requested_stream_type; std::unique_ptr vipc_client; @@ -93,7 +83,11 @@ void CameraWidget::vipcThread() { std::this_thread::sleep_for(std::chrono::milliseconds(100)); continue; } - emit vipcAvailableStreamsUpdated(streams); + utils::runOnMainThread([this, alive = std::weak_ptr(alive_), streams]() { + if (alive.expired()) return; + available_streams = streams; + availableStreamsUpdated(streams); + }); if (!vipc_client->connect(false)) { std::this_thread::sleep_for(std::chrono::milliseconds(100)); @@ -112,7 +106,9 @@ void CameraWidget::vipcThread() { std::lock_guard lk(frame_lock); rgb_frame.swap(rgb_back); } - emit vipcThreadFrameReceived(); + utils::runOnMainThread([this, alive = std::weak_ptr(alive_)]() { + if (!alive.expired()) update(); + }); } } } diff --git a/openpilot/tools/cabana/cameraview.h b/openpilot/tools/cabana/cameraview.h index 3b55dd9ed9..ac292a701c 100644 --- a/openpilot/tools/cabana/cameraview.h +++ b/openpilot/tools/cabana/cameraview.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -11,11 +12,10 @@ #include #include "openpilot/cereal/visionstream.h" +#include "tools/cabana/core/observable.h" #include "msgq/visionipc/visionipc_client.h" class CameraWidget : public QWidget { - Q_OBJECT - public: explicit CameraWidget(std::string stream_name, VisionStreamType stream_type, QWidget* parent = nullptr); ~CameraWidget(); @@ -23,16 +23,14 @@ public: VisionStreamType getStreamType() { return active_stream_type; } void stopVipcThread(); -signals: - void clicked(); - void vipcThreadFrameReceived(); - void vipcAvailableStreamsUpdated(std::set); + Observable<> clicked; + Observable> availableStreamsUpdated; // invoked on the main thread protected: void paintEvent(QPaintEvent *event) override; void showEvent(QShowEvent *event) override; void hideEvent(QHideEvent *event) override { stopVipcThread(); } - void mouseReleaseEvent(QMouseEvent *event) override { emit clicked(); } + void mouseReleaseEvent(QMouseEvent *event) override { clicked(); } void vipcThread(); void clearFrames(); @@ -47,10 +45,5 @@ protected: std::thread vipc_thread; std::atomic vipc_exit = false; std::mutex frame_lock; - -protected slots: - void vipcFrameReceived(); - void availableStreamsUpdated(std::set streams); + std::shared_ptr alive_ = std::make_shared(true); }; - -Q_DECLARE_METATYPE(std::set); diff --git a/openpilot/tools/cabana/chart/chart.cc b/openpilot/tools/cabana/chart/chart.cc index 8496c26994..beece12352 100644 --- a/openpilot/tools/cabana/chart/chart.cc +++ b/openpilot/tools/cabana/chart/chart.cc @@ -1,5 +1,4 @@ #include "tools/cabana/chart/chart.h" -#include "tools/cabana/dbc/dbcqt.h" #include #include @@ -37,10 +36,10 @@ ChartView::ChartView(const std::pair &x_range, ChartsWidget *par createToolButtons(); signal_value_font.setPointSize(9); - QObject::connect(dbcNotifier(), &QtDBCNotifier::signalRemoved, this, &ChartView::signalRemoved); - QObject::connect(dbcNotifier(), &QtDBCNotifier::signalUpdated, this, &ChartView::signalUpdated); - QObject::connect(dbcNotifier(), &QtDBCNotifier::msgRemoved, this, &ChartView::msgRemoved); - QObject::connect(dbcNotifier(), &QtDBCNotifier::msgUpdated, this, &ChartView::msgUpdated); + connections_.push_back(dbc()->signalRemoved.connect([this](const cabana::Signal *sig) { signalRemoved(sig); })); + connections_.push_back(dbc()->signalUpdated.connect([this](const cabana::Signal *sig) { signalUpdated(sig); })); + connections_.push_back(dbc()->msgRemoved.connect([this](MessageId id) { msgRemoved(id); })); + connections_.push_back(dbc()->msgUpdated.connect([this](MessageId id) { msgUpdated(id); })); } void ChartView::createToolButtons() { diff --git a/openpilot/tools/cabana/chart/chart.h b/openpilot/tools/cabana/chart/chart.h index b03623475a..d7227b6cf8 100644 --- a/openpilot/tools/cabana/chart/chart.h +++ b/openpilot/tools/cabana/chart/chart.h @@ -126,5 +126,6 @@ private: double tooltip_x = -1; QFont signal_value_font; ChartsWidget *charts_widget; + Connections connections_; friend class ChartsWidget; }; diff --git a/openpilot/tools/cabana/chart/chartswidget.cc b/openpilot/tools/cabana/chart/chartswidget.cc index 3144c5132d..8aa095b65f 100644 --- a/openpilot/tools/cabana/chart/chartswidget.cc +++ b/openpilot/tools/cabana/chart/chartswidget.cc @@ -1,5 +1,4 @@ #include "tools/cabana/chart/chartswidget.h" -#include "tools/cabana/dbc/dbcqt.h" #include #include @@ -76,10 +75,10 @@ ChartsWidget::ChartsWidget(QWidget *parent) : QFrame(parent) { redo_zoom_action = toolbar->addAction(utils::icon("arrow-clockwise"), tr("Redo Zoom"), [this]() { zoom_undo_stack.redo(); }); undo_zoom_action->setEnabled(false); redo_zoom_action->setEnabled(false); - zoom_undo_stack.setCallbacks({.index_changed = [this]() { + connections_.push_back(zoom_undo_stack.indexChanged.connect([this]() { undo_zoom_action->setEnabled(zoom_undo_stack.canUndo()); redo_zoom_action->setEnabled(zoom_undo_stack.canRedo()); - }}); + })); reset_zoom_action = toolbar->addWidget(reset_zoom_btn = new ToolButton("zoom-out", tr("Reset Zoom"))); reset_zoom_btn->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); @@ -122,16 +121,16 @@ ChartsWidget::ChartsWidget(QWidget *parent) : QFrame(parent) { align_timer->setSingleShot(true); QObject::connect(align_timer, &QTimer::timeout, this, &ChartsWidget::alignCharts); QObject::connect(auto_scroll_timer, &QTimer::timeout, this, &ChartsWidget::doAutoScroll); - QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &ChartsWidget::removeAll); - QObject::connect(can, &AbstractStream::eventsMerged, this, &ChartsWidget::eventsMerged); - QObject::connect(can, &AbstractStream::msgsReceived, this, &ChartsWidget::updateState); - QObject::connect(can, &AbstractStream::seeking, this, &ChartsWidget::updateState); - QObject::connect(can, &AbstractStream::timeRangeChanged, this, &ChartsWidget::timeRangeChanged); + connections_.push_back(dbc()->fileChanged.connect([this]() { removeAll(); })); + connections_.push_back(can->eventsMerged.connect([this](const MessageEventsMap &events) { eventsMerged(events); })); + connections_.push_back(can->msgsReceived.connect([this](const std::set *, bool) { updateState(); })); + connections_.push_back(can->seeking.connect([this](double) { updateState(); })); + connections_.push_back(can->timeRangeChanged.connect([this](const auto &range) { timeRangeChanged(range); })); QObject::connect(range_slider, &QSlider::valueChanged, this, &ChartsWidget::setMaxChartRange); QObject::connect(new_plot_btn, &QToolButton::clicked, this, &ChartsWidget::newChart); QObject::connect(remove_all_btn, &QToolButton::clicked, this, &ChartsWidget::removeAll); QObject::connect(reset_zoom_btn, &QToolButton::clicked, this, &ChartsWidget::zoomReset); - QObject::connect(&settings, &Settings::changed, this, &ChartsWidget::settingChanged); + connections_.push_back(settings.changed.connect([this]() { settingChanged(); })); QObject::connect(new_tab_btn, &QToolButton::clicked, this, &ChartsWidget::newTab); QObject::connect(this, &ChartsWidget::seriesChanged, this, &ChartsWidget::updateTabBar); QObject::connect(tabbar, &QTabBar::tabCloseRequested, this, &ChartsWidget::removeTab); diff --git a/openpilot/tools/cabana/chart/chartswidget.h b/openpilot/tools/cabana/chart/chartswidget.h index 8b3003dcd6..4c143242b2 100644 --- a/openpilot/tools/cabana/chart/chartswidget.h +++ b/openpilot/tools/cabana/chart/chartswidget.h @@ -123,6 +123,7 @@ private: QTimer *align_timer; int current_theme = 0; bool value_tip_visible_ = false; + Connections connections_; friend class ChartView; friend class ChartsContainer; }; diff --git a/openpilot/tools/cabana/chart/signalselector.cc b/openpilot/tools/cabana/chart/signalselector.cc index 85832e796b..aab38f5372 100644 --- a/openpilot/tools/cabana/chart/signalselector.cc +++ b/openpilot/tools/cabana/chart/signalselector.cc @@ -1,5 +1,4 @@ #include "tools/cabana/chart/signalselector.h" -#include "tools/cabana/dbc/dbcqt.h" #include #include diff --git a/openpilot/tools/cabana/commands.cc b/openpilot/tools/cabana/commands.cc index b47bf90b0c..c6cdd9b1b7 100644 --- a/openpilot/tools/cabana/commands.cc +++ b/openpilot/tools/cabana/commands.cc @@ -28,22 +28,22 @@ void UndoStack::clear() { bool was_clean = isClean(); commands_.clear(); index_ = clean_index_ = 0; - if (callbacks_.index_changed) callbacks_.index_changed(); - if (!was_clean && callbacks_.clean_changed) callbacks_.clean_changed(true); + indexChanged(); + if (!was_clean) cleanChanged(true); } void UndoStack::setClean() { if (!isClean()) { clean_index_ = index_; - if (callbacks_.clean_changed) callbacks_.clean_changed(true); + cleanChanged(true); } } void UndoStack::setIndex(int index) { bool was_clean = isClean(); index_ = index; - if (callbacks_.index_changed) callbacks_.index_changed(); - if (isClean() != was_clean && callbacks_.clean_changed) callbacks_.clean_changed(isClean()); + indexChanged(); + if (isClean() != was_clean) cleanChanged(isClean()); } UndoStack *UndoStack::instance() { @@ -51,18 +51,6 @@ UndoStack *UndoStack::instance() { return &undo_stack; } -QtUndoNotifier::QtUndoNotifier(QObject *parent) : QObject(parent) { - UndoStack::instance()->setCallbacks({ - .index_changed = [this]() { emit indexChanged(); }, - .clean_changed = [this](bool clean) { emit cleanChanged(clean); }, - }); -} - -QtUndoNotifier *undoNotifier() { - static QtUndoNotifier notifier; - return ¬ifier; -} - // EditMsgCommand EditMsgCommand::EditMsgCommand(const MessageId &id, const std::string &name, int size, diff --git a/openpilot/tools/cabana/commands.h b/openpilot/tools/cabana/commands.h index 200a4f2f5b..1e40499515 100644 --- a/openpilot/tools/cabana/commands.h +++ b/openpilot/tools/cabana/commands.h @@ -1,13 +1,11 @@ #pragma once -#include #include #include #include #include -#include - +#include "tools/cabana/core/observable.h" #include "tools/cabana/dbc/dbcmanager.h" #include "tools/cabana/streams/abstractstream.h" @@ -21,11 +19,6 @@ public: class UndoStack { public: - struct Callbacks { - std::function index_changed; - std::function clean_changed; - }; - void push(UndoCommand *cmd); // takes ownership and calls redo() void undo(); void redo(); @@ -36,31 +29,18 @@ public: bool canRedo() const { return index_ < (int)commands_.size(); } std::string undoText() const { return canUndo() ? commands_[index_ - 1]->text : ""; } std::string redoText() const { return canRedo() ? commands_[index_]->text : ""; } - void setCallbacks(Callbacks callbacks) { callbacks_ = std::move(callbacks); } static UndoStack *instance(); + Observable<> indexChanged; + Observable cleanChanged; + private: void setIndex(int index); std::vector> commands_; int index_ = 0; int clean_index_ = 0; - Callbacks callbacks_; }; -// emits Qt signals for the global undo stack -class QtUndoNotifier : public QObject { - Q_OBJECT - -public: - explicit QtUndoNotifier(QObject *parent = nullptr); - -signals: - void indexChanged(); - void cleanChanged(bool clean); -}; - -QtUndoNotifier *undoNotifier(); - class EditMsgCommand : public UndoCommand { public: EditMsgCommand(const MessageId &id, const std::string &name, int size, const std::string &node, diff --git a/openpilot/tools/cabana/core/observable.h b/openpilot/tools/cabana/core/observable.h new file mode 100644 index 0000000000..0c992bf1da --- /dev/null +++ b/openpilot/tools/cabana/core/observable.h @@ -0,0 +1,83 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace observable_detail { +struct HandlerTable { + virtual ~HandlerTable() = default; + virtual void erase(int id) = 0; +}; +} // namespace observable_detail + +// disconnects on destruction; safe to outlive the Observable +class Connection { +public: + Connection() = default; + Connection(std::weak_ptr table, int id) : table_(std::move(table)), id_(id) {} + Connection(Connection &&other) noexcept { *this = std::move(other); } + Connection &operator=(Connection &&other) noexcept { + if (this != &other) { + disconnect(); + table_ = std::move(other.table_); + id_ = std::exchange(other.id_, -1); + } + return *this; + } + Connection(const Connection &) = delete; + Connection &operator=(const Connection &) = delete; + ~Connection() { disconnect(); } + + void disconnect() { + if (auto table = table_.lock()) table->erase(id_); + table_.reset(); + id_ = -1; + } + +private: + std::weak_ptr table_; + int id_ = -1; +}; + +using Connections = std::vector; + +// main thread only. handlers may disconnect (or destroy the Observable) while being invoked. +template +class Observable { +public: + using Handler = std::function; + + Observable() = default; + Observable(const Observable &) = delete; + Observable &operator=(const Observable &) = delete; + + [[nodiscard]] Connection connect(Handler handler) { + int id = table_->next_id++; + table_->handlers.emplace(id, std::make_shared(std::move(handler))); + return Connection(table_, id); + } + + void operator()(Args... args) const { + auto table = table_; + std::vector ids; + ids.reserve(table->handlers.size()); + for (const auto &[id, _] : table->handlers) ids.push_back(id); + for (int id : ids) { + auto it = table->handlers.find(id); + if (it == table->handlers.end()) continue; + auto handler = it->second; + (*handler)(args...); + } + } + +private: + struct Table : observable_detail::HandlerTable { + std::map> handlers; + int next_id = 0; + void erase(int id) override { handlers.erase(id); } + }; + std::shared_ptr table_ = std::make_shared
(); +}; diff --git a/openpilot/tools/cabana/dbc/dbcmanager.cc b/openpilot/tools/cabana/dbc/dbcmanager.cc index 7a95a4f809..551619ea82 100644 --- a/openpilot/tools/cabana/dbc/dbcmanager.cc +++ b/openpilot/tools/cabana/dbc/dbcmanager.cc @@ -17,7 +17,7 @@ bool DBCManager::open(const SourceSet &sources, const std::string &dbc_file_name return false; } - if (callbacks_.file_changed) callbacks_.file_changed(); + fileChanged(); return true; } @@ -32,7 +32,7 @@ bool DBCManager::open(const SourceSet &sources, const std::string &name, const s return false; } - if (callbacks_.file_changed) callbacks_.file_changed(); + fileChanged(); return true; } @@ -40,26 +40,26 @@ void DBCManager::close(const SourceSet &sources) { for (auto s : sources) { dbc_files[s] = nullptr; } - if (callbacks_.file_changed) callbacks_.file_changed(); + fileChanged(); } void DBCManager::close(DBCFile *dbc_file) { for (auto &[_, f] : dbc_files) { if (f.get() == dbc_file) f = nullptr; } - if (callbacks_.file_changed) callbacks_.file_changed(); + fileChanged(); } void DBCManager::closeAll() { dbc_files.clear(); - if (callbacks_.file_changed) callbacks_.file_changed(); + fileChanged(); } void DBCManager::addSignal(const MessageId &id, const cabana::Signal &sig) { if (auto m = msg(id)) { if (auto s = m->addSignal(sig)) { - if (callbacks_.signal_added) callbacks_.signal_added(id, s); - if (callbacks_.mask_updated) callbacks_.mask_updated(); + signalAdded(id, s); + maskUpdated(); } } } @@ -67,8 +67,8 @@ void DBCManager::addSignal(const MessageId &id, const cabana::Signal &sig) { void DBCManager::updateSignal(const MessageId &id, const std::string &sig_name, const cabana::Signal &sig) { if (auto m = msg(id)) { if (auto s = m->updateSignal(sig_name, sig)) { - if (callbacks_.signal_updated) callbacks_.signal_updated(s); - if (callbacks_.mask_updated) callbacks_.mask_updated(); + signalUpdated(s); + maskUpdated(); } } } @@ -76,9 +76,9 @@ void DBCManager::updateSignal(const MessageId &id, const std::string &sig_name, void DBCManager::removeSignal(const MessageId &id, const std::string &sig_name) { if (auto m = msg(id)) { if (auto s = m->sig(sig_name)) { - if (callbacks_.signal_removed) callbacks_.signal_removed(s); + signalRemoved(s); m->removeSignal(sig_name); - if (callbacks_.mask_updated) callbacks_.mask_updated(); + maskUpdated(); } } } @@ -87,15 +87,15 @@ void DBCManager::updateMsg(const MessageId &id, const std::string &name, uint32_ auto dbc_file = findDBCFile(id); assert(dbc_file); // This should be impossible dbc_file->updateMsg(id, name, size, node, comment); - if (callbacks_.msg_updated) callbacks_.msg_updated(id); + msgUpdated(id); } void DBCManager::removeMsg(const MessageId &id) { auto dbc_file = findDBCFile(id); assert(dbc_file); // This should be impossible dbc_file->removeMsg(id); - if (callbacks_.msg_removed) callbacks_.msg_removed(id); - if (callbacks_.mask_updated) callbacks_.mask_updated(); + msgRemoved(id); + maskUpdated(); } std::string DBCManager::newMsgName(const MessageId &id) { diff --git a/openpilot/tools/cabana/dbc/dbcmanager.h b/openpilot/tools/cabana/dbc/dbcmanager.h index 5a09fae03d..6bc86a8167 100644 --- a/openpilot/tools/cabana/dbc/dbcmanager.h +++ b/openpilot/tools/cabana/dbc/dbcmanager.h @@ -1,12 +1,12 @@ #pragma once -#include #include #include #include #include #include +#include "tools/cabana/core/observable.h" #include "tools/cabana/dbc/dbcfile.h" typedef std::set SourceSet; @@ -15,16 +15,6 @@ inline bool operator<(const std::shared_ptr &l, const std::shared_ptr signal_added; - std::function signal_removed; - std::function signal_updated; - std::function msg_updated; - std::function msg_removed; - std::function file_changed; - std::function mask_updated; - }; - DBCManager() = default; bool open(const SourceSet &sources, const std::string &dbc_file_name, std::string *error = nullptr); bool open(const SourceSet &sources, const std::string &name, const std::string &content, std::string *error = nullptr); @@ -54,11 +44,17 @@ public: DBCFile *findDBCFile(const uint8_t source); inline DBCFile *findDBCFile(const MessageId &id) { return findDBCFile(id.source); } std::set allDBCFiles(); - void setCallbacks(Callbacks callbacks) { callbacks_ = std::move(callbacks); } + + Observable signalAdded; + Observable signalRemoved; + Observable signalUpdated; + Observable msgUpdated; + Observable msgRemoved; + Observable<> fileChanged; + Observable<> maskUpdated; private: std::map> dbc_files; - Callbacks callbacks_; }; DBCManager *dbc(); diff --git a/openpilot/tools/cabana/dbc/dbcqt.cc b/openpilot/tools/cabana/dbc/dbcqt.cc deleted file mode 100644 index 4354caf3f5..0000000000 --- a/openpilot/tools/cabana/dbc/dbcqt.cc +++ /dev/null @@ -1,18 +0,0 @@ -#include "tools/cabana/dbc/dbcqt.h" - -QtDBCNotifier::QtDBCNotifier(QObject *parent) : QObject(parent) { - dbc()->setCallbacks({ - .signal_added = [this](MessageId id, const cabana::Signal *sig) { emit signalAdded(id, sig); }, - .signal_removed = [this](const cabana::Signal *sig) { emit signalRemoved(sig); }, - .signal_updated = [this](const cabana::Signal *sig) { emit signalUpdated(sig); }, - .msg_updated = [this](MessageId id) { emit msgUpdated(id); }, - .msg_removed = [this](MessageId id) { emit msgRemoved(id); }, - .file_changed = [this]() { emit DBCFileChanged(); }, - .mask_updated = [this]() { emit maskUpdated(); }, - }); -} - -QtDBCNotifier *dbcNotifier() { - static QtDBCNotifier notifier; - return ¬ifier; -} diff --git a/openpilot/tools/cabana/dbc/dbcqt.h b/openpilot/tools/cabana/dbc/dbcqt.h deleted file mode 100644 index b889f854ed..0000000000 --- a/openpilot/tools/cabana/dbc/dbcqt.h +++ /dev/null @@ -1,27 +0,0 @@ -#pragma once - -#include -#include - -#include "tools/cabana/dbc/dbcmanager.h" - -Q_DECLARE_METATYPE(MessageId) -Q_DECLARE_METATYPE(ValueDescription) - -class QtDBCNotifier : public QObject { - Q_OBJECT - -public: - explicit QtDBCNotifier(QObject *parent = nullptr); - -signals: - void signalAdded(MessageId id, const cabana::Signal *sig); - void signalRemoved(const cabana::Signal *sig); - void signalUpdated(const cabana::Signal *sig); - void msgUpdated(MessageId id); - void msgRemoved(MessageId id); - void DBCFileChanged(); - void maskUpdated(); -}; - -QtDBCNotifier *dbcNotifier(); diff --git a/openpilot/tools/cabana/detailwidget.cc b/openpilot/tools/cabana/detailwidget.cc index 6b62f54959..d0cccb5992 100644 --- a/openpilot/tools/cabana/detailwidget.cc +++ b/openpilot/tools/cabana/detailwidget.cc @@ -1,5 +1,4 @@ #include "tools/cabana/detailwidget.h" -#include "tools/cabana/dbc/dbcqt.h" #include #include @@ -56,9 +55,9 @@ DetailWidget::DetailWidget(ChartsWidget *charts, QWidget *parent) : charts(chart QObject::connect(signal_view, &SignalView::showChart, charts, &ChartsWidget::showChart); QObject::connect(signal_view, &SignalView::highlight, binary_view, &BinaryView::highlight); QObject::connect(tab_widget, &QTabWidget::currentChanged, [this]() { updateState(); }); - QObject::connect(can, &AbstractStream::msgsReceived, this, &DetailWidget::updateState); - QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &DetailWidget::refresh); - QObject::connect(undoNotifier(), &QtUndoNotifier::indexChanged, this, &DetailWidget::refresh); + connections_.push_back(can->msgsReceived.connect([this](const std::set *msgs, bool) { updateState(msgs); })); + connections_.push_back(dbc()->fileChanged.connect([this]() { refresh(); })); + connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { refresh(); })); QObject::connect(tabbar, &QTabBar::customContextMenuRequested, this, &DetailWidget::showTabBarContextMenu); QObject::connect(tabbar, &QTabBar::currentChanged, [this](int index) { if (index != -1) { @@ -97,11 +96,11 @@ void DetailWidget::createToolBar() { layout()->addWidget(toolbar); connect(heatmap_live, &QAbstractButton::toggled, this, [this](bool on) { binary_view->setHeatmapLiveMode(on); }); - connect(can, &AbstractStream::timeRangeChanged, this, [=](const std::optional> &range) { + connections_.push_back(can->timeRangeChanged.connect([=](const std::optional> &range) { auto text = range ? QString("%1 - %2").arg(range->first, 0, 'f', 3).arg(range->second, 0, 'f', 3) : "All"; heatmap_all->setText(text); (range ? heatmap_all : heatmap_live)->setChecked(true); - }); + })); } void DetailWidget::showTabBarContextMenu(const QPoint &pt) { diff --git a/openpilot/tools/cabana/detailwidget.h b/openpilot/tools/cabana/detailwidget.h index 0fe1535c7a..48d320c40f 100644 --- a/openpilot/tools/cabana/detailwidget.h +++ b/openpilot/tools/cabana/detailwidget.h @@ -57,6 +57,7 @@ private: SignalView *signal_view; ChartsWidget *charts; QSplitter *splitter; + Connections connections_; }; class CenterWidget : public QWidget { diff --git a/openpilot/tools/cabana/historylog.cc b/openpilot/tools/cabana/historylog.cc index 3fd569c648..26c0f4168f 100644 --- a/openpilot/tools/cabana/historylog.cc +++ b/openpilot/tools/cabana/historylog.cc @@ -1,5 +1,4 @@ #include "tools/cabana/historylog.h" -#include "tools/cabana/dbc/dbcqt.h" #include @@ -10,6 +9,12 @@ #include "tools/cabana/commands.h" #include "tools/cabana/utils/export.h" +HistoryLogModel::HistoryLogModel(QObject *parent) : QAbstractTableModel(parent) { + connections_.push_back(can->seekedTo.connect([this](double) { reset(); })); + connections_.push_back(dbc()->fileChanged.connect([this]() { reset(); })); + connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { reset(); })); +} + QVariant HistoryLogModel::data(const QModelIndex &index, int role) const { const auto &m = messages[index.row()]; const int col = index.column(); @@ -207,9 +212,6 @@ LogsWidget::LogsWidget(QWidget *parent) : QFrame(parent) { QObject::connect(comp_box, SIGNAL(activated(int)), this, SLOT(filterChanged())); QObject::connect(value_edit, &QLineEdit::textEdited, this, &LogsWidget::filterChanged); QObject::connect(export_btn, &QToolButton::clicked, this, &LogsWidget::exportToCSV); - QObject::connect(can, &AbstractStream::seekedTo, model, &HistoryLogModel::reset); - QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, model, &HistoryLogModel::reset); - QObject::connect(undoNotifier(), &QtUndoNotifier::indexChanged, model, &HistoryLogModel::reset); QObject::connect(model, &HistoryLogModel::modelReset, this, &LogsWidget::modelReset); QObject::connect(model, &HistoryLogModel::rowsInserted, [this]() { export_btn->setEnabled(true); }); } diff --git a/openpilot/tools/cabana/historylog.h b/openpilot/tools/cabana/historylog.h index 1d3200b200..405fe60fbb 100644 --- a/openpilot/tools/cabana/historylog.h +++ b/openpilot/tools/cabana/historylog.h @@ -22,7 +22,7 @@ class HistoryLogModel : public QAbstractTableModel { Q_OBJECT public: - HistoryLogModel(QObject *parent) : QAbstractTableModel(parent) {} + HistoryLogModel(QObject *parent); void setMessage(const MessageId &message_id); void updateState(bool clear = false); void setFilter(int sig_idx, const QString &value, std::function cmp); @@ -54,6 +54,7 @@ public: std::deque messages; std::vector sigs; bool hex_mode = false; + Connections connections_; }; class LogsWidget : public QFrame { diff --git a/openpilot/tools/cabana/mainwin.cc b/openpilot/tools/cabana/mainwin.cc index 953f573a0b..bcfe1dde41 100644 --- a/openpilot/tools/cabana/mainwin.cc +++ b/openpilot/tools/cabana/mainwin.cc @@ -1,5 +1,4 @@ #include "tools/cabana/mainwin.h" -#include "tools/cabana/dbc/dbcqt.h" #include #include @@ -41,15 +40,13 @@ MainWindow::MainWindow(AbstractStream *stream, const QString &dbc_file) : QMainW restoreGeometry(utils::qbytes(settings.geometry)); restoreState(utils::qbytes(settings.window_state)); - // install handlers + // download handlers are called from download threads static auto static_main_win = this; - qRegisterMetaType("uint64_t"); - qRegisterMetaType("SourceSet"); installDownloadProgressHandler([](uint64_t cur, uint64_t total, bool success) { - emit static_main_win->updateProgressBar(cur, total, success); + utils::runOnMainThread([=]() { static_main_win->updateDownloadProgress(cur, total, success); }); }); installMessageHandler([](ReplyMsgType type, const std::string msg) { - emit static_main_win->showMessage(QString::fromStdString(msg), 2000); + utils::runOnMainThread([=]() { static_main_win->statusBar()->showMessage(QString::fromStdString(msg), 2000); }); }); setStyleSheet(QString(R"(QMainWindow::separator { @@ -57,11 +54,14 @@ MainWindow::MainWindow(AbstractStream *stream, const QString &dbc_file) : QMainW height: %1px; /* when horizontal */ })").arg(style()->pixelMetric(QStyle::PM_SplitterWidth))); - QObject::connect(this, &MainWindow::showMessage, statusBar(), &QStatusBar::showMessage); - QObject::connect(this, &MainWindow::updateProgressBar, this, &MainWindow::updateDownloadProgress); - QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &MainWindow::DBCFileChanged); - QObject::connect(undoNotifier(), &QtUndoNotifier::cleanChanged, this, &MainWindow::undoStackCleanChanged); - QObject::connect(&settings, &Settings::changed, this, &MainWindow::updateStatus); + connections_.push_back(dbc()->fileChanged.connect([this]() { DBCFileChanged(); })); + connections_.push_back(UndoStack::instance()->cleanChanged.connect([this](bool clean) { undoStackCleanChanged(clean); })); + connections_.push_back(settings.changed.connect([this]() { updateStatus(); })); + + // temporary pump for the non-Qt main thread queue until imgui owns the loop + auto *queue_timer = new QTimer(this); + QObject::connect(queue_timer, &QTimer::timeout, utils::drainMainThreadQueue); + queue_timer->start(10); QTimer::singleShot(0, this, [=]() { stream ? openStream(stream, dbc_file) : selectAndOpenStream(); }); show(); @@ -136,7 +136,7 @@ void MainWindow::createActions() { undo_act->setShortcuts(QKeySequence::Undo); redo_act = edit_menu->addAction(tr("&Redo"), []() { UndoStack::instance()->redo(); }); redo_act->setShortcuts(QKeySequence::Redo); - QObject::connect(undoNotifier(), &QtUndoNotifier::indexChanged, this, &MainWindow::updateUndoRedoActions); + connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { updateUndoRedoActions(); })); updateUndoRedoActions(); // View Menu @@ -259,14 +259,14 @@ void MainWindow::selectAndOpenStream() { if (dlg.exec()) { openStream(dlg.stream(), dlg.dbcFile()); } else if (!can) { - openStream(new DummyStream(this)); + openStream(new DummyStream()); } } void MainWindow::closeStream() { - openStream(new DummyStream(this)); + openStream(new DummyStream()); if (dbc()->nonEmptyDBCCount() > 0) { - emit dbcNotifier()->DBCFileChanged(); + dbc()->fileChanged(); } statusBar()->showMessage(tr("stream closed")); } @@ -336,13 +336,19 @@ void MainWindow::loadFromClipboard(SourceSet s, bool close_all) { } } +// stream threads read the global `can` until its destructor joins them +MainWindow::~MainWindow() { + delete can; + can = nullptr; +} + void MainWindow::openStream(AbstractStream *stream, const QString &dbc_file) { - if (can) { - QObject::connect(can, &QObject::destroyed, this, [=]() { startStream(stream, dbc_file); }); - can->deleteLater(); - } else { - startStream(stream, dbc_file); - } + stream_connections_.clear(); + if (wait_dlg_) wait_dlg_->deleteLater(); + wait_dlg_ = nullptr; + delete can; + can = nullptr; + startStream(stream, dbc_file); } void MainWindow::startStream(AbstractStream *stream, QString dbc_file) { @@ -350,8 +356,7 @@ void MainWindow::startStream(AbstractStream *stream, QString dbc_file) { delete messages_widget; delete video_splitter; - can = stream; - can->setParent(this); // take ownership + can = stream; // take ownership can->start(); loadFile(dbc_file); @@ -373,18 +378,19 @@ void MainWindow::startStream(AbstractStream *stream, QString dbc_file) { newFile(); } - QObject::connect(can, &AbstractStream::eventsMerged, this, &MainWindow::eventsMerged); + stream_connections_.push_back(can->eventsMerged.connect([this](const MessageEventsMap &) { eventsMerged(); })); if (has_stream) { - auto wait_dlg = new QProgressDialog( + wait_dlg_ = new QProgressDialog( can->liveStreaming() ? tr("Waiting for the live stream to start...") : tr("Loading segment data..."), tr("&Abort"), 0, 100, this); - wait_dlg->setWindowModality(Qt::WindowModal); - wait_dlg->setFixedSize(400, wait_dlg->sizeHint().height()); - QObject::connect(wait_dlg, &QProgressDialog::canceled, this, &MainWindow::close); - QObject::connect(can, &AbstractStream::eventsMerged, wait_dlg, &QProgressDialog::deleteLater); - QObject::connect(this, &MainWindow::updateProgressBar, wait_dlg, [=](uint64_t cur, uint64_t total, bool success) { - wait_dlg->setValue((int)((cur / (double)total) * 100)); + wait_dlg_->setWindowModality(Qt::WindowModal); + wait_dlg_->setFixedSize(400, wait_dlg_->sizeHint().height()); + QObject::connect(wait_dlg_, &QProgressDialog::canceled, this, &MainWindow::close); + wait_dlg_connection_ = can->eventsMerged.connect([this](const MessageEventsMap &) { + wait_dlg_->deleteLater(); + wait_dlg_ = nullptr; + wait_dlg_connection_.disconnect(); }); } } @@ -544,6 +550,7 @@ void MainWindow::remindSaveChanges() { } void MainWindow::updateDownloadProgress(uint64_t cur, uint64_t total, bool success) { + if (wait_dlg_) wait_dlg_->setValue((int)((cur / (double)total) * 100)); if (success && cur < total) { progress_bar->setValue((cur / (double)total) * 100); progress_bar->setFormat(tr("Downloading %p% (%1)").arg(formattedDataSize(total).c_str())); diff --git a/openpilot/tools/cabana/mainwin.h b/openpilot/tools/cabana/mainwin.h index 279ea3b969..a57232ceb2 100644 --- a/openpilot/tools/cabana/mainwin.h +++ b/openpilot/tools/cabana/mainwin.h @@ -19,11 +19,14 @@ #include "tools/cabana/videowidget.h" #include "tools/cabana/tools/findsimilarbits.h" +class QProgressDialog; + class MainWindow : public QMainWindow { Q_OBJECT public: MainWindow(AbstractStream *stream, const QString &dbc_file); + ~MainWindow(); void toggleChartsDocking(); void showStatusMessage(const QString &msg, int timeout = 0) { statusBar()->showMessage(msg, timeout); } void loadFile(const QString &fn, SourceSet s = SOURCE_ALL); @@ -42,10 +45,6 @@ public slots: void saveAs(); void saveToClipboard(); -signals: - void showMessage(const QString &msg, int timeout); - void updateProgressBar(uint64_t cur, uint64_t total, bool success); - protected: void startStream(AbstractStream *stream, QString dbc_file); bool eventFilter(QObject *obj, QEvent *event) override; @@ -104,6 +103,10 @@ protected: QAction *redo_act = nullptr; QString car_fingerprint; std::vector default_state; + Connections connections_; + Connections stream_connections_; + Connection wait_dlg_connection_; + QProgressDialog *wait_dlg_ = nullptr; }; class HelpOverlay : public QWidget { diff --git a/openpilot/tools/cabana/messageswidget.cc b/openpilot/tools/cabana/messageswidget.cc index b07e3ea0bf..eea07b8711 100644 --- a/openpilot/tools/cabana/messageswidget.cc +++ b/openpilot/tools/cabana/messageswidget.cc @@ -1,5 +1,4 @@ #include "tools/cabana/messageswidget.h" -#include "tools/cabana/dbc/dbcqt.h" #include #include @@ -43,9 +42,6 @@ MessagesWidget::MessagesWidget(QWidget *parent) : menu(new QMenu(this)), QWidget QObject::connect(menu, &QMenu::aboutToShow, this, &MessagesWidget::menuAboutToShow); QObject::connect(header, &MessageViewHeader::customContextMenuRequested, this, &MessagesWidget::headerContextMenuEvent); QObject::connect(view->horizontalScrollBar(), &QScrollBar::valueChanged, header, &MessageViewHeader::updateHeaderPositions); - QObject::connect(can, &AbstractStream::msgsReceived, model, &MessageListModel::msgsReceived); - QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, model, &MessageListModel::dbcModified); - QObject::connect(undoNotifier(), &QtUndoNotifier::indexChanged, model, &MessageListModel::dbcModified); QObject::connect(model, &MessageListModel::modelReset, [this]() { if (current_msg_id) { selectMessage(*current_msg_id); @@ -96,7 +92,7 @@ QWidget *MessagesWidget::createToolBar() { QObject::connect(suppress_add, &QPushButton::clicked, this, &MessagesWidget::suppressHighlighted); QObject::connect(suppress_clear, &QPushButton::clicked, this, &MessagesWidget::suppressHighlighted); - QObject::connect(suppress_defined_signals, &QCheckBox::stateChanged, can, &AbstractStream::suppressDefinedSignals); + QObject::connect(suppress_defined_signals, &QCheckBox::stateChanged, this, [](int state) { can->suppressDefinedSignals(state); }); suppressHighlighted(); return toolbar; @@ -161,6 +157,12 @@ void MessagesWidget::setMultiLineBytes(bool multi) { // MessageListModel +MessageListModel::MessageListModel(QObject *parent) : QAbstractTableModel(parent) { + connections_.push_back(can->msgsReceived.connect([this](const std::set *msgs, bool has_new_ids) { msgsReceived(msgs, has_new_ids); })); + connections_.push_back(dbc()->fileChanged.connect([this]() { dbcModified(); })); + connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { dbcModified(); })); +} + QVariant MessageListModel::headerData(int section, Qt::Orientation orientation, int role) const { if (orientation == Qt::Horizontal && role == Qt::DisplayRole) { switch (section) { diff --git a/openpilot/tools/cabana/messageswidget.h b/openpilot/tools/cabana/messageswidget.h index 0a9cd256d8..28cab25463 100644 --- a/openpilot/tools/cabana/messageswidget.h +++ b/openpilot/tools/cabana/messageswidget.h @@ -31,7 +31,7 @@ public: DATA, }; - MessageListModel(QObject *parent) : QAbstractTableModel(parent) {} + MessageListModel(QObject *parent); QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; int columnCount(const QModelIndex &parent = QModelIndex()) const override { return Column::DATA + 1; } QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const; @@ -63,6 +63,7 @@ private: int sort_column = 0; Qt::SortOrder sort_order = Qt::AscendingOrder; int sort_threshold_ = 0; + Connections connections_; }; class MessageView : public QTreeView { diff --git a/openpilot/tools/cabana/settings.cc b/openpilot/tools/cabana/settings.cc index 0c8136b84c..cbeded0d10 100644 --- a/openpilot/tools/cabana/settings.cc +++ b/openpilot/tools/cabana/settings.cc @@ -613,6 +613,6 @@ void SettingsDlg::save() { settings.log_livestream = log_livestream->isChecked(); settings.log_path = log_path->text().toStdString(); settings.drag_direction = (Settings::DragDirection)drag_direction->currentIndex(); - emit settings.changed(); + settings.changed(); QDialog::accept(); } diff --git a/openpilot/tools/cabana/settings.h b/openpilot/tools/cabana/settings.h index 7357ecf4fc..2c21fab419 100644 --- a/openpilot/tools/cabana/settings.h +++ b/openpilot/tools/cabana/settings.h @@ -9,11 +9,10 @@ #include #include +#include "tools/cabana/core/observable.h" #include "tools/cabana/core/settings.h" -class Settings : public QObject, public CabanaSettingsState { - Q_OBJECT - +class Settings : public CabanaSettingsState { public: Settings(); void save(); @@ -24,8 +23,7 @@ public: std::vector window_state; std::vector message_header_state; -signals: - void changed(); + Observable<> changed; }; class SettingsDlg : public QDialog { diff --git a/openpilot/tools/cabana/signalview.cc b/openpilot/tools/cabana/signalview.cc index ebb5374140..250d05e3b2 100644 --- a/openpilot/tools/cabana/signalview.cc +++ b/openpilot/tools/cabana/signalview.cc @@ -1,5 +1,4 @@ #include "tools/cabana/signalview.h" -#include "tools/cabana/dbc/dbcqt.h" #include #include @@ -26,12 +25,12 @@ static QString signalTypeToString(cabana::Signal::Type type) { } SignalModel::SignalModel(QObject *parent) : root(new Item), QAbstractItemModel(parent) { - QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &SignalModel::refresh); - QObject::connect(dbcNotifier(), &QtDBCNotifier::msgUpdated, this, &SignalModel::handleMsgChanged); - QObject::connect(dbcNotifier(), &QtDBCNotifier::msgRemoved, this, &SignalModel::handleMsgChanged); - QObject::connect(dbcNotifier(), &QtDBCNotifier::signalAdded, this, &SignalModel::handleSignalAdded); - QObject::connect(dbcNotifier(), &QtDBCNotifier::signalUpdated, this, &SignalModel::handleSignalUpdated); - QObject::connect(dbcNotifier(), &QtDBCNotifier::signalRemoved, this, &SignalModel::handleSignalRemoved); + connections_.push_back(dbc()->fileChanged.connect([this]() { refresh(); })); + connections_.push_back(dbc()->msgUpdated.connect([this](MessageId id) { handleMsgChanged(id); })); + connections_.push_back(dbc()->msgRemoved.connect([this](MessageId id) { handleMsgChanged(id); })); + connections_.push_back(dbc()->signalAdded.connect([this](MessageId id, const cabana::Signal *sig) { handleSignalAdded(id, sig); })); + connections_.push_back(dbc()->signalUpdated.connect([this](const cabana::Signal *sig) { handleSignalUpdated(sig); })); + connections_.push_back(dbc()->signalRemoved.connect([this](const cabana::Signal *sig) { handleSignalRemoved(sig); })); } void SignalModel::insertItem(SignalModel::Item *root_item, int pos, const cabana::Signal *sig) { @@ -472,11 +471,11 @@ SignalView::SignalView(ChartsWidget *charts, QWidget *parent) : charts(charts), QObject::connect(tree, &QTreeView::entered, [this](const QModelIndex &index) { emit highlight(model->getItem(index)->sig); }); QObject::connect(model, &QAbstractItemModel::modelReset, this, &SignalView::rowsChanged); QObject::connect(model, &QAbstractItemModel::rowsRemoved, this, &SignalView::rowsChanged); - QObject::connect(dbcNotifier(), &QtDBCNotifier::signalAdded, this, &SignalView::handleSignalAdded); - QObject::connect(dbcNotifier(), &QtDBCNotifier::signalUpdated, this, &SignalView::handleSignalUpdated); + connections_.push_back(dbc()->signalAdded.connect([this](MessageId id, const cabana::Signal *sig) { handleSignalAdded(id, sig); })); + connections_.push_back(dbc()->signalUpdated.connect([this](const cabana::Signal *sig) { handleSignalUpdated(sig); })); QObject::connect(tree->verticalScrollBar(), &QScrollBar::valueChanged, [this]() { updateState(); }); QObject::connect(tree->verticalScrollBar(), &QScrollBar::rangeChanged, [this]() { updateState(); }); - QObject::connect(can, &AbstractStream::msgsReceived, this, &SignalView::updateState); + connections_.push_back(can->msgsReceived.connect([this](const std::set *msgs, bool) { updateState(msgs); })); QObject::connect(tree->header(), &QHeaderView::sectionResized, [this](int logicalIndex, int oldSize, int newSize) { if (logicalIndex == 1) { value_column_width = newSize; diff --git a/openpilot/tools/cabana/signalview.h b/openpilot/tools/cabana/signalview.h index bccfbab0cc..e2a99ebbd1 100644 --- a/openpilot/tools/cabana/signalview.h +++ b/openpilot/tools/cabana/signalview.h @@ -62,6 +62,7 @@ private: MessageId msg_id; QString filter_str; std::unique_ptr root; + Connections connections_; friend class SignalView; friend class SignalItemDelegate; }; @@ -150,4 +151,5 @@ private: ChartsWidget *charts; QLabel *signal_count_lb; SignalItemDelegate *delegate; + Connections connections_; }; diff --git a/openpilot/tools/cabana/streams/abstractstream.cc b/openpilot/tools/cabana/streams/abstractstream.cc index c58a98084f..cc8b2f96ad 100644 --- a/openpilot/tools/cabana/streams/abstractstream.cc +++ b/openpilot/tools/cabana/streams/abstractstream.cc @@ -1,10 +1,8 @@ #include "tools/cabana/streams/abstractstream.h" -#include "tools/cabana/dbc/dbcqt.h" #include #include -#include #include "common/timing.h" #include "tools/cabana/settings.h" @@ -12,15 +10,41 @@ static const int EVENT_NEXT_BUFFER_SIZE = 6 * 1024 * 1024; // 6MB AbstractStream *can = nullptr; -AbstractStream::AbstractStream(QObject *parent) : QObject(parent) { - assert(parent != nullptr); +AbstractStream::AbstractStream() { event_buffer_ = std::make_unique(EVENT_NEXT_BUFFER_SIZE); - QObject::connect(this, &AbstractStream::privateUpdateLastMsgsSignal, this, &AbstractStream::updateLastMessages, Qt::QueuedConnection); - QObject::connect(this, &AbstractStream::seekedTo, this, &AbstractStream::updateLastMsgsTo); - QObject::connect(this, &AbstractStream::seeking, this, [this](double sec) { current_sec_ = sec; }); - QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &AbstractStream::updateMasks); - QObject::connect(dbcNotifier(), &QtDBCNotifier::maskUpdated, this, &AbstractStream::updateMasks); + // connected first so the stream state is updated before any widget handlers run + connections_.push_back(seekedTo.connect([this](double sec) { updateLastMsgsTo(sec); })); + connections_.push_back(seeking.connect([this](double sec) { current_sec_ = sec; })); + connections_.push_back(dbc()->fileChanged.connect([this]() { updateMasks(); })); + connections_.push_back(dbc()->maskUpdated.connect([this]() { updateMasks(); })); +} + +void AbstractStream::postToMainThread(std::function fn) { + utils::runOnMainThread([alive = std::weak_ptr(alive_), fn = std::move(fn)]() { + if (!alive.expired()) fn(); + }); +} + +void AbstractStream::postToMainThreadAndWait(std::function fn) { + assert(!utils::isMainThread()); + std::unique_lock lock(mutex_); + if (exiting_) return; + auto done = std::make_shared(false); + postToMainThread([this, alive = std::weak_ptr(alive_), done, fn = std::move(fn)]() { + fn(); + if (alive.expired()) return; // fn deleted the stream, the waiter was released by cancelWaits() + std::lock_guard lk(mutex_); + *done = true; + wait_cv_.notify_all(); + }); + wait_cv_.wait(lock, [&]() { return *done || exiting_; }); +} + +void AbstractStream::cancelWaits() { + std::lock_guard lk(mutex_); + exiting_ = true; + wait_cv_.notify_all(); } void AbstractStream::updateMasks() { @@ -97,9 +121,8 @@ void AbstractStream::updateLastMessages() { if (sources.size() != prev_src_size) { updateMasks(); - emit sourcesUpdated(sources); } - emit msgsReceived(&msgs, prev_msg_size != last_msgs.size()); + msgsReceived(&msgs, prev_msg_size != last_msgs.size()); } void AbstractStream::setTimeRange(const std::optional> &range) { @@ -107,7 +130,7 @@ void AbstractStream::setTimeRange(const std::optional> if (time_range_ && (current_sec_ < time_range_->first || current_sec_ >= time_range_->second)) { seekTo(time_range_->first); } - emit timeRangeChanged(time_range_); + timeRangeChanged(time_range_); } void AbstractStream::updateEvent(const MessageId &id, double sec, const uint8_t *data, uint8_t size) { @@ -175,16 +198,16 @@ void AbstractStream::updateLastMsgsTo(double sec) { std::any_of(messages_.cbegin(), messages_.cend(), [this](const auto &m) { return !last_msgs.count(m.first); }); last_msgs = messages_; - emit msgsReceived(nullptr, id_changed); + msgsReceived(nullptr, id_changed); std::lock_guard lk(mutex_); seek_finished_ = true; - seek_finished_cv_.notify_one(); + wait_cv_.notify_all(); } void AbstractStream::waitForSeekFinshed() { std::unique_lock lock(mutex_); - seek_finished_cv_.wait(lock, [this]() { return seek_finished_; }); + wait_cv_.wait(lock, [this]() { return seek_finished_ || exiting_; }); seek_finished_ = false; } @@ -218,16 +241,16 @@ void AbstractStream::mergeEvents(const std::vector &events) { } auto pos = std::upper_bound(all_events_.cbegin(), all_events_.cend(), events.front()->mono_time, CompareCanEvent()); all_events_.insert(pos, events.cbegin(), events.cend()); - emit eventsMerged(msg_events); + eventsMerged(msg_events); } } std::pair AbstractStream::eventsInRange(const MessageId &id, std::optional> time_range) const { - const auto &events = can->events(id); + const auto &events = this->events(id); if (!time_range) return {events.begin(), events.end()}; - auto first = std::lower_bound(events.begin(), events.end(), can->toMonoTime(time_range->first), CompareCanEvent()); - auto last = std::upper_bound(first, events.end(), can->toMonoTime(time_range->second), CompareCanEvent()); + auto first = std::lower_bound(events.begin(), events.end(), toMonoTime(time_range->first), CompareCanEvent()); + auto last = std::upper_bound(first, events.end(), toMonoTime(time_range->second), CompareCanEvent()); return {first, last}; } diff --git a/openpilot/tools/cabana/streams/abstractstream.h b/openpilot/tools/cabana/streams/abstractstream.h index 82cb899937..17d212041a 100644 --- a/openpilot/tools/cabana/streams/abstractstream.h +++ b/openpilot/tools/cabana/streams/abstractstream.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -14,16 +15,15 @@ #include "openpilot/cereal/messaging/messaging.h" #include "tools/cabana/core/can_data.h" +#include "tools/cabana/core/observable.h" #include "tools/cabana/dbc/dbcmanager.h" #include "tools/cabana/utils/util.h" #include "tools/replay/util.h" -class AbstractStream : public QObject { - Q_OBJECT - +class AbstractStream { public: - AbstractStream(QObject *parent); - virtual ~AbstractStream() {} + AbstractStream(); + virtual ~AbstractStream() = default; virtual void start() = 0; virtual bool liveStreaming() const { return true; } virtual void seekTo(double ts) {} @@ -56,21 +56,22 @@ public: void clearSuppressed(); void suppressDefinedSignals(bool suppress); -signals: - void paused(); - void resume(); - void seeking(double sec); - void seekedTo(double sec); - void timeRangeChanged(const std::optional> &range); - void eventsMerged(const MessageEventsMap &events_map); - void msgsReceived(const std::set *new_msgs, bool has_new_ids); - void sourcesUpdated(const SourceSet &s); - void privateUpdateLastMsgsSignal(); + // invoked on the main thread + Observable<> paused; + Observable<> resume; + Observable seeking; + Observable seekedTo; + Observable> &> timeRangeChanged; + Observable eventsMerged; + Observable *, bool> msgsReceived; -public: SourceSet sources; protected: + void postToMainThread(std::function fn); // dropped if the stream is destroyed first + void postToMainThreadAndWait(std::function fn); + void cancelWaits(); // call before joining threads, the main thread isn't pumping events during destruction + void requestUpdateLastMessages() { postToMainThread([this]() { updateLastMessages(); }); } void mergeEvents(const std::vector &events); const CanEvent *newEvent(uint64_t mono_time, const cereal::CanData::Reader &c); void updateEvent(const MessageId &id, double sec, const uint8_t *data, uint8_t size); @@ -87,11 +88,14 @@ private: MessageEventsMap events_; std::unordered_map last_msgs; std::unique_ptr event_buffer_; + std::shared_ptr alive_ = std::make_shared(true); + Connections connections_; // Members accessed in multiple threads. (mutex protected) std::mutex mutex_; - std::condition_variable seek_finished_cv_; + std::condition_variable wait_cv_; bool seek_finished_ = false; + bool exiting_ = false; std::set new_msgs_; std::unordered_map messages_; std::unordered_map> masks_; @@ -108,9 +112,7 @@ signals: }; class DummyStream : public AbstractStream { - Q_OBJECT public: - DummyStream(QObject *parent) : AbstractStream(parent) {} std::string routeName() const override { return "No Stream"; } void start() override {} }; diff --git a/openpilot/tools/cabana/streams/devicestream.cc b/openpilot/tools/cabana/streams/devicestream.cc index 3bd51c079d..3b4b54707c 100644 --- a/openpilot/tools/cabana/streams/devicestream.cc +++ b/openpilot/tools/cabana/streams/devicestream.cc @@ -23,7 +23,7 @@ // DeviceStream -DeviceStream::DeviceStream(QObject *parent, QString address) : zmq_address(address), LiveStream(parent) { +DeviceStream::DeviceStream(QString address) : zmq_address(address) { } DeviceStream::~DeviceStream() { @@ -61,8 +61,8 @@ void DeviceStream::start() { // fails, the child writes errno and the parent aborts stream start. int err_pipe[2] = {-1, -1}; if (::pipe(err_pipe) != 0) { - QMessageBox::warning(nullptr, tr("Error"), - tr("Failed to start bridge: %1").arg(QString::fromLocal8Bit(strerror(errno)))); + QMessageBox::warning(nullptr, "Error", + QString("Failed to start bridge: %1").arg(QString::fromLocal8Bit(strerror(errno)))); return; } @@ -79,8 +79,8 @@ void DeviceStream::start() { ::close(err_pipe[1]); if (pid < 0) { ::close(err_pipe[0]); - QMessageBox::warning(nullptr, tr("Error"), - tr("Failed to start bridge: %1").arg(QString::fromLocal8Bit(strerror(errno)))); + QMessageBox::warning(nullptr, "Error", + QString("Failed to start bridge: %1").arg(QString::fromLocal8Bit(strerror(errno)))); return; } @@ -91,8 +91,8 @@ void DeviceStream::start() { // Child failed to exec; reap and surface the error. int status = 0; ::waitpid(pid, &status, 0); - QMessageBox::warning(nullptr, tr("Error"), - tr("Failed to start bridge: %1").arg(QString::fromLocal8Bit(strerror(exec_errno)))); + QMessageBox::warning(nullptr, "Error", + QString("Failed to start bridge: %1").arg(QString::fromLocal8Bit(strerror(exec_errno)))); return; } @@ -144,5 +144,5 @@ OpenDeviceWidget::OpenDeviceWidget(QWidget *parent) : AbstractOpenStreamWidget(p AbstractStream *OpenDeviceWidget::open() { QString ip = ip_address->text().isEmpty() ? "127.0.0.1" : ip_address->text(); bool msgq = group->checkedId() == 0; - return new DeviceStream(qApp, msgq ? "" : ip); + return new DeviceStream(msgq ? "" : ip); } diff --git a/openpilot/tools/cabana/streams/devicestream.h b/openpilot/tools/cabana/streams/devicestream.h index 0e6951c92c..1aa8ce9bff 100644 --- a/openpilot/tools/cabana/streams/devicestream.h +++ b/openpilot/tools/cabana/streams/devicestream.h @@ -5,9 +5,8 @@ #include class DeviceStream : public LiveStream { - Q_OBJECT public: - DeviceStream(QObject *parent, QString address = {}); + DeviceStream(QString address = {}); ~DeviceStream(); inline std::string routeName() const override { return "Live Streaming From " + (zmq_address.isEmpty() ? std::string("127.0.0.1") : zmq_address.toStdString()); diff --git a/openpilot/tools/cabana/streams/livestream.cc b/openpilot/tools/cabana/streams/livestream.cc index 019a67e8f2..05a6d44c40 100644 --- a/openpilot/tools/cabana/streams/livestream.cc +++ b/openpilot/tools/cabana/streams/livestream.cc @@ -38,7 +38,7 @@ struct LiveStream::Logger { uint64_t start_ts; }; -LiveStream::LiveStream(QObject *parent) : AbstractStream(parent) { +LiveStream::LiveStream() { if (settings.log_livestream) { logger = std::make_unique(); } @@ -65,9 +65,9 @@ void LiveStream::stop() { void LiveStream::updateThread() { while (!exit_) { std::this_thread::sleep_for(std::chrono::milliseconds(1000 / fps_)); - // coalesce: skip the emit if the main thread hasn't processed the previous one yet. + // coalesce: skip the request if the main thread hasn't processed the previous one yet. if (!update_pending_.exchange(true)) { - emit privateUpdateLastMsgsSignal(); + requestUpdateLastMessages(); } } } @@ -89,7 +89,7 @@ void LiveStream::handleEvent(kj::ArrayPtr data) { } } -// called on the main thread by the queued privateUpdateLastMsgsSignal connection +// called on the main thread via requestUpdateLastMessages() void LiveStream::updateLastMessages() { update_pending_ = false; fps_ = settings.fps; @@ -142,10 +142,10 @@ void LiveStream::seekTo(double sec) { first_update_ts = nanos_since_boot(); current_event_ts = first_event_ts = std::min(sec * 1e9 + begin_event_ts, lastest_event_ts); post_last_event = (first_event_ts == lastest_event_ts); - emit seekedTo((current_event_ts - begin_event_ts) / 1e9); + seekedTo((current_event_ts - begin_event_ts) / 1e9); } void LiveStream::pause(bool pause) { paused_ = pause; - emit(pause ? paused() : resume()); + pause ? paused() : resume(); } diff --git a/openpilot/tools/cabana/streams/livestream.h b/openpilot/tools/cabana/streams/livestream.h index 5d65b1743f..22587335b0 100644 --- a/openpilot/tools/cabana/streams/livestream.h +++ b/openpilot/tools/cabana/streams/livestream.h @@ -9,10 +9,8 @@ #include "tools/cabana/streams/abstractstream.h" class LiveStream : public AbstractStream { - Q_OBJECT - public: - LiveStream(QObject *parent); + LiveStream(); virtual ~LiveStream(); void start() override; void stop(); diff --git a/openpilot/tools/cabana/streams/pandastream.cc b/openpilot/tools/cabana/streams/pandastream.cc index 7ccb18a756..06c8e6e19c 100644 --- a/openpilot/tools/cabana/streams/pandastream.cc +++ b/openpilot/tools/cabana/streams/pandastream.cc @@ -10,7 +10,7 @@ #include #include -PandaStream::PandaStream(QObject *parent, PandaStreamConfig config_) : config(config_), LiveStream(parent) { +PandaStream::PandaStream(PandaStreamConfig config_) : config(config_) { if (!connect()) { throw std::runtime_error("Failed to connect to panda"); } @@ -181,7 +181,7 @@ void OpenPandaWidget::buildConfigForm() { AbstractStream *OpenPandaWidget::open() { try { - return new PandaStream(qApp, config); + return new PandaStream(config); } catch (std::exception &e) { QMessageBox::warning(nullptr, tr("Warning"), tr("Failed to connect to panda: '%1'").arg(e.what())); return nullptr; diff --git a/openpilot/tools/cabana/streams/pandastream.h b/openpilot/tools/cabana/streams/pandastream.h index f8847f65e5..d764613fe1 100644 --- a/openpilot/tools/cabana/streams/pandastream.h +++ b/openpilot/tools/cabana/streams/pandastream.h @@ -24,9 +24,8 @@ struct PandaStreamConfig { }; class PandaStream : public LiveStream { - Q_OBJECT public: - PandaStream(QObject *parent, PandaStreamConfig config_ = {}); + PandaStream(PandaStreamConfig config_ = {}); ~PandaStream() { stop(); } inline std::string routeName() const override { return "Panda: " + config.serial; diff --git a/openpilot/tools/cabana/streams/replaystream.cc b/openpilot/tools/cabana/streams/replaystream.cc index 6d54369aef..68f4fb3056 100644 --- a/openpilot/tools/cabana/streams/replaystream.cc +++ b/openpilot/tools/cabana/streams/replaystream.cc @@ -12,17 +12,21 @@ #include "common/util.h" #include "tools/cabana/streams/routes.h" -ReplayStream::ReplayStream(QObject *parent) : AbstractStream(parent) { +ReplayStream::ReplayStream() { unsetenv("ZMQ"); setenv("COMMA_CACHE", "/tmp/comma_download_cache", 1); op_prefix = std::make_unique(); - QObject::connect(&settings, &Settings::changed, this, [this]() { + settings_connection_ = settings.changed.connect([this]() { if (replay) replay->setSegmentCacheLimit(settings.max_cached_minutes); }); } +ReplayStream::~ReplayStream() { + cancelWaits(); +} + void ReplayStream::mergeSegments() { auto event_data = replay->getEventData(); for (const auto &[n, seg] : event_data->segments) { @@ -51,14 +55,14 @@ bool ReplayStream::loadRoute(const std::string &route, const std::string &data_d replay->setSegmentCacheLimit(settings.max_cached_minutes); replay->installEventFilter([this](const Event *event) { return eventFilter(event); }); - // Forward replay callbacks to corresponding Qt signals. - replay->onSeeking = [this](double sec) { emit seeking(sec); }; + // replay callbacks arrive on replay threads + replay->onSeeking = [this](double sec) { postToMainThread([this, sec]() { seeking(sec); }); }; replay->onSeekedTo = [this](double sec) { - emit seekedTo(sec); + postToMainThread([this, sec]() { seekedTo(sec); }); waitForSeekFinshed(); }; - replay->onQLogLoaded = [this](std::shared_ptr qlog) { emit qLogLoaded(qlog); }; - replay->onSegmentsMerged = [this]() { QMetaObject::invokeMethod(this, &ReplayStream::mergeSegments, Qt::BlockingQueuedConnection); }; + replay->onQLogLoaded = [this](std::shared_ptr qlog) { postToMainThread([this, qlog]() { qLogLoaded(qlog); }); }; + replay->onSegmentsMerged = [this]() { postToMainThreadAndWait([this]() { mergeSegments(); }); }; bool success = replay->load(); if (!success) { @@ -70,18 +74,18 @@ bool ReplayStream::loadRoute(const std::string &route, const std::string &data_d "python3 openpilot/tools/lib/auth.py\n\n" "This will grant access to routes from your comma account."; } else { - message = tr("Access Denied. You do not have permission to access route:\n\n%1\n\n" - "This is likely a private route.").arg(QString::fromStdString(route)); + message = QString("Access Denied. You do not have permission to access route:\n\n%1\n\n" + "This is likely a private route.").arg(QString::fromStdString(route)); } - QMessageBox::warning(nullptr, tr("Access Denied"), message); + QMessageBox::warning(nullptr, "Access Denied", message); } else if (replay->lastRouteError() == RouteLoadError::NetworkError) { - QMessageBox::warning(nullptr, tr("Network Error"), - tr("Unable to load the route:\n\n %1.\n\nPlease check your network connection and try again.").arg(QString::fromStdString(route))); + QMessageBox::warning(nullptr, "Network Error", + QString("Unable to load the route:\n\n %1.\n\nPlease check your network connection and try again.").arg(QString::fromStdString(route))); } else if (replay->lastRouteError() == RouteLoadError::FileNotFound) { - QMessageBox::warning(nullptr, tr("Route Not Found"), - tr("The specified route could not be found:\n\n %1.\n\nPlease check the route name and try again.").arg(QString::fromStdString(route))); + QMessageBox::warning(nullptr, "Route Not Found", + QString("The specified route could not be found:\n\n %1.\n\nPlease check the route name and try again.").arg(QString::fromStdString(route))); } else { - QMessageBox::warning(nullptr, tr("Route Load Failed"), tr("Failed to load route: '%1'").arg(QString::fromStdString(route))); + QMessageBox::warning(nullptr, "Route Load Failed", QString("Failed to load route: '%1'").arg(QString::fromStdString(route))); } } return success; @@ -102,7 +106,7 @@ bool ReplayStream::eventFilter(const Event *event) { double ts = millis_since_boot(); if ((ts - prev_update_ts) > (1000.0 / settings.fps)) { - emit privateUpdateLastMsgsSignal(); + requestUpdateLastMessages(); prev_update_ts = ts; } return true; @@ -110,7 +114,7 @@ bool ReplayStream::eventFilter(const Event *event) { void ReplayStream::pause(bool pause) { replay->pause(pause); - emit(pause ? paused() : resume()); + pause ? paused() : resume(); } @@ -161,7 +165,7 @@ AbstractStream *OpenReplayWidget::open() { if (!is_valid_format) { QMessageBox::warning(nullptr, tr("Warning"), tr("Invalid route format: '%1'").arg(route)); } else { - auto replay_stream = std::make_unique(qApp); + auto replay_stream = std::make_unique(); uint32_t flags = REPLAY_FLAG_NONE; if (cameras[1]->isChecked()) flags |= REPLAY_FLAG_CABIN_CAMERA; if (cameras[2]->isChecked()) flags |= REPLAY_FLAG_WIDE_ROAD; diff --git a/openpilot/tools/cabana/streams/replaystream.h b/openpilot/tools/cabana/streams/replaystream.h index eecd345715..6f49502ba3 100644 --- a/openpilot/tools/cabana/streams/replaystream.h +++ b/openpilot/tools/cabana/streams/replaystream.h @@ -10,13 +10,10 @@ #include "tools/cabana/streams/abstractstream.h" #include "tools/replay/replay.h" -Q_DECLARE_METATYPE(std::shared_ptr); - class ReplayStream : public AbstractStream { - Q_OBJECT - public: - ReplayStream(QObject *parent); + ReplayStream(); + ~ReplayStream(); void start() override { replay->start(); } bool loadRoute(const std::string &route, const std::string &data_dir, uint32_t replay_flags = REPLAY_FLAG_NONE, bool auto_source = false); bool eventFilter(const Event *event); @@ -36,12 +33,13 @@ public: inline bool isPaused() const override { return replay->isPaused(); } void pause(bool pause) override; -signals: - void qLogLoaded(std::shared_ptr qlog); + // invoked on the main thread + Observable> qLogLoaded; private: void mergeSegments(); std::unique_ptr replay = nullptr; + Connection settings_connection_; std::set processed_segments; std::unique_ptr op_prefix; }; diff --git a/openpilot/tools/cabana/streams/socketcanstream.cc b/openpilot/tools/cabana/streams/socketcanstream.cc index b616e7f242..b10f2cab90 100644 --- a/openpilot/tools/cabana/streams/socketcanstream.cc +++ b/openpilot/tools/cabana/streams/socketcanstream.cc @@ -16,7 +16,7 @@ #include #include -SocketCanStream::SocketCanStream(QObject *parent, SocketCanStreamConfig config_) : config(config_), LiveStream(parent) { +SocketCanStream::SocketCanStream(SocketCanStreamConfig config_) : config(config_) { if (!available()) { throw std::runtime_error("SocketCAN not available"); } @@ -140,7 +140,7 @@ void OpenSocketCanWidget::refreshDevices() { AbstractStream *OpenSocketCanWidget::open() { try { - return new SocketCanStream(qApp, config); + return new SocketCanStream(config); } catch (std::exception &e) { QMessageBox::warning(nullptr, tr("Warning"), tr("Failed to connect to SocketCAN device: '%1'").arg(e.what())); return nullptr; diff --git a/openpilot/tools/cabana/streams/socketcanstream.h b/openpilot/tools/cabana/streams/socketcanstream.h index 3c5cd184f7..78e856d506 100644 --- a/openpilot/tools/cabana/streams/socketcanstream.h +++ b/openpilot/tools/cabana/streams/socketcanstream.h @@ -9,9 +9,8 @@ struct SocketCanStreamConfig { }; class SocketCanStream : public LiveStream { - Q_OBJECT public: - SocketCanStream(QObject *parent, SocketCanStreamConfig config_ = {}); + SocketCanStream(SocketCanStreamConfig config_ = {}); ~SocketCanStream(); static bool available(); diff --git a/openpilot/tools/cabana/tests/test_cabana.cc b/openpilot/tools/cabana/tests/test_cabana.cc index 53be1b0afa..c2ebc2e6b0 100644 --- a/openpilot/tools/cabana/tests/test_cabana.cc +++ b/openpilot/tools/cabana/tests/test_cabana.cc @@ -164,11 +164,10 @@ void test_dbc_manager() { int files_changed = 0; int signals_added = 0; int masks_updated = 0; - manager.setCallbacks({ - .signal_added = [&](MessageId, const cabana::Signal *) { ++signals_added; }, - .file_changed = [&]() { ++files_changed; }, - .mask_updated = [&]() { ++masks_updated; }, - }); + Connections connections; + connections.push_back(manager.signalAdded.connect([&](MessageId, const cabana::Signal *) { ++signals_added; })); + connections.push_back(manager.fileChanged.connect([&]() { ++files_changed; })); + connections.push_back(manager.maskUpdated.connect([&]() { ++masks_updated; })); std::string error; REQUIRE(manager.open(SOURCE_ALL, "test", "BO_ 160 message: 8 XXX\n", &error)); diff --git a/openpilot/tools/cabana/tools/routeinfo.cc b/openpilot/tools/cabana/tools/routeinfo.cc index dc272e3d12..1037d4a206 100644 --- a/openpilot/tools/cabana/tools/routeinfo.cc +++ b/openpilot/tools/cabana/tools/routeinfo.cc @@ -6,7 +6,7 @@ #include "tools/cabana/streams/replaystream.h" RouteInfoDlg::RouteInfoDlg(QWidget *parent) : QDialog(parent) { - auto *replay = qobject_cast(can)->getReplay(); + auto *replay = dynamic_cast(can)->getReplay(); setWindowTitle(tr("Route: %1").arg(QString::fromStdString(replay->route().name()))); auto *table = new QTableWidget(replay->route().segments().size(), 7, this); diff --git a/openpilot/tools/cabana/utils/util.cc b/openpilot/tools/cabana/utils/util.cc index 87a0f89427..9a26e5f977 100644 --- a/openpilot/tools/cabana/utils/util.cc +++ b/openpilot/tools/cabana/utils/util.cc @@ -11,7 +11,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -23,6 +25,30 @@ #include #include "common/util.h" +static const std::thread::id main_thread_id = std::this_thread::get_id(); +static std::mutex main_thread_queue_mutex; +static std::vector> main_thread_queue; + +bool utils::isMainThread() { return std::this_thread::get_id() == main_thread_id; } + +void utils::runOnMainThread(std::function fn) { + if (isMainThread()) { + fn(); + } else { + std::lock_guard lk(main_thread_queue_mutex); + main_thread_queue.push_back(std::move(fn)); + } +} + +void utils::drainMainThreadQueue() { + std::vector> fns; + { + std::lock_guard lk(main_thread_queue_mutex); + fns.swap(main_thread_queue); + } + for (auto &fn : fns) fn(); +} + // SegmentTree void SegmentTree::build(const std::vector &arr) { diff --git a/openpilot/tools/cabana/utils/util.h b/openpilot/tools/cabana/utils/util.h index 5a3c62d118..cbcb6d14ee 100644 --- a/openpilot/tools/cabana/utils/util.h +++ b/openpilot/tools/cabana/utils/util.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -20,9 +21,14 @@ #include #include +#include "tools/cabana/core/observable.h" #include "tools/cabana/dbc/dbc.h" #include "tools/cabana/settings.h" +// needed by QVariant::fromValue() in the Qt views; goes away with QVariant +Q_DECLARE_METATYPE(MessageId) +Q_DECLARE_METATYPE(ValueDescription) + inline QColor toQColor(const CabanaColor &color) { return QColor(color.r, color.g, color.b, color.a); } @@ -132,6 +138,10 @@ public: namespace utils { +bool isMainThread(); +// inline on the main thread, queued until drainMainThreadQueue() otherwise +void runOnMainThread(std::function fn); +void drainMainThreadQueue(); QPixmap icon(const QString &id); std::string homePath(); std::filesystem::path configPath(); @@ -175,7 +185,7 @@ public: const int metric = QApplication::style()->pixelMetric(QStyle::PM_SmallIconSize); setIconSize({metric, metric}); theme = settings.theme; - connect(&settings, &Settings::changed, this, &ToolButton::updateIcon); + settings_connection_ = settings.changed.connect([this]() { updateIcon(); }); } void setIcon(const QString &icon) { icon_str = icon; @@ -184,6 +194,7 @@ public: private: void updateIcon() { if (std::exchange(theme, settings.theme) != theme) setIcon(icon_str); } + Connection settings_connection_; QString icon_str; int theme; }; diff --git a/openpilot/tools/cabana/videowidget.cc b/openpilot/tools/cabana/videowidget.cc index bd61573658..b1e84586a8 100644 --- a/openpilot/tools/cabana/videowidget.cc +++ b/openpilot/tools/cabana/videowidget.cc @@ -27,7 +27,7 @@ static const QColor timeline_colors[] = { }; static Replay *getReplay() { - auto stream = qobject_cast(can); + auto stream = dynamic_cast(can); return stream ? stream->getReplay() : nullptr; } @@ -42,11 +42,11 @@ VideoWidget::VideoWidget(QWidget *parent) : QFrame(parent) { createPlaybackController(); setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Maximum); - QObject::connect(can, &AbstractStream::paused, this, &VideoWidget::updatePlayBtnState); - QObject::connect(can, &AbstractStream::resume, this, &VideoWidget::updatePlayBtnState); - QObject::connect(can, &AbstractStream::msgsReceived, this, &VideoWidget::updateState); - QObject::connect(can, &AbstractStream::seeking, this, &VideoWidget::updateState); - QObject::connect(can, &AbstractStream::timeRangeChanged, this, &VideoWidget::timeRangeChanged); + connections_.push_back(can->paused.connect([this]() { updatePlayBtnState(); })); + connections_.push_back(can->resume.connect([this]() { updatePlayBtnState(); })); + connections_.push_back(can->msgsReceived.connect([this](const std::set *, bool) { updateState(); })); + connections_.push_back(can->seeking.connect([this](double) { updateState(); })); + connections_.push_back(can->timeRangeChanged.connect([this](const auto &) { timeRangeChanged(); })); updatePlayBtnState(); setWhatsThis(tr(R"( @@ -157,14 +157,14 @@ QWidget *VideoWidget::createCameraWidget() { slider->setTimeRange(can->minSeconds(), can->maxSeconds()); QObject::connect(slider, &QSlider::sliderReleased, [this]() { can->seekTo(slider->currentSecond()); }); - QObject::connect(can, &AbstractStream::paused, cam_widget, qOverload<>(&StreamCameraView::update)); - QObject::connect(can, &AbstractStream::eventsMerged, this, [this]() { slider->update(); }); - QObject::connect(cam_widget, &CameraWidget::clicked, []() { can->pause(!can->isPaused()); }); - QObject::connect(cam_widget, &CameraWidget::vipcAvailableStreamsUpdated, this, &VideoWidget::vipcAvailableStreamsUpdated); + connections_.push_back(can->paused.connect([this]() { cam_widget->update(); })); + connections_.push_back(can->eventsMerged.connect([this](const MessageEventsMap &) { slider->update(); })); + connections_.push_back(cam_widget->clicked.connect([]() { can->pause(!can->isPaused()); })); + connections_.push_back(cam_widget->availableStreamsUpdated.connect([this](std::set streams) { vipcAvailableStreamsUpdated(streams); })); QObject::connect(camera_tab, &QTabBar::currentChanged, [this](int index) { if (index != -1) cam_widget->setStreamType((VisionStreamType)camera_tab->tabData(index).toInt()); }); - QObject::connect(static_cast(can), &ReplayStream::qLogLoaded, cam_widget, &StreamCameraView::parseQLog, Qt::QueuedConnection); + connections_.push_back(static_cast(can)->qLogLoaded.connect([this](std::shared_ptr qlog) { cam_widget->parseQLog(qlog); })); slider->installEventFilter(this); return w; } diff --git a/openpilot/tools/cabana/videowidget.h b/openpilot/tools/cabana/videowidget.h index 09f7a9931b..1b6d81406e 100644 --- a/openpilot/tools/cabana/videowidget.h +++ b/openpilot/tools/cabana/videowidget.h @@ -64,6 +64,7 @@ protected: void timeRangeChanged(); void updateState(); void updatePlayBtnState(); + Connections connections_; QWidget *createCameraWidget(); void createPlaybackController(); void createSpeedDropdown(QToolBar *toolbar); From 7cc48b5bc94e197bfce1ee88d865d16488d8c286 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Thu, 27 Aug 2026 22:00:24 -0700 Subject: [PATCH 31/67] cabana: move RoutesDialog out of streams/ (#38716) --- openpilot/tools/cabana/SConscript | 2 +- .../cabana/{streams/routes.cc => routesdialog.cc} | 12 ++++++------ .../cabana/{streams/routes.h => routesdialog.h} | 0 openpilot/tools/cabana/streams/replaystream.cc | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) rename openpilot/tools/cabana/{streams/routes.cc => routesdialog.cc} (94%) rename openpilot/tools/cabana/{streams/routes.h => routesdialog.h} (100%) diff --git a/openpilot/tools/cabana/SConscript b/openpilot/tools/cabana/SConscript index bc366ab6fb..9c34de661d 100644 --- a/openpilot/tools/cabana/SConscript +++ b/openpilot/tools/cabana/SConscript @@ -99,7 +99,7 @@ cabana_env.Command(assets, "assets/assets.qrc", f"rcc $SOURCES -o $TARGET") cabana_env.Depends(assets, Glob('/assets/*', exclude=[assets, "assets/assets.o"])) cabana_srcs = ['mainwin.cc', 'streams/pandastream.cc', 'streams/devicestream.cc', 'streams/livestream.cc', 'streams/abstractstream.cc', 'streams/replaystream.cc', 'binaryview.cc', 'historylog.cc', 'videowidget.cc', 'signalview.cc', - 'streams/routes.cc', 'dbc/dbc.cc', 'dbc/dbcfile.cc', 'dbc/dbcmanager.cc', + 'routesdialog.cc', 'dbc/dbc.cc', 'dbc/dbcfile.cc', 'dbc/dbcmanager.cc', 'utils/export.cc', 'utils/util.cc', 'utils/elidedlabel.cc', 'chart/chartswidget.cc', 'chart/chart.cc', 'chart/signalselector.cc', 'chart/tiplabel.cc', 'chart/sparkline.cc', 'commands.cc', 'messageswidget.cc', 'streamselector.cc', 'settings.cc', 'panda.cc', diff --git a/openpilot/tools/cabana/streams/routes.cc b/openpilot/tools/cabana/routesdialog.cc similarity index 94% rename from openpilot/tools/cabana/streams/routes.cc rename to openpilot/tools/cabana/routesdialog.cc index b6f98da533..35e23a6eee 100644 --- a/openpilot/tools/cabana/streams/routes.cc +++ b/openpilot/tools/cabana/routesdialog.cc @@ -1,4 +1,4 @@ -#include "tools/cabana/streams/routes.h" +#include "tools/cabana/routesdialog.h" #include #include @@ -6,7 +6,6 @@ #include #include -#include #include #include #include @@ -14,6 +13,7 @@ #include #include "json11/json11.hpp" +#include "tools/cabana/utils/util.h" #include "tools/replay/py_downloader.h" namespace { @@ -113,9 +113,9 @@ RoutesDialog::RoutesDialog(QWidget *parent) : QDialog(parent) { // Fetch devices std::thread([this, alive = std::weak_ptr(alive_)]() { std::string result = PyDownloader::getDevices(); - QMetaObject::invokeMethod(qApp, [this, alive, r = QString::fromStdString(result), response = checkApiResponse(result)]() { + utils::runOnMainThread([this, alive, r = QString::fromStdString(result), response = checkApiResponse(result)]() { if (!alive.expired()) parseDeviceList(r, response.first, response.second); - }, Qt::QueuedConnection); + }); }).detach(); } @@ -156,9 +156,9 @@ void RoutesDialog::fetchRoutes() { int request_id = ++fetch_id_; std::thread([this, alive = std::weak_ptr(alive_), did, start_ms, end_ms, preserved, request_id]() { std::string result = PyDownloader::getDeviceRoutes(did, start_ms, end_ms, preserved); - QMetaObject::invokeMethod(qApp, [this, alive, r = QString::fromStdString(result), response = checkApiResponse(result), request_id]() { + utils::runOnMainThread([this, alive, r = QString::fromStdString(result), response = checkApiResponse(result), request_id]() { if (!alive.expired() && fetch_id_ == request_id) parseRouteList(r, response.first, response.second); - }, Qt::QueuedConnection); + }); }).detach(); } diff --git a/openpilot/tools/cabana/streams/routes.h b/openpilot/tools/cabana/routesdialog.h similarity index 100% rename from openpilot/tools/cabana/streams/routes.h rename to openpilot/tools/cabana/routesdialog.h diff --git a/openpilot/tools/cabana/streams/replaystream.cc b/openpilot/tools/cabana/streams/replaystream.cc index 68f4fb3056..56e4bfa084 100644 --- a/openpilot/tools/cabana/streams/replaystream.cc +++ b/openpilot/tools/cabana/streams/replaystream.cc @@ -10,7 +10,7 @@ #include "common/timing.h" #include "common/util.h" -#include "tools/cabana/streams/routes.h" +#include "tools/cabana/routesdialog.h" ReplayStream::ReplayStream() { unsetenv("ZMQ"); From 9b9e3ea6048a72948922d0e58dcdbbcd876cd658 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Fri, 28 Aug 2026 07:25:11 -0700 Subject: [PATCH 32/67] cabana: string helpers in utils return std::string (#38720) --- openpilot/tools/cabana/SConscript | 3 +- openpilot/tools/cabana/binaryview.cc | 2 +- openpilot/tools/cabana/chart/chartswidget.cc | 2 +- openpilot/tools/cabana/messageswidget.cc | 6 +- openpilot/tools/cabana/signalview.cc | 4 +- openpilot/tools/cabana/tests/test_cabana.cc | 58 ++++++++++++++++++++ openpilot/tools/cabana/utils/strings.cc | 57 +++++++++++++++++++ openpilot/tools/cabana/utils/strings.h | 33 +++++++++++ openpilot/tools/cabana/utils/util.cc | 42 -------------- openpilot/tools/cabana/utils/util.h | 15 +---- openpilot/tools/cabana/videowidget.cc | 2 +- 11 files changed, 159 insertions(+), 65 deletions(-) create mode 100644 openpilot/tools/cabana/utils/strings.cc create mode 100644 openpilot/tools/cabana/utils/strings.h diff --git a/openpilot/tools/cabana/SConscript b/openpilot/tools/cabana/SConscript index 9c34de661d..662b3952bb 100644 --- a/openpilot/tools/cabana/SConscript +++ b/openpilot/tools/cabana/SConscript @@ -100,7 +100,7 @@ cabana_env.Depends(assets, Glob('/assets/*', exclude=[assets, "assets/assets.o"] cabana_srcs = ['mainwin.cc', 'streams/pandastream.cc', 'streams/devicestream.cc', 'streams/livestream.cc', 'streams/abstractstream.cc', 'streams/replaystream.cc', 'binaryview.cc', 'historylog.cc', 'videowidget.cc', 'signalview.cc', 'routesdialog.cc', 'dbc/dbc.cc', 'dbc/dbcfile.cc', 'dbc/dbcmanager.cc', - 'utils/export.cc', 'utils/util.cc', 'utils/elidedlabel.cc', + 'utils/export.cc', 'utils/util.cc', 'utils/strings.cc', 'utils/elidedlabel.cc', 'chart/chartswidget.cc', 'chart/chart.cc', 'chart/signalselector.cc', 'chart/tiplabel.cc', 'chart/sparkline.cc', 'commands.cc', 'messageswidget.cc', 'streamselector.cc', 'settings.cc', 'panda.cc', 'cameraview.cc', 'detailwidget.cc', 'tools/findsimilarbits.cc', 'tools/findsignal.cc', 'tools/routeinfo.cc'] @@ -119,6 +119,7 @@ if GetOption('extras'): dbc_core_test_env.Object('tests/dbc_core_model', 'dbc/dbc.cc'), dbc_core_test_env.Object('tests/dbc_core_file', 'dbc/dbcfile.cc'), dbc_core_test_env.Object('tests/dbc_core_manager', 'dbc/dbcmanager.cc'), + dbc_core_test_env.Object('tests/dbc_core_strings', 'utils/strings.cc'), ] dbc_core_test_env.Program('tests/test_dbc_core', dbc_core_test_objects) diff --git a/openpilot/tools/cabana/binaryview.cc b/openpilot/tools/cabana/binaryview.cc index 2f0167107c..160aead6fd 100644 --- a/openpilot/tools/cabana/binaryview.cc +++ b/openpilot/tools/cabana/binaryview.cc @@ -385,7 +385,7 @@ QVariant BinaryViewModel::headerData(int section, Qt::Orientation orientation, i QVariant BinaryViewModel::data(const QModelIndex &index, int role) const { auto item = (const BinaryViewModel::Item *)index.internalPointer(); - return role == Qt::ToolTipRole && item && !item->sigs.empty() ? signalToolTip(item->sigs.back()) : QVariant(); + return role == Qt::ToolTipRole && item && !item->sigs.empty() ? QString::fromStdString(utils::signalToolTip(item->sigs.back())) : QVariant(); } // BinaryItemDelegate diff --git a/openpilot/tools/cabana/chart/chartswidget.cc b/openpilot/tools/cabana/chart/chartswidget.cc index 8aa095b65f..2e3e86bab0 100644 --- a/openpilot/tools/cabana/chart/chartswidget.cc +++ b/openpilot/tools/cabana/chart/chartswidget.cc @@ -245,7 +245,7 @@ void ChartsWidget::setIsDocked(bool docked) { void ChartsWidget::updateToolBar() { title_label->setText(tr("Charts: %1").arg(charts.size())); columns_action->setText(tr("Columns: %1").arg(column_count)); - range_lb->setText(utils::formatSeconds(max_chart_range)); + range_lb->setText(QString::fromStdString(utils::formatSeconds(max_chart_range))); bool is_zoomed = can->timeRange().has_value(); range_lb_action->setVisible(!is_zoomed); diff --git a/openpilot/tools/cabana/messageswidget.cc b/openpilot/tools/cabana/messageswidget.cc index eea07b8711..2325bb3ae4 100644 --- a/openpilot/tools/cabana/messageswidget.cc +++ b/openpilot/tools/cabana/messageswidget.cc @@ -195,7 +195,7 @@ QVariant MessageListModel::data(const QModelIndex &index, int role) const { switch (index.column()) { case Column::NAME: return item.name; case Column::SOURCE: return item.id.source != INVALID_SOURCE ? QString::number(item.id.source) : NA; - case Column::ADDRESS: return toHexString(item.id.address); + case Column::ADDRESS: return QString::fromStdString(utils::toHexString(item.id.address)); case Column::NODE: return item.node; case Column::FREQ: return item.id.source != INVALID_SOURCE ? getFreq(can->lastMessage(item.id).freq) : NA; case Column::COUNT: return item.id.source != INVALID_SOURCE ? QString::number(can->lastMessage(item.id).count) : NA; @@ -288,7 +288,7 @@ bool MessageListModel::match(const MessageListModel::Item &item) { match = parseRange(txt, item.id.source); break; case Column::ADDRESS: - match = toHexString(item.id.address).contains(txt, Qt::CaseInsensitive); + match = QString::fromStdString(utils::toHexString(item.id.address)).contains(txt, Qt::CaseInsensitive); match = match || parseRange(txt, item.id.address, 16); break; case Column::NODE: @@ -301,7 +301,7 @@ bool MessageListModel::match(const MessageListModel::Item &item) { match = parseRange(txt, data.count); break; case Column::DATA: - match = utils::toHex(data.dat).contains(txt, Qt::CaseInsensitive); + match = QString::fromStdString(utils::toHex(data.dat)).contains(txt, Qt::CaseInsensitive); break; } } diff --git a/openpilot/tools/cabana/signalview.cc b/openpilot/tools/cabana/signalview.cc index 250d05e3b2..3a6d4dab25 100644 --- a/openpilot/tools/cabana/signalview.cc +++ b/openpilot/tools/cabana/signalview.cc @@ -153,7 +153,7 @@ QVariant SignalModel::data(const QModelIndex &index, int role) const { if (item->type == Item::Endian) return item->sig->is_little_endian ? Qt::Checked : Qt::Unchecked; if (item->type == Item::Signed) return item->sig->is_signed ? Qt::Checked : Qt::Unchecked; } else if (role == Qt::ToolTipRole && item->type == Item::Sig) { - return (index.column() == 0) ? signalToolTip(item->sig) : QString(); + return (index.column() == 0) ? QString::fromStdString(utils::signalToolTip(item->sig)) : QString(); } } return {}; @@ -570,7 +570,7 @@ void SignalView::signalHovered(const cabana::Signal *sig) { void SignalView::updateToolBar() { signal_count_lb->setText(tr("Signals: %1").arg(model->rowCount())); - sparkline_label->setText(utils::formatSeconds(settings.sparkline_range)); + sparkline_label->setText(QString::fromStdString(utils::formatSeconds(settings.sparkline_range))); } void SignalView::setSparklineRange(int value) { diff --git a/openpilot/tools/cabana/tests/test_cabana.cc b/openpilot/tools/cabana/tests/test_cabana.cc index c2ebc2e6b0..e3b8377e02 100644 --- a/openpilot/tools/cabana/tests/test_cabana.cc +++ b/openpilot/tools/cabana/tests/test_cabana.cc @@ -1,10 +1,13 @@ +#include +#include #include #include #include "common/tests/native_test.h" #include "tools/cabana/dbc/dbcfile.h" #include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/utils/strings.h" const std::string TEST_RLOG_URL = "https://commadataci.blob.core.windows.net/openpilotci/0c94aa1e1296d7c6/2021-05-05--19-48-37/0/rlog.bz2"; @@ -185,7 +188,62 @@ void test_dbc_manager() { REQUIRE(manager.msg({.source = 0, .address = 160})->sig("speed") != nullptr); } +void test_format_seconds() { + REQUIRE(utils::formatSeconds(0) == "00:00"); + REQUIRE(utils::formatSeconds(59.4) == "00:59"); + REQUIRE(utils::formatSeconds(-1) == "00:00"); + REQUIRE(utils::formatSeconds(61.234, true) == "01:01.234"); + REQUIRE(utils::formatSeconds(3599.9) == "59:59"); + REQUIRE(utils::formatSeconds(3601) == "01:00:01"); + REQUIRE(utils::formatSeconds(3601.5, true) == "01:00:01.500"); + + const char *tz = getenv("TZ"); + const bool had_tz = tz != nullptr; + const std::string saved_tz = had_tz ? tz : ""; + setenv("TZ", "UTC", 1); + tzset(); + REQUIRE(utils::formatSeconds(0, false, true) == "1970-01-01 00:00:00"); + REQUIRE(utils::formatSeconds(1700000000.123, true, true) == "2023-11-14 22:13:20.123"); + if (had_tz) { + setenv("TZ", saved_tz.c_str(), 1); + } else { + unsetenv("TZ"); + } + tzset(); +} + +void test_to_hex() { + REQUIRE(utils::toHex({}) == ""); + REQUIRE(utils::toHex({0x00, 0x0f, 0xab, 0xff}) == "000FABFF"); + REQUIRE(utils::toHex({0x01, 0x02, 0x03}, ' ') == "01 02 03"); + + REQUIRE(utils::toHexString(0) == "0x00"); + REQUIRE(utils::toHexString(0xf) == "0x0F"); + REQUIRE(utils::toHexString(0x1ab) == "0x1AB"); + REQUIRE(utils::toHexString(0x1fffffff) == "0x1FFFFFFF"); +} + +void test_signal_tooltip() { + cabana::Signal sig{}; + sig.name = "speed"; + sig.start_bit = 3; + sig.size = 12; + sig.msb = 14; + sig.lsb = 3; + sig.is_little_endian = true; + sig.is_signed = false; + REQUIRE(utils::signalToolTip(&sig) == R"( + speed
+ Start Bit: 3 Size: 12
+ MSB: 14 LSB: 3
+ Little Endian: Y Signed: N
+ )"); +} + void test_cabana_core() { + test_format_seconds(); + test_to_hex(); + test_signal_tooltip(); test_generate_dbc(); test_comment_order(); test_preserve_original_header(); diff --git a/openpilot/tools/cabana/utils/strings.cc b/openpilot/tools/cabana/utils/strings.cc new file mode 100644 index 0000000000..5590b6078e --- /dev/null +++ b/openpilot/tools/cabana/utils/strings.cc @@ -0,0 +1,57 @@ +#include "tools/cabana/utils/strings.h" + +#include +#include +#include +#include + +#include "tools/cabana/dbc/dbc.h" + +namespace utils { + +std::string formatSeconds(double sec, bool include_milliseconds, bool absolute_time) { + char out[80] = {}; + if (absolute_time) { + const auto ms_total = static_cast(std::llround(sec * 1000.0)); + const std::time_t secs = static_cast(ms_total / 1000); + int millis = static_cast(ms_total % 1000); + if (millis < 0) millis = -millis; + std::tm tm{}; + localtime_r(&secs, &tm); + char buf[64] = {}; + std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm); + if (!include_milliseconds) return buf; + snprintf(out, sizeof(out), "%s.%03d", buf, millis); + return out; + } + + // Relative duration (not wall-clock). + const bool show_hours = sec > 60 * 60; + int total_ms = static_cast(std::llround(std::max(0.0, sec) * 1000.0)); + const int hours = total_ms / (3600 * 1000); + const int minutes = (total_ms / (60 * 1000)) % 60; + const int seconds = (total_ms / 1000) % 60; + const int millis = total_ms % 1000; + if (show_hours && include_milliseconds) { + snprintf(out, sizeof(out), "%02d:%02d:%02d.%03d", hours, minutes, seconds, millis); + } else if (show_hours) { + snprintf(out, sizeof(out), "%02d:%02d:%02d", hours, minutes, seconds); + } else if (include_milliseconds) { + snprintf(out, sizeof(out), "%02d:%02d.%03d", minutes, seconds, millis); + } else { + snprintf(out, sizeof(out), "%02d:%02d", minutes, seconds); + } + return out; +} + +std::string signalToolTip(const cabana::Signal *sig) { + std::ostringstream s; + s << "\n " << sig->name << "
\n" + << " Start Bit: " << sig->start_bit << " Size: " << sig->size << "
\n" + << " MSB: " << sig->msb << " LSB: " << sig->lsb << "
\n" + << " Little Endian: " << (sig->is_little_endian ? "Y" : "N") + << " Signed: " << (sig->is_signed ? "Y" : "N") << "
\n "; + return s.str(); +} + +} // namespace utils diff --git a/openpilot/tools/cabana/utils/strings.h b/openpilot/tools/cabana/utils/strings.h new file mode 100644 index 0000000000..f6581c3164 --- /dev/null +++ b/openpilot/tools/cabana/utils/strings.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include +#include + +namespace cabana { class Signal; } + +namespace utils { + +std::string formatSeconds(double sec, bool include_milliseconds = false, bool absolute_time = false); +std::string signalToolTip(const cabana::Signal *sig); + +inline std::string toHex(const std::vector &dat, char separator = '\0') { + static const char digits[] = "0123456789ABCDEF"; + std::string hex; + hex.reserve(dat.size() * (separator ? 3 : 2)); + for (size_t i = 0; i < dat.size(); ++i) { + if (separator && i) hex += separator; + hex += digits[dat[i] >> 4]; + hex += digits[dat[i] & 0xf]; + } + return hex; +} + +inline std::string toHexString(int value) { + char buf[16] = {}; + snprintf(buf, sizeof(buf), "0x%02X", value); + return buf; +} + +} // namespace utils diff --git a/openpilot/tools/cabana/utils/util.cc b/openpilot/tools/cabana/utils/util.cc index 9a26e5f977..9a44ef5054 100644 --- a/openpilot/tools/cabana/utils/util.cc +++ b/openpilot/tools/cabana/utils/util.cc @@ -7,7 +7,6 @@ #include #include #include -#include #include #include #include @@ -442,37 +441,6 @@ void setTheme(int theme) { } } -QString formatSeconds(double sec, bool include_milliseconds, bool absolute_time) { - if (absolute_time) { - const auto ms_total = static_cast(std::llround(sec * 1000.0)); - const std::time_t secs = static_cast(ms_total / 1000); - int millis = static_cast(ms_total % 1000); - if (millis < 0) millis = -millis; - std::tm tm{}; - localtime_r(&secs, &tm); - char buf[64]; - std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm); - if (include_milliseconds) { - return QString::asprintf("%s.%03d", buf, millis); - } - return QString::fromUtf8(buf); - } - - // Relative duration (not wall-clock). - const bool show_hours = sec > 60 * 60; - int total_ms = static_cast(std::llround(std::max(0.0, sec) * 1000.0)); - const int hours = total_ms / (3600 * 1000); - const int minutes = (total_ms / (60 * 1000)) % 60; - const int seconds = (total_ms / 1000) % 60; - const int millis = total_ms % 1000; - if (show_hours) { - return include_milliseconds ? QString::asprintf("%02d:%02d:%02d.%03d", hours, minutes, seconds, millis) - : QString::asprintf("%02d:%02d:%02d", hours, minutes, seconds); - } - return include_milliseconds ? QString::asprintf("%02d:%02d.%03d", minutes, seconds, millis) - : QString::asprintf("%02d:%02d", minutes, seconds); -} - } // namespace utils int num_decimals(double num) { @@ -481,16 +449,6 @@ int num_decimals(double num) { return dot_pos == -1 ? 0 : string.size() - dot_pos - 1; } -QString signalToolTip(const cabana::Signal *sig) { - return QObject::tr(R"( - %1
- Start Bit: %2 Size: %3
- MSB: %4 LSB: %5
- Little Endian: %6 Signed: %7
- )").arg(QString::fromStdString(sig->name)).arg(sig->start_bit).arg(sig->size).arg(sig->msb).arg(sig->lsb) - .arg(sig->is_little_endian ? "Y" : "N").arg(sig->is_signed ? "Y" : "N"); -} - void sigTermHandler(int s) { std::signal(s, SIG_DFL); qApp->quit(); diff --git a/openpilot/tools/cabana/utils/util.h b/openpilot/tools/cabana/utils/util.h index cbcb6d14ee..f2435143c1 100644 --- a/openpilot/tools/cabana/utils/util.h +++ b/openpilot/tools/cabana/utils/util.h @@ -24,6 +24,7 @@ #include "tools/cabana/core/observable.h" #include "tools/cabana/dbc/dbc.h" #include "tools/cabana/settings.h" +#include "tools/cabana/utils/strings.h" // needed by QVariant::fromValue() in the Qt views; goes away with QVariant Q_DECLARE_METATYPE(MessageId) @@ -149,22 +150,10 @@ bool getClipboardText(std::string *text); // false if no clipboard tool is avai bool setClipboardText(const std::string &text); bool isDarkTheme(); void setTheme(int theme); -QString formatSeconds(double sec, bool include_milliseconds = false, bool absolute_time = false); inline void drawStaticText(QPainter *p, const QRect &r, const QStaticText &text) { auto size = (r.size() - text.size()) / 2; p->drawStaticText(r.left() + size.width(), r.top() + size.height(), text); } -inline QString toHex(const std::vector &dat, char separator = '\0') { - static const char digits[] = "0123456789ABCDEF"; - QString hex; - hex.reserve(dat.size() * (separator ? 3 : 2)); - for (size_t i = 0; i < dat.size(); ++i) { - if (separator && i) hex += QLatin1Char(separator); - hex += QLatin1Char(digits[dat[i] >> 4]); - hex += QLatin1Char(digits[dat[i] & 0xf]); - } - return hex; -} // boundary conversions for the remaining Qt byte-array based state APIs template @@ -225,7 +214,5 @@ private: }; int num_decimals(double num); -QString signalToolTip(const cabana::Signal *sig); -inline QString toHexString(int value) { return QString("0x%1").arg(QString::number(value, 16).toUpper(), 2, '0'); } void initApp(int argc, char *argv[], bool disable_hidpi = true); QPixmap bootstrapPixmap(const QString &id); diff --git a/openpilot/tools/cabana/videowidget.cc b/openpilot/tools/cabana/videowidget.cc index b1e84586a8..23f60f29f1 100644 --- a/openpilot/tools/cabana/videowidget.cc +++ b/openpilot/tools/cabana/videowidget.cc @@ -204,7 +204,7 @@ void VideoWidget::timeRangeChanged() { QString VideoWidget::formatTime(double sec, bool include_milliseconds) { if (settings.absolute_time) sec += std::chrono::duration(can->beginDateTime().time_since_epoch()).count(); - return utils::formatSeconds(sec, include_milliseconds, settings.absolute_time); + return QString::fromStdString(utils::formatSeconds(sec, include_milliseconds, settings.absolute_time)); } void VideoWidget::updateState() { From 30f358eb59c191cb6ca98b4ee79e17e5b9b82b63 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Fri, 28 Aug 2026 07:25:34 -0700 Subject: [PATCH 33/67] cabana: use std::string in RoutesDialog API results (#38717) --- openpilot/tools/cabana/routesdialog.cc | 24 ++++++++++--------- openpilot/tools/cabana/routesdialog.h | 6 ++--- .../tools/cabana/streams/replaystream.cc | 2 +- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/openpilot/tools/cabana/routesdialog.cc b/openpilot/tools/cabana/routesdialog.cc index 35e23a6eee..9891104c9f 100644 --- a/openpilot/tools/cabana/routesdialog.cc +++ b/openpilot/tools/cabana/routesdialog.cc @@ -58,13 +58,13 @@ int64_t parseIsoToUnixMs(const std::string &s) { return static_cast(secs) * 1000 + millis; } -QString formatUnixMs(int64_t ms) { +std::string formatUnixMs(int64_t ms) { time_t secs = static_cast(ms / 1000); std::tm tm{}; localtime_r(&secs, &tm); char buf[64]; std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm); - return QString::fromUtf8(buf); + return buf; } } // namespace @@ -113,17 +113,18 @@ RoutesDialog::RoutesDialog(QWidget *parent) : QDialog(parent) { // Fetch devices std::thread([this, alive = std::weak_ptr(alive_)]() { std::string result = PyDownloader::getDevices(); - utils::runOnMainThread([this, alive, r = QString::fromStdString(result), response = checkApiResponse(result)]() { + auto response = checkApiResponse(result); + utils::runOnMainThread([this, alive, r = std::move(result), response]() { if (!alive.expired()) parseDeviceList(r, response.first, response.second); }); }).detach(); } -void RoutesDialog::parseDeviceList(const QString &json, bool success, int error_code) { +void RoutesDialog::parseDeviceList(const std::string &json, bool success, int error_code) { if (success) { device_list_->clear(); std::string err; - auto doc = json11::Json::parse(json.toStdString(), err); + auto doc = json11::Json::parse(json, err); if (err.empty() && doc.is_array()) { for (const auto &device : doc.array_items()) { QString dongle_id = QString::fromStdString(device["dongle_id"].string_value()); @@ -156,16 +157,17 @@ void RoutesDialog::fetchRoutes() { int request_id = ++fetch_id_; std::thread([this, alive = std::weak_ptr(alive_), did, start_ms, end_ms, preserved, request_id]() { std::string result = PyDownloader::getDeviceRoutes(did, start_ms, end_ms, preserved); - utils::runOnMainThread([this, alive, r = QString::fromStdString(result), response = checkApiResponse(result), request_id]() { + auto response = checkApiResponse(result); + utils::runOnMainThread([this, alive, r = std::move(result), response, request_id]() { if (!alive.expired() && fetch_id_ == request_id) parseRouteList(r, response.first, response.second); }); }).detach(); } -void RoutesDialog::parseRouteList(const QString &json, bool success, int error_code) { +void RoutesDialog::parseRouteList(const std::string &json, bool success, int error_code) { if (success) { std::string err; - auto doc = json11::Json::parse(json.toStdString(), err); + auto doc = json11::Json::parse(json, err); if (err.empty() && doc.is_array()) { for (const auto &route : doc.array_items()) { int64_t from_ms = 0, to_ms = 0; @@ -177,7 +179,7 @@ void RoutesDialog::parseRouteList(const QString &json, bool success, int error_c to_ms = static_cast(route["end_time_utc_millis"].number_value()); } const int mins = static_cast((to_ms - from_ms) / 60000); - auto item = new QListWidgetItem(QString("%1 %2min").arg(formatUnixMs(from_ms)).arg(mins)); + auto item = new QListWidgetItem(QString::fromStdString(formatUnixMs(from_ms) + " " + std::to_string(mins) + "min")); item->setData(Qt::UserRole, QString::fromStdString(route["fullname"].string_value())); route_list_->addItem(item); } @@ -190,7 +192,7 @@ void RoutesDialog::parseRouteList(const QString &json, bool success, int error_c route_list_->setEmptyText(tr("No items")); } -QString RoutesDialog::route() { +std::string RoutesDialog::route() { auto current_item = route_list_->currentItem(); - return current_item ? current_item->data(Qt::UserRole).toString() : ""; + return current_item ? current_item->data(Qt::UserRole).toString().toStdString() : ""; } diff --git a/openpilot/tools/cabana/routesdialog.h b/openpilot/tools/cabana/routesdialog.h index 6ed145603f..44d7f068f9 100644 --- a/openpilot/tools/cabana/routesdialog.h +++ b/openpilot/tools/cabana/routesdialog.h @@ -12,11 +12,11 @@ class RoutesDialog : public QDialog { Q_OBJECT public: RoutesDialog(QWidget *parent); - QString route(); + std::string route(); protected: - void parseDeviceList(const QString &json, bool success, int error_code); - void parseRouteList(const QString &json, bool success, int error_code); + void parseDeviceList(const std::string &json, bool success, int error_code); + void parseRouteList(const std::string &json, bool success, int error_code); void fetchRoutes(); QComboBox *device_list_; diff --git a/openpilot/tools/cabana/streams/replaystream.cc b/openpilot/tools/cabana/streams/replaystream.cc index 56e4bfa084..3524c9e600 100644 --- a/openpilot/tools/cabana/streams/replaystream.cc +++ b/openpilot/tools/cabana/streams/replaystream.cc @@ -148,7 +148,7 @@ OpenReplayWidget::OpenReplayWidget(QWidget *parent) : AbstractOpenStreamWidget(p QObject::connect(browse_remote_btn, &QPushButton::clicked, [this]() { RoutesDialog route_dlg(this); if (route_dlg.exec()) { - route_edit->setText(route_dlg.route()); + route_edit->setText(QString::fromStdString(route_dlg.route())); } }); } From 131e473f37f3f2762469f98ef11d82a238e42f6a Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:36:26 -0700 Subject: [PATCH 34/67] cabana: move stream open widgets into streamselector (#38715) --- .../tools/cabana/streams/abstractstream.h | 10 - .../tools/cabana/streams/devicestream.cc | 34 +-- openpilot/tools/cabana/streams/devicestream.h | 12 - openpilot/tools/cabana/streams/pandastream.cc | 116 -------- openpilot/tools/cabana/streams/pandastream.h | 22 -- .../tools/cabana/streams/replaystream.cc | 68 ----- openpilot/tools/cabana/streams/replaystream.h | 14 - .../tools/cabana/streams/socketcanstream.cc | 55 ---- .../tools/cabana/streams/socketcanstream.h | 16 -- openpilot/tools/cabana/streamselector.cc | 261 +++++++++++++++++- openpilot/tools/cabana/streamselector.h | 75 +++++ 11 files changed, 335 insertions(+), 348 deletions(-) diff --git a/openpilot/tools/cabana/streams/abstractstream.h b/openpilot/tools/cabana/streams/abstractstream.h index 17d212041a..4f7c0dc3e7 100644 --- a/openpilot/tools/cabana/streams/abstractstream.h +++ b/openpilot/tools/cabana/streams/abstractstream.h @@ -101,16 +101,6 @@ private: std::unordered_map> masks_; }; -class AbstractOpenStreamWidget : public QWidget { - Q_OBJECT -public: - AbstractOpenStreamWidget(QWidget *parent = nullptr) : QWidget(parent) {} - virtual AbstractStream *open() = 0; - -signals: - void enableOpenButton(bool); -}; - class DummyStream : public AbstractStream { public: std::string routeName() const override { return "No Stream"; } diff --git a/openpilot/tools/cabana/streams/devicestream.cc b/openpilot/tools/cabana/streams/devicestream.cc index 3b4b54707c..6d580cc912 100644 --- a/openpilot/tools/cabana/streams/devicestream.cc +++ b/openpilot/tools/cabana/streams/devicestream.cc @@ -14,12 +14,8 @@ #include "openpilot/cereal/services.h" -#include -#include +#include #include -#include - -#include "tools/cabana/utils/util.h" // DeviceStream @@ -118,31 +114,3 @@ void DeviceStream::streamThread() { handleEvent(kj::ArrayPtr((capnp::word*)msg->getData(), msg->getSize() / sizeof(capnp::word))); } } - -// OpenDeviceWidget - -OpenDeviceWidget::OpenDeviceWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) { - QRadioButton *msgq = new QRadioButton(tr("MSGQ")); - QRadioButton *zmq = new QRadioButton(tr("ZMQ")); - ip_address = new QLineEdit(this); - ip_address->setPlaceholderText(tr("Enter device Ip Address")); - ip_address->setValidator(new IpAddressValidator(this)); - - group = new QButtonGroup(this); - group->addButton(msgq, 0); - group->addButton(zmq, 1); - - QFormLayout *form_layout = new QFormLayout(this); - form_layout->addRow(msgq); - form_layout->addRow(zmq, ip_address); - QObject::connect(group, qOverload(&QButtonGroup::buttonToggled), [=](QAbstractButton *button, bool checked) { - ip_address->setEnabled(button == zmq && checked); - }); - zmq->setChecked(true); -} - -AbstractStream *OpenDeviceWidget::open() { - QString ip = ip_address->text().isEmpty() ? "127.0.0.1" : ip_address->text(); - bool msgq = group->checkedId() == 0; - return new DeviceStream(msgq ? "" : ip); -} diff --git a/openpilot/tools/cabana/streams/devicestream.h b/openpilot/tools/cabana/streams/devicestream.h index 1aa8ce9bff..71205e5a80 100644 --- a/openpilot/tools/cabana/streams/devicestream.h +++ b/openpilot/tools/cabana/streams/devicestream.h @@ -19,15 +19,3 @@ protected: pid_t bridge_pid = -1; const QString zmq_address; }; - -class OpenDeviceWidget : public AbstractOpenStreamWidget { - Q_OBJECT - -public: - OpenDeviceWidget(QWidget *parent = nullptr); - AbstractStream *open() override; - -private: - QLineEdit *ip_address; - QButtonGroup *group; -}; diff --git a/openpilot/tools/cabana/streams/pandastream.cc b/openpilot/tools/cabana/streams/pandastream.cc index 06c8e6e19c..0e72443c7a 100644 --- a/openpilot/tools/cabana/streams/pandastream.cc +++ b/openpilot/tools/cabana/streams/pandastream.cc @@ -4,12 +4,6 @@ #include #include -#include -#include -#include -#include -#include - PandaStream::PandaStream(PandaStreamConfig config_) : config(config_) { if (!connect()) { throw std::runtime_error("Failed to connect to panda"); @@ -77,113 +71,3 @@ void PandaStream::streamThread() { panda->send_heartbeat(false); } } - -// OpenPandaWidget - -OpenPandaWidget::OpenPandaWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) { - form_layout = new QFormLayout(this); - if (can && dynamic_cast(can) != nullptr) { - form_layout->addWidget(new QLabel(tr("Already connected to %1.").arg(QString::fromStdString(can->routeName())))); - form_layout->addWidget(new QLabel("Close the current connection via [File menu -> Close Stream] before connecting to another Panda.")); - QTimer::singleShot(0, [this]() { emit enableOpenButton(false); }); - return; - } - - QHBoxLayout *serial_layout = new QHBoxLayout(); - serial_layout->addWidget(serial_edit = new QComboBox()); - - QPushButton *refresh = new QPushButton(tr("Refresh")); - refresh->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred); - serial_layout->addWidget(refresh); - form_layout->addRow(tr("Serial"), serial_layout); - - QObject::connect(refresh, &QPushButton::clicked, this, &OpenPandaWidget::refreshSerials); - QObject::connect(serial_edit, &QComboBox::currentTextChanged, this, &OpenPandaWidget::buildConfigForm); - - // Populate serials - refreshSerials(); - buildConfigForm(); -} - -void OpenPandaWidget::refreshSerials() { - serial_edit->clear(); - for (auto serial : Panda::list()) { - serial_edit->addItem(QString::fromStdString(serial)); - } -} - -void OpenPandaWidget::buildConfigForm() { - for (int i = form_layout->rowCount() - 1; i > 0; --i) { - form_layout->removeRow(i); - } - - QString serial = serial_edit->currentText(); - bool has_fd = false; - bool has_panda = !serial.isEmpty(); - if (has_panda) { - try { - Panda panda(serial.toStdString()); - has_fd = (panda.hw_type == cereal::PandaState::PandaType::RED_PANDA) || (panda.hw_type == cereal::PandaState::PandaType::RED_PANDA_V2); - } catch (const std::exception& e) { - fprintf(stderr, "failed to open panda %s\n", serial.toUtf8().constData()); - has_panda = false; - } - } - - if (has_panda) { - config.serial = serial.toStdString(); - config.bus_config.resize(3); - for (int i = 0; i < config.bus_config.size(); i++) { - QHBoxLayout *bus_layout = new QHBoxLayout; - - // CAN Speed - bus_layout->addWidget(new QLabel(tr("CAN Speed (kbps):"))); - QComboBox *can_speed = new QComboBox; - for (int j = 0; j < std::size(speeds); j++) { - can_speed->addItem(QString::number(speeds[j])); - - if (data_speeds[j] == config.bus_config[i].can_speed_kbps) { - can_speed->setCurrentIndex(j); - } - } - QObject::connect(can_speed, qOverload(&QComboBox::currentIndexChanged), [=](int index) {config.bus_config[i].can_speed_kbps = speeds[index];}); - bus_layout->addWidget(can_speed); - - // CAN-FD Speed - if (has_fd) { - QCheckBox *enable_fd = new QCheckBox("CAN-FD"); - bus_layout->addWidget(enable_fd); - bus_layout->addWidget(new QLabel(tr("Data Speed (kbps):"))); - QComboBox *data_speed = new QComboBox; - for (int j = 0; j < std::size(data_speeds); j++) { - data_speed->addItem(QString::number(data_speeds[j])); - - if (data_speeds[j] == config.bus_config[i].data_speed_kbps) { - data_speed->setCurrentIndex(j); - } - } - - data_speed->setEnabled(false); - bus_layout->addWidget(data_speed); - - QObject::connect(data_speed, qOverload(&QComboBox::currentIndexChanged), [=](int index) {config.bus_config[i].data_speed_kbps = data_speeds[index];}); - QObject::connect(enable_fd, &QCheckBox::stateChanged, data_speed, &QComboBox::setEnabled); - QObject::connect(enable_fd, &QCheckBox::stateChanged, [=](int state) {config.bus_config[i].can_fd = (bool)state;}); - } - - form_layout->addRow(tr("Bus %1:").arg(i), bus_layout); - } - } else { - config.serial = ""; - form_layout->addWidget(new QLabel(tr("No panda found"))); - } -} - -AbstractStream *OpenPandaWidget::open() { - try { - return new PandaStream(config); - } catch (std::exception &e) { - QMessageBox::warning(nullptr, tr("Warning"), tr("Failed to connect to panda: '%1'").arg(e.what())); - return nullptr; - } -} diff --git a/openpilot/tools/cabana/streams/pandastream.h b/openpilot/tools/cabana/streams/pandastream.h index d764613fe1..760b5c7b15 100644 --- a/openpilot/tools/cabana/streams/pandastream.h +++ b/openpilot/tools/cabana/streams/pandastream.h @@ -3,15 +3,9 @@ #include #include -#include -#include - #include "tools/cabana/streams/livestream.h" #include "tools/cabana/panda.h" -const uint32_t speeds[] = {10U, 20U, 50U, 100U, 125U, 250U, 500U, 1000U}; -const uint32_t data_speeds[] = {10U, 20U, 50U, 100U, 125U, 250U, 500U, 1000U, 2000U, 5000U}; - struct BusConfig { int can_speed_kbps = 500; int data_speed_kbps = 2000; @@ -38,19 +32,3 @@ protected: std::unique_ptr panda; PandaStreamConfig config = {}; }; - -class OpenPandaWidget : public AbstractOpenStreamWidget { - Q_OBJECT - -public: - OpenPandaWidget(QWidget *parent = nullptr); - AbstractStream *open() override; - -private: - void refreshSerials(); - void buildConfigForm(); - - QComboBox *serial_edit; - QFormLayout *form_layout; - PandaStreamConfig config = {}; -}; diff --git a/openpilot/tools/cabana/streams/replaystream.cc b/openpilot/tools/cabana/streams/replaystream.cc index 3524c9e600..e9df400599 100644 --- a/openpilot/tools/cabana/streams/replaystream.cc +++ b/openpilot/tools/cabana/streams/replaystream.cc @@ -1,16 +1,9 @@ #include "tools/cabana/streams/replaystream.h" -#include - -#include -#include -#include #include -#include #include "common/timing.h" #include "common/util.h" -#include "tools/cabana/routesdialog.h" ReplayStream::ReplayStream() { unsetenv("ZMQ"); @@ -116,64 +109,3 @@ void ReplayStream::pause(bool pause) { replay->pause(pause); pause ? paused() : resume(); } - - -// OpenReplayWidget - -OpenReplayWidget::OpenReplayWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) { - QGridLayout *grid_layout = new QGridLayout(this); - grid_layout->addWidget(new QLabel(tr("Route")), 0, 0); - grid_layout->addWidget(route_edit = new QLineEdit(this), 0, 1); - route_edit->setPlaceholderText(tr("Enter route name or browse for local/remote route")); - auto browse_remote_btn = new QPushButton(tr("Remote route..."), this); - grid_layout->addWidget(browse_remote_btn, 0, 2); - auto browse_local_btn = new QPushButton(tr("Local route..."), this); - grid_layout->addWidget(browse_local_btn, 0, 3); - - QHBoxLayout *camera_layout = new QHBoxLayout(); - for (auto c : {tr("Road camera"), tr("Driver camera"), tr("Wide road camera")}) - camera_layout->addWidget(cameras.emplace_back(new QCheckBox(c, this))); - cameras[0]->setChecked(true); - camera_layout->addStretch(1); - grid_layout->addItem(camera_layout, 1, 1); - - setMinimumWidth(550); - QObject::connect(browse_local_btn, &QPushButton::clicked, [=]() { - QString dir = QFileDialog::getExistingDirectory(this, tr("Open Local Route"), QString::fromStdString(settings.last_route_dir)); - if (!dir.isEmpty()) { - route_edit->setText(dir); - settings.last_route_dir = std::filesystem::absolute(dir.toStdString()).parent_path().string(); - } - }); - QObject::connect(browse_remote_btn, &QPushButton::clicked, [this]() { - RoutesDialog route_dlg(this); - if (route_dlg.exec()) { - route_edit->setText(QString::fromStdString(route_dlg.route())); - } - }); -} - -AbstractStream *OpenReplayWidget::open() { - QString route = route_edit->text(); - QString data_dir; - if (int idx = route.lastIndexOf('/'); idx != -1 && util::file_exists(route.toStdString())) { - data_dir = route.mid(0, idx + 1); - route = route.mid(idx + 1); - } - - bool is_valid_format = Route::parseRoute(route.toStdString()).str.size() > 0; - if (!is_valid_format) { - QMessageBox::warning(nullptr, tr("Warning"), tr("Invalid route format: '%1'").arg(route)); - } else { - auto replay_stream = std::make_unique(); - uint32_t flags = REPLAY_FLAG_NONE; - if (cameras[1]->isChecked()) flags |= REPLAY_FLAG_CABIN_CAMERA; - if (cameras[2]->isChecked()) flags |= REPLAY_FLAG_WIDE_ROAD; - if (flags == REPLAY_FLAG_NONE && !cameras[0]->isChecked()) flags = REPLAY_FLAG_NO_VIPC; - - if (replay_stream->loadRoute(route.toStdString(), data_dir.toStdString(), flags)) { - return replay_stream.release(); - } - } - return nullptr; -} diff --git a/openpilot/tools/cabana/streams/replaystream.h b/openpilot/tools/cabana/streams/replaystream.h index 6f49502ba3..aad0ac6361 100644 --- a/openpilot/tools/cabana/streams/replaystream.h +++ b/openpilot/tools/cabana/streams/replaystream.h @@ -1,10 +1,8 @@ #pragma once -#include #include #include #include -#include #include "common/prefix.h" #include "tools/cabana/streams/abstractstream.h" @@ -43,15 +41,3 @@ private: std::set processed_segments; std::unique_ptr op_prefix; }; - -class OpenReplayWidget : public AbstractOpenStreamWidget { - Q_OBJECT - -public: - OpenReplayWidget(QWidget *parent = nullptr); - AbstractStream *open() override; - -private: - QLineEdit *route_edit; - std::vector cameras; -}; diff --git a/openpilot/tools/cabana/streams/socketcanstream.cc b/openpilot/tools/cabana/streams/socketcanstream.cc index b10f2cab90..f16ed36d9d 100644 --- a/openpilot/tools/cabana/streams/socketcanstream.cc +++ b/openpilot/tools/cabana/streams/socketcanstream.cc @@ -8,13 +8,6 @@ #include #include -#include -#include - -#include -#include -#include -#include SocketCanStream::SocketCanStream(SocketCanStreamConfig config_) : config(config_) { if (!available()) { @@ -98,51 +91,3 @@ void SocketCanStream::streamThread() { handleEvent(capnp::messageToFlatArray(msg)); } } - -OpenSocketCanWidget::OpenSocketCanWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) { - QVBoxLayout *main_layout = new QVBoxLayout(this); - main_layout->addStretch(1); - - QFormLayout *form_layout = new QFormLayout(); - - QHBoxLayout *device_layout = new QHBoxLayout(); - device_edit = new QComboBox(); - device_edit->setFixedWidth(300); - device_layout->addWidget(device_edit); - - QPushButton *refresh = new QPushButton(tr("Refresh")); - refresh->setFixedWidth(100); - device_layout->addWidget(refresh); - form_layout->addRow(tr("Device"), device_layout); - main_layout->addLayout(form_layout); - - main_layout->addStretch(1); - - QObject::connect(refresh, &QPushButton::clicked, this, &OpenSocketCanWidget::refreshDevices); - QObject::connect(device_edit, &QComboBox::currentTextChanged, this, [=]{ config.device = device_edit->currentText().toStdString(); }); - - // Populate devices - refreshDevices(); -} - -void OpenSocketCanWidget::refreshDevices() { - device_edit->clear(); - // Scan /sys/class/net/ for CAN interfaces (type 280 = ARPHRD_CAN) - std::error_code ec; - for (const auto &entry : std::filesystem::directory_iterator("/sys/class/net", ec)) { - std::ifstream type_file(entry.path() / "type"); - int type = 0; - if (type_file >> type && type == 280) { - device_edit->addItem(QString::fromStdString(entry.path().filename().string())); - } - } -} - -AbstractStream *OpenSocketCanWidget::open() { - try { - return new SocketCanStream(config); - } catch (std::exception &e) { - QMessageBox::warning(nullptr, tr("Warning"), tr("Failed to connect to SocketCAN device: '%1'").arg(e.what())); - return nullptr; - } -} diff --git a/openpilot/tools/cabana/streams/socketcanstream.h b/openpilot/tools/cabana/streams/socketcanstream.h index 78e856d506..c44a498221 100644 --- a/openpilot/tools/cabana/streams/socketcanstream.h +++ b/openpilot/tools/cabana/streams/socketcanstream.h @@ -1,7 +1,5 @@ #pragma once -#include - #include "tools/cabana/streams/livestream.h" struct SocketCanStreamConfig { @@ -25,17 +23,3 @@ protected: SocketCanStreamConfig config = {}; int sock_fd = -1; }; - -class OpenSocketCanWidget : public AbstractOpenStreamWidget { - Q_OBJECT - -public: - OpenSocketCanWidget(QWidget *parent = nullptr); - AbstractStream *open() override; - -private: - void refreshDevices(); - - QComboBox *device_edit; - SocketCanStreamConfig config = {}; -}; diff --git a/openpilot/tools/cabana/streamselector.cc b/openpilot/tools/cabana/streamselector.cc index 7e8adc568d..dae022be75 100644 --- a/openpilot/tools/cabana/streamselector.cc +++ b/openpilot/tools/cabana/streamselector.cc @@ -1,18 +1,275 @@ #include "tools/cabana/streamselector.h" #include +#include #include +#include #include +#include #include +#include +#include #include "tools/cabana/streams/devicestream.h" -#include "tools/cabana/streams/pandastream.h" #include "tools/cabana/streams/replaystream.h" +#include "tools/cabana/routesdialog.h" + +// OpenReplayWidget + +OpenReplayWidget::OpenReplayWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) { + QGridLayout *grid_layout = new QGridLayout(this); + grid_layout->addWidget(new QLabel(tr("Route")), 0, 0); + grid_layout->addWidget(route_edit = new QLineEdit(this), 0, 1); + route_edit->setPlaceholderText(tr("Enter route name or browse for local/remote route")); + auto browse_remote_btn = new QPushButton(tr("Remote route..."), this); + grid_layout->addWidget(browse_remote_btn, 0, 2); + auto browse_local_btn = new QPushButton(tr("Local route..."), this); + grid_layout->addWidget(browse_local_btn, 0, 3); + + QHBoxLayout *camera_layout = new QHBoxLayout(); + for (auto c : {tr("Road camera"), tr("Driver camera"), tr("Wide road camera")}) + camera_layout->addWidget(cameras.emplace_back(new QCheckBox(c, this))); + cameras[0]->setChecked(true); + camera_layout->addStretch(1); + grid_layout->addItem(camera_layout, 1, 1); + + setMinimumWidth(550); + QObject::connect(browse_local_btn, &QPushButton::clicked, [=]() { + QString dir = QFileDialog::getExistingDirectory(this, tr("Open Local Route"), QString::fromStdString(settings.last_route_dir)); + if (!dir.isEmpty()) { + route_edit->setText(dir); + settings.last_route_dir = std::filesystem::absolute(dir.toStdString()).parent_path().string(); + } + }); + QObject::connect(browse_remote_btn, &QPushButton::clicked, [this]() { + RoutesDialog route_dlg(this); + if (route_dlg.exec()) { + route_edit->setText(QString::fromStdString(route_dlg.route())); + } + }); +} + +AbstractStream *OpenReplayWidget::open() { + QString route = route_edit->text(); + QString data_dir; + if (int idx = route.lastIndexOf('/'); idx != -1 && util::file_exists(route.toStdString())) { + data_dir = route.mid(0, idx + 1); + route = route.mid(idx + 1); + } + + bool is_valid_format = Route::parseRoute(route.toStdString()).str.size() > 0; + if (!is_valid_format) { + QMessageBox::warning(nullptr, tr("Warning"), tr("Invalid route format: '%1'").arg(route)); + } else { + auto replay_stream = std::make_unique(); + uint32_t flags = REPLAY_FLAG_NONE; + if (cameras[1]->isChecked()) flags |= REPLAY_FLAG_CABIN_CAMERA; + if (cameras[2]->isChecked()) flags |= REPLAY_FLAG_WIDE_ROAD; + if (flags == REPLAY_FLAG_NONE && !cameras[0]->isChecked()) flags = REPLAY_FLAG_NO_VIPC; + + if (replay_stream->loadRoute(route.toStdString(), data_dir.toStdString(), flags)) { + return replay_stream.release(); + } + } + return nullptr; +} + +// OpenPandaWidget + +static const uint32_t speeds[] = {10U, 20U, 50U, 100U, 125U, 250U, 500U, 1000U}; +static const uint32_t data_speeds[] = {10U, 20U, 50U, 100U, 125U, 250U, 500U, 1000U, 2000U, 5000U}; + +OpenPandaWidget::OpenPandaWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) { + form_layout = new QFormLayout(this); + if (can && dynamic_cast(can) != nullptr) { + form_layout->addWidget(new QLabel(tr("Already connected to %1.").arg(QString::fromStdString(can->routeName())))); + form_layout->addWidget(new QLabel("Close the current connection via [File menu -> Close Stream] before connecting to another Panda.")); + QTimer::singleShot(0, [this]() { emit enableOpenButton(false); }); + return; + } + + QHBoxLayout *serial_layout = new QHBoxLayout(); + serial_layout->addWidget(serial_edit = new QComboBox()); + + QPushButton *refresh = new QPushButton(tr("Refresh")); + refresh->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred); + serial_layout->addWidget(refresh); + form_layout->addRow(tr("Serial"), serial_layout); + + QObject::connect(refresh, &QPushButton::clicked, this, &OpenPandaWidget::refreshSerials); + QObject::connect(serial_edit, &QComboBox::currentTextChanged, this, &OpenPandaWidget::buildConfigForm); + + // Populate serials + refreshSerials(); + buildConfigForm(); +} + +void OpenPandaWidget::refreshSerials() { + serial_edit->clear(); + for (auto serial : Panda::list()) { + serial_edit->addItem(QString::fromStdString(serial)); + } +} + +void OpenPandaWidget::buildConfigForm() { + for (int i = form_layout->rowCount() - 1; i > 0; --i) { + form_layout->removeRow(i); + } + + QString serial = serial_edit->currentText(); + bool has_fd = false; + bool has_panda = !serial.isEmpty(); + if (has_panda) { + try { + Panda panda(serial.toStdString()); + has_fd = (panda.hw_type == cereal::PandaState::PandaType::RED_PANDA) || (panda.hw_type == cereal::PandaState::PandaType::RED_PANDA_V2); + } catch (const std::exception& e) { + fprintf(stderr, "failed to open panda %s\n", serial.toUtf8().constData()); + has_panda = false; + } + } + + if (has_panda) { + config.serial = serial.toStdString(); + config.bus_config.resize(3); + for (int i = 0; i < config.bus_config.size(); i++) { + QHBoxLayout *bus_layout = new QHBoxLayout; + + // CAN Speed + bus_layout->addWidget(new QLabel(tr("CAN Speed (kbps):"))); + QComboBox *can_speed = new QComboBox; + for (int j = 0; j < std::size(speeds); j++) { + can_speed->addItem(QString::number(speeds[j])); + + if (data_speeds[j] == config.bus_config[i].can_speed_kbps) { + can_speed->setCurrentIndex(j); + } + } + QObject::connect(can_speed, qOverload(&QComboBox::currentIndexChanged), [=](int index) {config.bus_config[i].can_speed_kbps = speeds[index];}); + bus_layout->addWidget(can_speed); + + // CAN-FD Speed + if (has_fd) { + QCheckBox *enable_fd = new QCheckBox("CAN-FD"); + bus_layout->addWidget(enable_fd); + bus_layout->addWidget(new QLabel(tr("Data Speed (kbps):"))); + QComboBox *data_speed = new QComboBox; + for (int j = 0; j < std::size(data_speeds); j++) { + data_speed->addItem(QString::number(data_speeds[j])); + + if (data_speeds[j] == config.bus_config[i].data_speed_kbps) { + data_speed->setCurrentIndex(j); + } + } + + data_speed->setEnabled(false); + bus_layout->addWidget(data_speed); + + QObject::connect(data_speed, qOverload(&QComboBox::currentIndexChanged), [=](int index) {config.bus_config[i].data_speed_kbps = data_speeds[index];}); + QObject::connect(enable_fd, &QCheckBox::stateChanged, data_speed, &QComboBox::setEnabled); + QObject::connect(enable_fd, &QCheckBox::stateChanged, [=](int state) {config.bus_config[i].can_fd = (bool)state;}); + } + + form_layout->addRow(tr("Bus %1:").arg(i), bus_layout); + } + } else { + config.serial = ""; + form_layout->addWidget(new QLabel(tr("No panda found"))); + } +} + +AbstractStream *OpenPandaWidget::open() { + try { + return new PandaStream(config); + } catch (std::exception &e) { + QMessageBox::warning(nullptr, tr("Warning"), tr("Failed to connect to panda: '%1'").arg(e.what())); + return nullptr; + } +} + +// OpenDeviceWidget + +OpenDeviceWidget::OpenDeviceWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) { + QRadioButton *msgq = new QRadioButton(tr("MSGQ")); + QRadioButton *zmq = new QRadioButton(tr("ZMQ")); + ip_address = new QLineEdit(this); + ip_address->setPlaceholderText(tr("Enter device Ip Address")); + ip_address->setValidator(new IpAddressValidator(this)); + + group = new QButtonGroup(this); + group->addButton(msgq, 0); + group->addButton(zmq, 1); + + QFormLayout *form_layout = new QFormLayout(this); + form_layout->addRow(msgq); + form_layout->addRow(zmq, ip_address); + QObject::connect(group, qOverload(&QButtonGroup::buttonToggled), [=](QAbstractButton *button, bool checked) { + ip_address->setEnabled(button == zmq && checked); + }); + zmq->setChecked(true); +} + +AbstractStream *OpenDeviceWidget::open() { + QString ip = ip_address->text().isEmpty() ? "127.0.0.1" : ip_address->text(); + bool msgq = group->checkedId() == 0; + return new DeviceStream(msgq ? "" : ip); +} + #ifdef __linux__ -#include "tools/cabana/streams/socketcanstream.h" +// OpenSocketCanWidget + +OpenSocketCanWidget::OpenSocketCanWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) { + QVBoxLayout *main_layout = new QVBoxLayout(this); + main_layout->addStretch(1); + + QFormLayout *form_layout = new QFormLayout(); + + QHBoxLayout *device_layout = new QHBoxLayout(); + device_edit = new QComboBox(); + device_edit->setFixedWidth(300); + device_layout->addWidget(device_edit); + + QPushButton *refresh = new QPushButton(tr("Refresh")); + refresh->setFixedWidth(100); + device_layout->addWidget(refresh); + form_layout->addRow(tr("Device"), device_layout); + main_layout->addLayout(form_layout); + + main_layout->addStretch(1); + + QObject::connect(refresh, &QPushButton::clicked, this, &OpenSocketCanWidget::refreshDevices); + QObject::connect(device_edit, &QComboBox::currentTextChanged, this, [=]{ config.device = device_edit->currentText().toStdString(); }); + + // Populate devices + refreshDevices(); +} + +void OpenSocketCanWidget::refreshDevices() { + device_edit->clear(); + // Scan /sys/class/net/ for CAN interfaces (type 280 = ARPHRD_CAN) + std::error_code ec; + for (const auto &entry : std::filesystem::directory_iterator("/sys/class/net", ec)) { + std::ifstream type_file(entry.path() / "type"); + int type = 0; + if (type_file >> type && type == 280) { + device_edit->addItem(QString::fromStdString(entry.path().filename().string())); + } + } +} + +AbstractStream *OpenSocketCanWidget::open() { + try { + return new SocketCanStream(config); + } catch (std::exception &e) { + QMessageBox::warning(nullptr, tr("Warning"), tr("Failed to connect to SocketCAN device: '%1'").arg(e.what())); + return nullptr; + } +} #endif +// StreamSelector + StreamSelector::StreamSelector(QWidget *parent) : QDialog(parent) { setWindowTitle(tr("Open stream")); QVBoxLayout *layout = new QVBoxLayout(this); diff --git a/openpilot/tools/cabana/streamselector.h b/openpilot/tools/cabana/streamselector.h index 0919195e4e..1210806a3b 100644 --- a/openpilot/tools/cabana/streamselector.h +++ b/openpilot/tools/cabana/streamselector.h @@ -1,11 +1,86 @@ #pragma once +#include + +#include +#include +#include #include #include +#include #include #include #include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/streams/pandastream.h" +#ifdef __linux__ +#include "tools/cabana/streams/socketcanstream.h" +#endif + +class AbstractOpenStreamWidget : public QWidget { + Q_OBJECT +public: + AbstractOpenStreamWidget(QWidget *parent = nullptr) : QWidget(parent) {} + virtual AbstractStream *open() = 0; + +signals: + void enableOpenButton(bool); +}; + +class OpenReplayWidget : public AbstractOpenStreamWidget { + Q_OBJECT + +public: + OpenReplayWidget(QWidget *parent = nullptr); + AbstractStream *open() override; + +private: + QLineEdit *route_edit; + std::vector cameras; +}; + +class OpenPandaWidget : public AbstractOpenStreamWidget { + Q_OBJECT + +public: + OpenPandaWidget(QWidget *parent = nullptr); + AbstractStream *open() override; + +private: + void refreshSerials(); + void buildConfigForm(); + + QComboBox *serial_edit; + QFormLayout *form_layout; + PandaStreamConfig config = {}; +}; + +class OpenDeviceWidget : public AbstractOpenStreamWidget { + Q_OBJECT + +public: + OpenDeviceWidget(QWidget *parent = nullptr); + AbstractStream *open() override; + +private: + QLineEdit *ip_address; + QButtonGroup *group; +}; + +#ifdef __linux__ +// no Q_OBJECT: moc does not define __linux__ and would otherwise skip this class +class OpenSocketCanWidget : public AbstractOpenStreamWidget { +public: + OpenSocketCanWidget(QWidget *parent = nullptr); + AbstractStream *open() override; + +private: + void refreshDevices(); + + QComboBox *device_edit; + SocketCanStreamConfig config = {}; +}; +#endif class StreamSelector : public QDialog { Q_OBJECT From 0f9c753e6ec5c7af9ea37f9020ab3cc66bed5db1 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:46:45 -0700 Subject: [PATCH 35/67] cabana: remove Qt from livestream (#38722) --- openpilot/tools/cabana/streams/livestream.cc | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/openpilot/tools/cabana/streams/livestream.cc b/openpilot/tools/cabana/streams/livestream.cc index 05a6d44c40..71b5d9a1ec 100644 --- a/openpilot/tools/cabana/streams/livestream.cc +++ b/openpilot/tools/cabana/streams/livestream.cc @@ -21,12 +21,9 @@ struct LiveStream::Logger { localtime_r(&start_time, &local_time); std::ostringstream date; date << std::put_time(&local_time, "%Y-%m-%d--%H-%M-%S"); - QString dir = QString("%1/%2--%3") - .arg(QString::fromStdString(settings.log_path)) - .arg(QString::fromStdString(date.str())) - .arg(n); - util::create_directories(dir.toStdString(), 0755); - fs.reset(new std::ofstream((dir + "/rlog").toStdString(), std::ios::binary | std::ios::out)); + std::string dir = settings.log_path + "/" + date.str() + "--" + std::to_string(n); + util::create_directories(dir, 0755); + fs.reset(new std::ofstream(dir + "/rlog", std::ios::binary | std::ios::out)); } auto bytes = data.asBytes(); From 6e0f4f46306d6c569d6a52a4bbbea6537354d898 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:55:28 -0700 Subject: [PATCH 36/67] cabana: split SettingsDialog out of settings (#38719) cabana: split SettingsDialog out of settings.{h,cc} --- openpilot/tools/cabana/SConscript | 2 +- openpilot/tools/cabana/mainwin.cc | 3 +- openpilot/tools/cabana/settings.cc | 89 ---------------------- openpilot/tools/cabana/settings.h | 20 ----- openpilot/tools/cabana/settingsdialog.cc | 94 ++++++++++++++++++++++++ openpilot/tools/cabana/settingsdialog.h | 21 ++++++ openpilot/tools/cabana/utils/util.h | 5 ++ 7 files changed, 123 insertions(+), 111 deletions(-) create mode 100644 openpilot/tools/cabana/settingsdialog.cc create mode 100644 openpilot/tools/cabana/settingsdialog.h diff --git a/openpilot/tools/cabana/SConscript b/openpilot/tools/cabana/SConscript index 662b3952bb..75e67d99e3 100644 --- a/openpilot/tools/cabana/SConscript +++ b/openpilot/tools/cabana/SConscript @@ -102,7 +102,7 @@ cabana_srcs = ['mainwin.cc', 'streams/pandastream.cc', 'streams/devicestream.cc' 'routesdialog.cc', 'dbc/dbc.cc', 'dbc/dbcfile.cc', 'dbc/dbcmanager.cc', 'utils/export.cc', 'utils/util.cc', 'utils/strings.cc', 'utils/elidedlabel.cc', 'chart/chartswidget.cc', 'chart/chart.cc', 'chart/signalselector.cc', 'chart/tiplabel.cc', 'chart/sparkline.cc', - 'commands.cc', 'messageswidget.cc', 'streamselector.cc', 'settings.cc', 'panda.cc', + 'commands.cc', 'messageswidget.cc', 'streamselector.cc', 'settings.cc', 'settingsdialog.cc', 'panda.cc', 'cameraview.cc', 'detailwidget.cc', 'tools/findsimilarbits.cc', 'tools/findsignal.cc', 'tools/routeinfo.cc'] if arch != "Darwin": cabana_srcs += ['streams/socketcanstream.cc'] diff --git a/openpilot/tools/cabana/mainwin.cc b/openpilot/tools/cabana/mainwin.cc index bcfe1dde41..3ae5896a2d 100644 --- a/openpilot/tools/cabana/mainwin.cc +++ b/openpilot/tools/cabana/mainwin.cc @@ -19,6 +19,7 @@ #include "json11/json11.hpp" #include "tools/cabana/commands.h" +#include "tools/cabana/settingsdialog.h" #include "tools/cabana/streamselector.h" #include "tools/cabana/tools/findsignal.h" #include "tools/cabana/utils/export.h" @@ -617,7 +618,7 @@ void MainWindow::closeEvent(QCloseEvent *event) { } void MainWindow::setOption() { - SettingsDlg dlg(this); + SettingsDialog dlg(this); dlg.exec(); } diff --git a/openpilot/tools/cabana/settings.cc b/openpilot/tools/cabana/settings.cc index cbeded0d10..bacbbd5330 100644 --- a/openpilot/tools/cabana/settings.cc +++ b/openpilot/tools/cabana/settings.cc @@ -24,19 +24,11 @@ #include #endif -#include -#include -#include -#include -#include #include #include "json11/json11.hpp" #include "tools/cabana/utils/util.h" -const int MIN_CACHE_MINIUTES = 30; -const int MAX_CACHE_MINIUTES = 120; - Settings settings; namespace { @@ -535,84 +527,3 @@ void Settings::save() { settingsOp(stored_settings.values, [](auto &s, const char *key, const auto &value) { writeSetting(s, key, value); }); saveSettings(stored_settings.values); } - -// SettingsDlg - -SettingsDlg::SettingsDlg(QWidget *parent) : QDialog(parent) { - setWindowTitle(tr("Settings")); - QVBoxLayout *main_layout = new QVBoxLayout(this); - QGroupBox *groupbox = new QGroupBox("General"); - QFormLayout *form_layout = new QFormLayout(groupbox); - - form_layout->addRow(tr("Color Theme"), theme = new QComboBox(this)); - theme->setToolTip(tr("You may need to restart cabana after changes theme")); - theme->addItems({tr("Automatic"), tr("Light"), tr("Dark")}); - theme->setCurrentIndex(settings.theme); - - form_layout->addRow("FPS", fps = new QSpinBox(this)); - fps->setRange(10, 100); - fps->setSingleStep(10); - fps->setValue(settings.fps); - - form_layout->addRow(tr("Max Cached Minutes"), cached_minutes = new QSpinBox(this)); - cached_minutes->setRange(MIN_CACHE_MINIUTES, MAX_CACHE_MINIUTES); - cached_minutes->setSingleStep(1); - cached_minutes->setValue(settings.max_cached_minutes); - main_layout->addWidget(groupbox); - - groupbox = new QGroupBox("New Signal Settings"); - form_layout = new QFormLayout(groupbox); - form_layout->addRow(tr("Drag Direction"), drag_direction = new QComboBox(this)); - drag_direction->addItems({tr("MSB First"), tr("LSB First"), tr("Always Little Endian"), tr("Always Big Endian")}); - drag_direction->setCurrentIndex(settings.drag_direction); - main_layout->addWidget(groupbox); - - groupbox = new QGroupBox("Chart"); - form_layout = new QFormLayout(groupbox); - form_layout->addRow(tr("Chart Height"), chart_height = new QSpinBox(this)); - chart_height->setRange(100, 500); - chart_height->setSingleStep(10); - chart_height->setValue(settings.chart_height); - main_layout->addWidget(groupbox); - - log_livestream = new QGroupBox(tr("Enable live stream logging"), this); - log_livestream->setCheckable(true); - log_livestream->setChecked(settings.log_livestream); - QHBoxLayout *path_layout = new QHBoxLayout(log_livestream); - path_layout->addWidget(log_path = new QLineEdit(QString::fromStdString(settings.log_path), this)); - log_path->setReadOnly(true); - auto browse_btn = new QPushButton(tr("B&rowse...")); - path_layout->addWidget(browse_btn); - main_layout->addWidget(log_livestream); - - auto buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); - main_layout->addWidget(buttonBox); - setFixedSize(400, sizeHint().height()); - - QObject::connect(browse_btn, &QPushButton::clicked, [this]() { - QString fn = QFileDialog::getExistingDirectory( - this, tr("Log File Location"), - QString::fromStdString(utils::homePath()), - QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks); - if (!fn.isEmpty()) { - log_path->setText(fn); - } - }); - QObject::connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); - QObject::connect(buttonBox, &QDialogButtonBox::accepted, this, &SettingsDlg::save); -} - -void SettingsDlg::save() { - if (std::exchange(settings.theme, theme->currentIndex()) != settings.theme) { - // set theme before emit changed - utils::setTheme(settings.theme); - } - settings.fps = fps->value(); - settings.max_cached_minutes = cached_minutes->value(); - settings.chart_height = chart_height->value(); - settings.log_livestream = log_livestream->isChecked(); - settings.log_path = log_path->text().toStdString(); - settings.drag_direction = (Settings::DragDirection)drag_direction->currentIndex(); - settings.changed(); - QDialog::accept(); -} diff --git a/openpilot/tools/cabana/settings.h b/openpilot/tools/cabana/settings.h index 2c21fab419..52353dc667 100644 --- a/openpilot/tools/cabana/settings.h +++ b/openpilot/tools/cabana/settings.h @@ -3,12 +3,6 @@ #include #include -#include -#include -#include -#include -#include - #include "tools/cabana/core/observable.h" #include "tools/cabana/core/settings.h" @@ -26,18 +20,4 @@ public: Observable<> changed; }; -class SettingsDlg : public QDialog { -public: - SettingsDlg(QWidget *parent); - void save(); - QSpinBox *fps; - QSpinBox *cached_minutes; - QSpinBox *chart_height; - QComboBox *chart_series_type; - QComboBox *theme; - QGroupBox *log_livestream; - QLineEdit *log_path; - QComboBox *drag_direction; -}; - extern Settings settings; diff --git a/openpilot/tools/cabana/settingsdialog.cc b/openpilot/tools/cabana/settingsdialog.cc new file mode 100644 index 0000000000..3a49de5746 --- /dev/null +++ b/openpilot/tools/cabana/settingsdialog.cc @@ -0,0 +1,94 @@ +#include "tools/cabana/settingsdialog.h" + +#include + +#include +#include +#include +#include +#include + +#include "tools/cabana/settings.h" +#include "tools/cabana/utils/util.h" + +const int MIN_CACHE_MINIUTES = 30; +const int MAX_CACHE_MINIUTES = 120; + +SettingsDialog::SettingsDialog(QWidget *parent) : QDialog(parent) { + setWindowTitle(tr("Settings")); + QVBoxLayout *main_layout = new QVBoxLayout(this); + QGroupBox *groupbox = new QGroupBox("General"); + QFormLayout *form_layout = new QFormLayout(groupbox); + + form_layout->addRow(tr("Color Theme"), theme = new QComboBox(this)); + theme->setToolTip(tr("You may need to restart cabana after changes theme")); + theme->addItems({tr("Automatic"), tr("Light"), tr("Dark")}); + theme->setCurrentIndex(settings.theme); + + form_layout->addRow("FPS", fps = new QSpinBox(this)); + fps->setRange(10, 100); + fps->setSingleStep(10); + fps->setValue(settings.fps); + + form_layout->addRow(tr("Max Cached Minutes"), cached_minutes = new QSpinBox(this)); + cached_minutes->setRange(MIN_CACHE_MINIUTES, MAX_CACHE_MINIUTES); + cached_minutes->setSingleStep(1); + cached_minutes->setValue(settings.max_cached_minutes); + main_layout->addWidget(groupbox); + + groupbox = new QGroupBox("New Signal Settings"); + form_layout = new QFormLayout(groupbox); + form_layout->addRow(tr("Drag Direction"), drag_direction = new QComboBox(this)); + drag_direction->addItems({tr("MSB First"), tr("LSB First"), tr("Always Little Endian"), tr("Always Big Endian")}); + drag_direction->setCurrentIndex(settings.drag_direction); + main_layout->addWidget(groupbox); + + groupbox = new QGroupBox("Chart"); + form_layout = new QFormLayout(groupbox); + form_layout->addRow(tr("Chart Height"), chart_height = new QSpinBox(this)); + chart_height->setRange(100, 500); + chart_height->setSingleStep(10); + chart_height->setValue(settings.chart_height); + main_layout->addWidget(groupbox); + + log_livestream = new QGroupBox(tr("Enable live stream logging"), this); + log_livestream->setCheckable(true); + log_livestream->setChecked(settings.log_livestream); + QHBoxLayout *path_layout = new QHBoxLayout(log_livestream); + path_layout->addWidget(log_path = new QLineEdit(QString::fromStdString(settings.log_path), this)); + log_path->setReadOnly(true); + auto browse_btn = new QPushButton(tr("B&rowse...")); + path_layout->addWidget(browse_btn); + main_layout->addWidget(log_livestream); + + auto buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + main_layout->addWidget(buttonBox); + setFixedSize(400, sizeHint().height()); + + QObject::connect(browse_btn, &QPushButton::clicked, [this]() { + QString fn = QFileDialog::getExistingDirectory( + this, tr("Log File Location"), + QString::fromStdString(utils::homePath()), + QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks); + if (!fn.isEmpty()) { + log_path->setText(fn); + } + }); + QObject::connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + QObject::connect(buttonBox, &QDialogButtonBox::accepted, this, &SettingsDialog::save); +} + +void SettingsDialog::save() { + if (std::exchange(settings.theme, theme->currentIndex()) != settings.theme) { + // set theme before emit changed + utils::setTheme(settings.theme); + } + settings.fps = fps->value(); + settings.max_cached_minutes = cached_minutes->value(); + settings.chart_height = chart_height->value(); + settings.log_livestream = log_livestream->isChecked(); + settings.log_path = log_path->text().toStdString(); + settings.drag_direction = (Settings::DragDirection)drag_direction->currentIndex(); + settings.changed(); + QDialog::accept(); +} diff --git a/openpilot/tools/cabana/settingsdialog.h b/openpilot/tools/cabana/settingsdialog.h new file mode 100644 index 0000000000..c5c723c1cb --- /dev/null +++ b/openpilot/tools/cabana/settingsdialog.h @@ -0,0 +1,21 @@ +#pragma once + +#include +#include +#include +#include +#include + +class SettingsDialog : public QDialog { +public: + SettingsDialog(QWidget *parent); + void save(); + QSpinBox *fps; + QSpinBox *cached_minutes; + QSpinBox *chart_height; + QComboBox *chart_series_type; + QComboBox *theme; + QGroupBox *log_livestream; + QLineEdit *log_path; + QComboBox *drag_direction; +}; diff --git a/openpilot/tools/cabana/utils/util.h b/openpilot/tools/cabana/utils/util.h index f2435143c1..77a634fc9e 100644 --- a/openpilot/tools/cabana/utils/util.h +++ b/openpilot/tools/cabana/utils/util.h @@ -12,9 +12,14 @@ #include #include +#include +#include #include #include +#include +#include #include +#include #include #include #include From 46f612224cf6953a845ddc6ed7aadaa9f2128e6b Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:57:47 -0700 Subject: [PATCH 37/67] cabana: split comma API route fetching out of RoutesDialog (#38721) --- openpilot/tools/cabana/SConscript | 5 +- openpilot/tools/cabana/routes.cc | 115 ++++++++++++++++++ openpilot/tools/cabana/routes.h | 42 +++++++ openpilot/tools/cabana/routesdialog.cc | 126 +++----------------- openpilot/tools/cabana/routesdialog.h | 7 +- openpilot/tools/cabana/tests/test_cabana.cc | 62 ++++++++++ 6 files changed, 246 insertions(+), 111 deletions(-) create mode 100644 openpilot/tools/cabana/routes.cc create mode 100644 openpilot/tools/cabana/routes.h diff --git a/openpilot/tools/cabana/SConscript b/openpilot/tools/cabana/SConscript index 75e67d99e3..f7db3f23b7 100644 --- a/openpilot/tools/cabana/SConscript +++ b/openpilot/tools/cabana/SConscript @@ -99,7 +99,7 @@ cabana_env.Command(assets, "assets/assets.qrc", f"rcc $SOURCES -o $TARGET") cabana_env.Depends(assets, Glob('/assets/*', exclude=[assets, "assets/assets.o"])) cabana_srcs = ['mainwin.cc', 'streams/pandastream.cc', 'streams/devicestream.cc', 'streams/livestream.cc', 'streams/abstractstream.cc', 'streams/replaystream.cc', 'binaryview.cc', 'historylog.cc', 'videowidget.cc', 'signalview.cc', - 'routesdialog.cc', 'dbc/dbc.cc', 'dbc/dbcfile.cc', 'dbc/dbcmanager.cc', + 'routesdialog.cc', 'routes.cc', 'dbc/dbc.cc', 'dbc/dbcfile.cc', 'dbc/dbcmanager.cc', 'utils/export.cc', 'utils/util.cc', 'utils/strings.cc', 'utils/elidedlabel.cc', 'chart/chartswidget.cc', 'chart/chart.cc', 'chart/signalselector.cc', 'chart/tiplabel.cc', 'chart/sparkline.cc', 'commands.cc', 'messageswidget.cc', 'streamselector.cc', 'settings.cc', 'settingsdialog.cc', 'panda.cc', @@ -120,8 +120,9 @@ if GetOption('extras'): dbc_core_test_env.Object('tests/dbc_core_file', 'dbc/dbcfile.cc'), dbc_core_test_env.Object('tests/dbc_core_manager', 'dbc/dbcmanager.cc'), dbc_core_test_env.Object('tests/dbc_core_strings', 'utils/strings.cc'), + dbc_core_test_env.Object('tests/dbc_core_routes', 'routes.cc'), ] - dbc_core_test_env.Program('tests/test_dbc_core', dbc_core_test_objects) + dbc_core_test_env.Program('tests/test_dbc_core', dbc_core_test_objects, LIBS=[replay_lib, common]) output_json_file = 'openpilot/tools/cabana/dbc/car_fingerprint_to_dbc.json' generate_dbc = cabana_env.Command('#' + output_json_file, diff --git a/openpilot/tools/cabana/routes.cc b/openpilot/tools/cabana/routes.cc new file mode 100644 index 0000000000..50a1eacf8d --- /dev/null +++ b/openpilot/tools/cabana/routes.cc @@ -0,0 +1,115 @@ +#include "tools/cabana/routes.h" + +#include +#include +#include +#include + +#include "json11/json11.hpp" +#include "tools/replay/py_downloader.h" + +namespace routes { + +std::pair checkApiResponse(const std::string &result) { + if (result.empty()) return {false, 500}; + std::string err; + auto doc = json11::Json::parse(result, err); + if (!err.empty()) return {false, 500}; + if (doc.is_object() && doc["error"].is_string()) { + return {false, doc["error"].string_value() == "unauthorized" ? 401 : 500}; + } + return {true, 0}; +} + +int64_t nowUnixMs() { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); +} + +int64_t parseIsoToUnixMs(const std::string &s) { + std::string bytes = s; + if (!bytes.empty() && (bytes.back() == 'Z' || bytes.back() == 'z')) bytes.pop_back(); + int millis = 0; + auto dot = bytes.find('.'); + if (dot != std::string::npos) { + std::string frac = bytes.substr(dot + 1); + bytes = bytes.substr(0, dot); + while (frac.size() < 3) frac.push_back('0'); + millis = std::atoi(frac.substr(0, 3).c_str()); + } + std::tm tm{}; + const char *ret = strptime(bytes.c_str(), "%Y-%m-%dT%H:%M:%S", &tm); + if (!ret) ret = strptime(bytes.c_str(), "%Y-%m-%d %H:%M:%S", &tm); + if (!ret) return 0; + time_t secs = timegm(&tm); + if (secs == static_cast(-1)) return 0; + return static_cast(secs) * 1000 + millis; +} + +std::string formatUnixMs(int64_t ms) { + time_t secs = static_cast(ms / 1000); + std::tm tm{}; + localtime_r(&secs, &tm); + char buf[64]; + std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm); + return buf; +} + +std::vector parseDevices(const std::string &json) { + std::vector devices; + std::string err; + auto doc = json11::Json::parse(json, err); + if (err.empty() && doc.is_array()) { + for (const auto &device : doc.array_items()) { + devices.push_back({device["dongle_id"].string_value()}); + } + } + return devices; +} + +std::vector parseRoutes(const std::string &json, bool preserved) { + std::vector items; + std::string err; + auto doc = json11::Json::parse(json, err); + if (err.empty() && doc.is_array()) { + for (const auto &route : doc.array_items()) { + RouteInfo info; + info.name = route["fullname"].string_value(); + if (preserved) { + info.start_ms = parseIsoToUnixMs(route["start_time"].string_value()); + info.end_ms = parseIsoToUnixMs(route["end_time"].string_value()); + } else { + info.start_ms = static_cast(route["start_time_utc_millis"].number_value()); + info.end_ms = static_cast(route["end_time_utc_millis"].number_value()); + } + items.push_back(std::move(info)); + } + } + return items; +} + +void fetchDevices(DevicesCallback callback) { + std::thread([callback = std::move(callback)]() { + std::string result = PyDownloader::getDevices(); + auto [success, error_code] = checkApiResponse(result); + callback(success ? parseDevices(result) : std::vector{}, success, error_code); + }).detach(); +} + +void fetchRoutes(const std::string &dongle_id, int period_days, RoutesCallback callback) { + const bool preserved = period_days == -1; + int64_t start_ms = 0, end_ms = 0; + if (!preserved) { + end_ms = nowUnixMs(); + start_ms = end_ms - static_cast(period_days) * 24LL * 60LL * 60LL * 1000LL; + } + + std::thread([dongle_id, start_ms, end_ms, preserved, callback = std::move(callback)]() { + std::string result = PyDownloader::getDeviceRoutes(dongle_id, start_ms, end_ms, preserved); + auto [success, error_code] = checkApiResponse(result); + callback(success ? parseRoutes(result, preserved) : std::vector{}, success, error_code); + }).detach(); +} + +} // namespace routes diff --git a/openpilot/tools/cabana/routes.h b/openpilot/tools/cabana/routes.h new file mode 100644 index 0000000000..ef6fc8dee0 --- /dev/null +++ b/openpilot/tools/cabana/routes.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace routes { + +struct DeviceInfo { + std::string dongle_id; +}; + +struct RouteInfo { + std::string name; + int64_t start_ms = 0; + int64_t end_ms = 0; +}; + +using DevicesCallback = std::function devices, bool success, int error_code)>; +using RoutesCallback = std::function routes, bool success, int error_code)>; + +// Parse a PyDownloader JSON response into (success, error_code). +std::pair checkApiResponse(const std::string &result); + +int64_t nowUnixMs(); +// Parse ISO-8601 (with optional fractional seconds / Z) to unix ms. Returns 0 on failure. +int64_t parseIsoToUnixMs(const std::string &s); +// Local time, "%Y-%m-%d %H:%M:%S". +std::string formatUnixMs(int64_t ms); + +std::vector parseDevices(const std::string &json); +// preserved routes report ISO-8601 timestamps instead of unix millis +std::vector parseRoutes(const std::string &json, bool preserved); + +// Both fetch on a detached thread and invoke the callback from that thread. +void fetchDevices(DevicesCallback callback); +// period_days of -1 requests preserved routes +void fetchRoutes(const std::string &dongle_id, int period_days, RoutesCallback callback); + +} // namespace routes diff --git a/openpilot/tools/cabana/routesdialog.cc b/openpilot/tools/cabana/routesdialog.cc index 9891104c9f..33b773edb9 100644 --- a/openpilot/tools/cabana/routesdialog.cc +++ b/openpilot/tools/cabana/routesdialog.cc @@ -1,9 +1,6 @@ #include "tools/cabana/routesdialog.h" -#include -#include #include -#include #include #include @@ -12,62 +9,7 @@ #include #include -#include "json11/json11.hpp" #include "tools/cabana/utils/util.h" -#include "tools/replay/py_downloader.h" - -namespace { - -// Parse a PyDownloader JSON response into (success, error_code). -std::pair checkApiResponse(const std::string &result) { - if (result.empty()) return {false, 500}; - std::string err; - auto doc = json11::Json::parse(result, err); - if (!err.empty()) return {false, 500}; - if (doc.is_object() && doc["error"].is_string()) { - return {false, doc["error"].string_value() == "unauthorized" ? 401 : 500}; - } - return {true, 0}; -} - -int64_t nowUnixMs() { - return std::chrono::duration_cast( - std::chrono::system_clock::now().time_since_epoch()) - .count(); -} - -// Parse ISO-8601 (with optional fractional seconds / Z) to unix ms. Returns 0 on failure. -int64_t parseIsoToUnixMs(const std::string &s) { - std::string bytes = s; - if (!bytes.empty() && (bytes.back() == 'Z' || bytes.back() == 'z')) bytes.pop_back(); - int millis = 0; - auto dot = bytes.find('.'); - if (dot != std::string::npos) { - std::string frac = bytes.substr(dot + 1); - bytes = bytes.substr(0, dot); - while (frac.size() < 3) frac.push_back('0'); - millis = std::atoi(frac.substr(0, 3).c_str()); - } - std::tm tm{}; - const char *ret = strptime(bytes.c_str(), "%Y-%m-%dT%H:%M:%S", &tm); - if (!ret) ret = strptime(bytes.c_str(), "%Y-%m-%d %H:%M:%S", &tm); - if (!ret) return 0; - tm.tm_isdst = -1; - time_t secs = timegm(&tm); - if (secs == static_cast(-1)) return 0; - return static_cast(secs) * 1000 + millis; -} - -std::string formatUnixMs(int64_t ms) { - time_t secs = static_cast(ms / 1000); - std::tm tm{}; - localtime_r(&secs, &tm); - char buf[64]; - std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm); - return buf; -} - -} // namespace // The RouteListWidget class extends QListWidget to display a custom message when empty class RouteListWidget : public QListWidget { @@ -110,26 +52,19 @@ RoutesDialog::RoutesDialog(QWidget *parent) : QDialog(parent) { connect(button_box, &QDialogButtonBox::accepted, this, &QDialog::accept); connect(button_box, &QDialogButtonBox::rejected, this, &QDialog::reject); - // Fetch devices - std::thread([this, alive = std::weak_ptr(alive_)]() { - std::string result = PyDownloader::getDevices(); - auto response = checkApiResponse(result); - utils::runOnMainThread([this, alive, r = std::move(result), response]() { - if (!alive.expired()) parseDeviceList(r, response.first, response.second); + routes::fetchDevices([this, alive = std::weak_ptr(alive_)](std::vector devices, bool success, int error_code) { + utils::runOnMainThread([this, alive, devices = std::move(devices), success, error_code]() { + if (!alive.expired()) setDeviceList(devices, success, error_code); }); - }).detach(); + }); } -void RoutesDialog::parseDeviceList(const std::string &json, bool success, int error_code) { +void RoutesDialog::setDeviceList(const std::vector &devices, bool success, int error_code) { if (success) { device_list_->clear(); - std::string err; - auto doc = json11::Json::parse(json, err); - if (err.empty() && doc.is_array()) { - for (const auto &device : doc.array_items()) { - QString dongle_id = QString::fromStdString(device["dongle_id"].string_value()); - device_list_->addItem(dongle_id, dongle_id); - } + for (const auto &device : devices) { + QString dongle_id = QString::fromStdString(device.dongle_id); + device_list_->addItem(dongle_id, dongle_id); } } else { QMessageBox::warning(this, tr("Error"), error_code == 401 ? tr("Unauthorized. Authenticate with openpilot/tools/lib/auth.py") : tr("Network error")); @@ -144,45 +79,22 @@ void RoutesDialog::fetchRoutes() { route_list_->clear(); route_list_->setEmptyText(tr("Loading...")); - std::string did = device_list_->currentText().toStdString(); - int period = period_selector_->currentData().toInt(); - - bool preserved = (period == -1); - int64_t start_ms = 0, end_ms = 0; - if (!preserved) { - end_ms = nowUnixMs(); - start_ms = end_ms - static_cast(period) * 24LL * 60LL * 60LL * 1000LL; - } - int request_id = ++fetch_id_; - std::thread([this, alive = std::weak_ptr(alive_), did, start_ms, end_ms, preserved, request_id]() { - std::string result = PyDownloader::getDeviceRoutes(did, start_ms, end_ms, preserved); - auto response = checkApiResponse(result); - utils::runOnMainThread([this, alive, r = std::move(result), response, request_id]() { - if (!alive.expired() && fetch_id_ == request_id) parseRouteList(r, response.first, response.second); + auto on_routes = [this, alive = std::weak_ptr(alive_), request_id](std::vector list, bool success, int) { + utils::runOnMainThread([this, alive, list = std::move(list), success, request_id]() { + if (!alive.expired() && fetch_id_ == request_id) setRouteList(list, success); }); - }).detach(); + }; + routes::fetchRoutes(device_list_->currentText().toStdString(), period_selector_->currentData().toInt(), std::move(on_routes)); } -void RoutesDialog::parseRouteList(const std::string &json, bool success, int error_code) { +void RoutesDialog::setRouteList(const std::vector &list, bool success) { if (success) { - std::string err; - auto doc = json11::Json::parse(json, err); - if (err.empty() && doc.is_array()) { - for (const auto &route : doc.array_items()) { - int64_t from_ms = 0, to_ms = 0; - if (period_selector_->currentData().toInt() == -1) { - from_ms = parseIsoToUnixMs(route["start_time"].string_value()); - to_ms = parseIsoToUnixMs(route["end_time"].string_value()); - } else { - from_ms = static_cast(route["start_time_utc_millis"].number_value()); - to_ms = static_cast(route["end_time_utc_millis"].number_value()); - } - const int mins = static_cast((to_ms - from_ms) / 60000); - auto item = new QListWidgetItem(QString::fromStdString(formatUnixMs(from_ms) + " " + std::to_string(mins) + "min")); - item->setData(Qt::UserRole, QString::fromStdString(route["fullname"].string_value())); - route_list_->addItem(item); - } + for (const auto &route : list) { + const int mins = static_cast((route.end_ms - route.start_ms) / 60000); + auto item = new QListWidgetItem(QString::fromStdString(routes::formatUnixMs(route.start_ms) + " " + std::to_string(mins) + "min")); + item->setData(Qt::UserRole, QString::fromStdString(route.name)); + route_list_->addItem(item); } if (route_list_->count() > 0) route_list_->setCurrentRow(0); } else { diff --git a/openpilot/tools/cabana/routesdialog.h b/openpilot/tools/cabana/routesdialog.h index 44d7f068f9..4983f1b558 100644 --- a/openpilot/tools/cabana/routesdialog.h +++ b/openpilot/tools/cabana/routesdialog.h @@ -2,10 +2,13 @@ #include #include +#include #include #include +#include "tools/cabana/routes.h" + class RouteListWidget; class RoutesDialog : public QDialog { @@ -15,8 +18,8 @@ public: std::string route(); protected: - void parseDeviceList(const std::string &json, bool success, int error_code); - void parseRouteList(const std::string &json, bool success, int error_code); + void setDeviceList(const std::vector &devices, bool success, int error_code); + void setRouteList(const std::vector &list, bool success); void fetchRoutes(); QComboBox *device_list_; diff --git a/openpilot/tools/cabana/tests/test_cabana.cc b/openpilot/tools/cabana/tests/test_cabana.cc index e3b8377e02..5c5fce9864 100644 --- a/openpilot/tools/cabana/tests/test_cabana.cc +++ b/openpilot/tools/cabana/tests/test_cabana.cc @@ -7,6 +7,7 @@ #include "common/tests/native_test.h" #include "tools/cabana/dbc/dbcfile.h" #include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/routes.h" #include "tools/cabana/utils/strings.h" const std::string TEST_RLOG_URL = "https://commadataci.blob.core.windows.net/openpilotci/0c94aa1e1296d7c6/2021-05-05--19-48-37/0/rlog.bz2"; @@ -240,6 +241,64 @@ void test_signal_tooltip() { )"); } +void test_route_timestamps() { + REQUIRE(routes::parseIsoToUnixMs("2024-01-02T03:04:05Z") == 1704164645000); + REQUIRE(routes::parseIsoToUnixMs("2024-01-02T03:04:05") == 1704164645000); + REQUIRE(routes::parseIsoToUnixMs("2024-01-02 03:04:05") == 1704164645000); + REQUIRE(routes::parseIsoToUnixMs("2024-01-02T03:04:05.123Z") == 1704164645123); + REQUIRE(routes::parseIsoToUnixMs("2024-01-02T03:04:05.4Z") == 1704164645400); + REQUIRE(routes::parseIsoToUnixMs("2024-01-02T03:04:05.123456Z") == 1704164645123); + REQUIRE(routes::parseIsoToUnixMs("") == 0); + REQUIRE(routes::parseIsoToUnixMs("not a timestamp") == 0); + + // formatUnixMs is local time + const char *tz = getenv("TZ"); + const std::string prev_tz = tz ? tz : ""; + setenv("TZ", "UTC", 1); + tzset(); + REQUIRE(routes::formatUnixMs(1704164645123) == "2024-01-02 03:04:05"); + if (tz) { + setenv("TZ", prev_tz.c_str(), 1); + } else { + unsetenv("TZ"); + } + tzset(); +} + +void test_route_api_response() { + REQUIRE(routes::checkApiResponse("") == std::make_pair(false, 500)); + REQUIRE(routes::checkApiResponse("not json") == std::make_pair(false, 500)); + REQUIRE(routes::checkApiResponse(R"({"error": "unauthorized"})") == std::make_pair(false, 401)); + REQUIRE(routes::checkApiResponse(R"({"error": "server error"})") == std::make_pair(false, 500)); + REQUIRE(routes::checkApiResponse("[]") == std::make_pair(true, 0)); + REQUIRE(routes::checkApiResponse(R"({"dongle_id": "aaaa"})") == std::make_pair(true, 0)); +} + +void test_route_json() { + auto devices = routes::parseDevices(R"([{"dongle_id": "aaaa"}, {"dongle_id": "bbbb"}])"); + REQUIRE(devices.size() == 2); + REQUIRE(devices[0].dongle_id == "aaaa"); + REQUIRE(devices[1].dongle_id == "bbbb"); + REQUIRE(routes::parseDevices("not json").empty()); + REQUIRE(routes::parseDevices(R"({"error": "unauthorized"})").empty()); + + auto list = routes::parseRoutes( + R"([{"fullname": "aaaa|2024-01-02--03-04-05", "start_time_utc_millis": 1704164645000, "end_time_utc_millis": 1704165245000}])", false); + REQUIRE(list.size() == 1); + REQUIRE(list[0].name == "aaaa|2024-01-02--03-04-05"); + REQUIRE(list[0].start_ms == 1704164645000); + REQUIRE(list[0].end_ms == 1704165245000); + + // preserved routes report ISO-8601 timestamps + auto preserved = routes::parseRoutes( + R"([{"fullname": "aaaa|2024-01-02--03-04-05", "start_time": "2024-01-02T03:04:05Z", "end_time": "2024-01-02T03:14:05Z"}])", true); + REQUIRE(preserved.size() == 1); + REQUIRE(preserved[0].start_ms == 1704164645000); + REQUIRE(preserved[0].end_ms == 1704165245000); + + REQUIRE(routes::parseRoutes("not json", false).empty()); +} + void test_cabana_core() { test_format_seconds(); test_to_hex(); @@ -251,6 +310,9 @@ void test_cabana_core() { test_parse_dbc(); test_parse_opendbc(); test_dbc_manager(); + test_route_timestamps(); + test_route_api_response(); + test_route_json(); } int main() { From 5419f57b3a63f581dde6b0485624f0a2f698d878 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:18:10 -0700 Subject: [PATCH 38/67] cabana: de-QT streams (#38718) --- openpilot/tools/cabana/cabana.cc | 3 ++- openpilot/tools/cabana/mainwin.cc | 3 +++ .../tools/cabana/streams/abstractstream.h | 1 + .../tools/cabana/streams/devicestream.cc | 26 +++++++------------ openpilot/tools/cabana/streams/devicestream.h | 7 ++--- .../tools/cabana/streams/replaystream.cc | 18 ++++++------- openpilot/tools/cabana/streamselector.cc | 5 +++- openpilot/tools/cabana/utils/util.cc | 22 ++++++++++++---- openpilot/tools/cabana/utils/util.h | 1 + 9 files changed, 50 insertions(+), 36 deletions(-) diff --git a/openpilot/tools/cabana/cabana.cc b/openpilot/tools/cabana/cabana.cc index e3a1850346..6cc6046bec 100644 --- a/openpilot/tools/cabana/cabana.cc +++ b/openpilot/tools/cabana/cabana.cc @@ -148,7 +148,7 @@ int main(int argc, char *argv[]) { if (args.msgq) { stream = new DeviceStream(); } else if (!args.zmq.empty()) { - stream = new DeviceStream(QString::fromStdString(args.zmq)); + stream = new DeviceStream(args.zmq); } else if (args.panda || !args.panda_serial.empty()) { try { stream = new PandaStream({.serial = args.panda_serial}); @@ -175,6 +175,7 @@ int main(int argc, char *argv[]) { } if (!route.isEmpty()) { auto replay_stream = std::make_unique(); + Connection err = replay_stream->error.connect([](const std::string &msg) { fprintf(stderr, "%s\n", msg.c_str()); }); if (!replay_stream->loadRoute(route.toStdString(), args.data_dir, replay_flags, args.auto_source)) { return 0; } diff --git a/openpilot/tools/cabana/mainwin.cc b/openpilot/tools/cabana/mainwin.cc index 3ae5896a2d..939aade269 100644 --- a/openpilot/tools/cabana/mainwin.cc +++ b/openpilot/tools/cabana/mainwin.cc @@ -358,6 +358,9 @@ void MainWindow::startStream(AbstractStream *stream, QString dbc_file) { delete video_splitter; can = stream; // take ownership + stream_connections_.push_back(can->error.connect([this](const std::string &msg) { + QMessageBox::warning(this, tr("Error"), QString::fromStdString(msg)); + })); can->start(); loadFile(dbc_file); diff --git a/openpilot/tools/cabana/streams/abstractstream.h b/openpilot/tools/cabana/streams/abstractstream.h index 4f7c0dc3e7..b992b086fd 100644 --- a/openpilot/tools/cabana/streams/abstractstream.h +++ b/openpilot/tools/cabana/streams/abstractstream.h @@ -64,6 +64,7 @@ public: Observable> &> timeRangeChanged; Observable eventsMerged; Observable *, bool> msgsReceived; + Observable error; SourceSet sources; diff --git a/openpilot/tools/cabana/streams/devicestream.cc b/openpilot/tools/cabana/streams/devicestream.cc index 6d580cc912..3a338c7f59 100644 --- a/openpilot/tools/cabana/streams/devicestream.cc +++ b/openpilot/tools/cabana/streams/devicestream.cc @@ -9,17 +9,16 @@ #include #include #include +#include #include #include #include "openpilot/cereal/services.h" - -#include -#include +#include "tools/cabana/utils/util.h" // DeviceStream -DeviceStream::DeviceStream(QString address) : zmq_address(address) { +DeviceStream::DeviceStream(std::string address) : zmq_address(std::move(address)) { } DeviceStream::~DeviceStream() { @@ -46,19 +45,16 @@ void DeviceStream::stopBridge() { } void DeviceStream::start() { - if (!zmq_address.isEmpty()) { + if (!zmq_address.empty()) { stopBridge(); - const std::string path = (std::filesystem::path(QCoreApplication::applicationDirPath().toStdString()) / - "../../cereal/messaging/bridge").lexically_normal().string(); - const std::string addr = zmq_address.toStdString(); + const std::string path = (executableDir() / "../../cereal/messaging/bridge").lexically_normal().string(); const char *can_filter = "/\"can/\""; // Self-pipe: write end is CLOEXEC so it closes on successful exec. If exec // fails, the child writes errno and the parent aborts stream start. int err_pipe[2] = {-1, -1}; if (::pipe(err_pipe) != 0) { - QMessageBox::warning(nullptr, "Error", - QString("Failed to start bridge: %1").arg(QString::fromLocal8Bit(strerror(errno)))); + error(std::string("Failed to start bridge: ") + strerror(errno)); return; } @@ -66,7 +62,7 @@ void DeviceStream::start() { if (pid == 0) { ::close(err_pipe[0]); ::fcntl(err_pipe[1], F_SETFD, FD_CLOEXEC); - execl(path.c_str(), path.c_str(), addr.c_str(), can_filter, static_cast(nullptr)); + execl(path.c_str(), path.c_str(), zmq_address.c_str(), can_filter, static_cast(nullptr)); const int err = errno; (void)!::write(err_pipe[1], &err, sizeof(err)); _exit(127); @@ -75,8 +71,7 @@ void DeviceStream::start() { ::close(err_pipe[1]); if (pid < 0) { ::close(err_pipe[0]); - QMessageBox::warning(nullptr, "Error", - QString("Failed to start bridge: %1").arg(QString::fromLocal8Bit(strerror(errno)))); + error(std::string("Failed to start bridge: ") + strerror(errno)); return; } @@ -87,8 +82,7 @@ void DeviceStream::start() { // Child failed to exec; reap and surface the error. int status = 0; ::waitpid(pid, &status, 0); - QMessageBox::warning(nullptr, "Error", - QString("Failed to start bridge: %1").arg(QString::fromLocal8Bit(strerror(exec_errno)))); + error(std::string("Failed to start bridge: ") + strerror(exec_errno)); return; } @@ -99,7 +93,7 @@ void DeviceStream::start() { } void DeviceStream::streamThread() { - zmq_address.isEmpty() ? unsetenv("ZMQ") : setenv("ZMQ", "1", 1); + zmq_address.empty() ? unsetenv("ZMQ") : setenv("ZMQ", "1", 1); std::unique_ptr context(Context::create()); std::unique_ptr sock(SubSocket::create(context.get(), "can", "127.0.0.1", false, true, services.at("can").queue_size)); diff --git a/openpilot/tools/cabana/streams/devicestream.h b/openpilot/tools/cabana/streams/devicestream.h index 71205e5a80..3770d952aa 100644 --- a/openpilot/tools/cabana/streams/devicestream.h +++ b/openpilot/tools/cabana/streams/devicestream.h @@ -2,14 +2,15 @@ #include "tools/cabana/streams/livestream.h" +#include #include class DeviceStream : public LiveStream { public: - DeviceStream(QString address = {}); + DeviceStream(std::string address = {}); ~DeviceStream(); inline std::string routeName() const override { - return "Live Streaming From " + (zmq_address.isEmpty() ? std::string("127.0.0.1") : zmq_address.toStdString()); + return "Live Streaming From " + (zmq_address.empty() ? std::string("127.0.0.1") : zmq_address); } protected: @@ -17,5 +18,5 @@ protected: void streamThread() override; void stopBridge(); pid_t bridge_pid = -1; - const QString zmq_address; + const std::string zmq_address; }; diff --git a/openpilot/tools/cabana/streams/replaystream.cc b/openpilot/tools/cabana/streams/replaystream.cc index e9df400599..81ed34e8db 100644 --- a/openpilot/tools/cabana/streams/replaystream.cc +++ b/openpilot/tools/cabana/streams/replaystream.cc @@ -1,6 +1,6 @@ #include "tools/cabana/streams/replaystream.h" -#include +#include #include "common/timing.h" #include "common/util.h" @@ -59,27 +59,25 @@ bool ReplayStream::loadRoute(const std::string &route, const std::string &data_d bool success = replay->load(); if (!success) { + std::string message; if (replay->lastRouteError() == RouteLoadError::Unauthorized) { auto auth_content = util::read_file(util::getenv("HOME") + "/.comma/auth.json"); - QString message; if (auth_content.empty()) { message = "Authentication Required. Please run the following command to authenticate:\n\n" "python3 openpilot/tools/lib/auth.py\n\n" "This will grant access to routes from your comma account."; } else { - message = QString("Access Denied. You do not have permission to access route:\n\n%1\n\n" - "This is likely a private route.").arg(QString::fromStdString(route)); + message = "Access Denied. You do not have permission to access route:\n\n" + route + "\n\n" + "This is likely a private route."; } - QMessageBox::warning(nullptr, "Access Denied", message); } else if (replay->lastRouteError() == RouteLoadError::NetworkError) { - QMessageBox::warning(nullptr, "Network Error", - QString("Unable to load the route:\n\n %1.\n\nPlease check your network connection and try again.").arg(QString::fromStdString(route))); + message = "Unable to load the route:\n\n " + route + ".\n\nPlease check your network connection and try again."; } else if (replay->lastRouteError() == RouteLoadError::FileNotFound) { - QMessageBox::warning(nullptr, "Route Not Found", - QString("The specified route could not be found:\n\n %1.\n\nPlease check the route name and try again.").arg(QString::fromStdString(route))); + message = "The specified route could not be found:\n\n " + route + ".\n\nPlease check the route name and try again."; } else { - QMessageBox::warning(nullptr, "Route Load Failed", QString("Failed to load route: '%1'").arg(QString::fromStdString(route))); + message = "Failed to load route: '" + route + "'"; } + error(message); } return success; } diff --git a/openpilot/tools/cabana/streamselector.cc b/openpilot/tools/cabana/streamselector.cc index dae022be75..8c95bf584b 100644 --- a/openpilot/tools/cabana/streamselector.cc +++ b/openpilot/tools/cabana/streamselector.cc @@ -63,6 +63,9 @@ AbstractStream *OpenReplayWidget::open() { QMessageBox::warning(nullptr, tr("Warning"), tr("Invalid route format: '%1'").arg(route)); } else { auto replay_stream = std::make_unique(); + Connection err = replay_stream->error.connect([](const std::string &msg) { + QMessageBox::warning(nullptr, tr("Error"), QString::fromStdString(msg)); + }); uint32_t flags = REPLAY_FLAG_NONE; if (cameras[1]->isChecked()) flags |= REPLAY_FLAG_CABIN_CAMERA; if (cameras[2]->isChecked()) flags |= REPLAY_FLAG_WIDE_ROAD; @@ -211,7 +214,7 @@ OpenDeviceWidget::OpenDeviceWidget(QWidget *parent) : AbstractOpenStreamWidget(p } AbstractStream *OpenDeviceWidget::open() { - QString ip = ip_address->text().isEmpty() ? "127.0.0.1" : ip_address->text(); + std::string ip = ip_address->text().isEmpty() ? "127.0.0.1" : ip_address->text().toStdString(); bool msgq = group->checkedId() == 0; return new DeviceStream(msgq ? "" : ip); } diff --git a/openpilot/tools/cabana/utils/util.cc b/openpilot/tools/cabana/utils/util.cc index 9a44ef5054..d5cd77a8c8 100644 --- a/openpilot/tools/cabana/utils/util.cc +++ b/openpilot/tools/cabana/utils/util.cc @@ -16,6 +16,9 @@ #include #include #include +#ifdef __APPLE__ +#include +#endif #include #include @@ -454,27 +457,36 @@ void sigTermHandler(int s) { qApp->quit(); } +std::filesystem::path executableDir() { +#ifdef __APPLE__ + char buf[PATH_MAX]; + uint32_t size = sizeof(buf); + if (_NSGetExecutablePath(buf, &size) != 0) return {}; + std::error_code ec; + auto path = std::filesystem::canonical(buf, ec); + return (ec ? std::filesystem::path(buf) : path).parent_path(); +#else + return std::filesystem::path(util::readlink("/proc/self/exe")).parent_path(); +#endif +} + void initApp(int argc, char *argv[], bool disable_hidpi) { // setup signal handlers to exit gracefully std::signal(SIGINT, sigTermHandler); std::signal(SIGTERM, sigTermHandler); - std::filesystem::path app_dir; #ifdef __APPLE__ // Get the devicePixelRatio, and scale accordingly to maintain 1:1 rendering QApplication tmp(argc, argv); - app_dir = QCoreApplication::applicationDirPath().toStdString(); if (disable_hidpi) { qputenv("QT_SCALE_FACTOR", QString::number(1.0 / tmp.devicePixelRatio()).toLocal8Bit()); } -#else - app_dir = std::filesystem::path(util::readlink("/proc/self/exe")).parent_path(); #endif qputenv("QT_DBL_CLICK_DIST", "150"); // ensure the current dir matches the exectuable's directory std::error_code ec; - std::filesystem::current_path(app_dir, ec); + std::filesystem::current_path(executableDir(), ec); } // embedded at build time from the bootstrap_icons package (see SConscript) diff --git a/openpilot/tools/cabana/utils/util.h b/openpilot/tools/cabana/utils/util.h index 77a634fc9e..e0d13208a6 100644 --- a/openpilot/tools/cabana/utils/util.h +++ b/openpilot/tools/cabana/utils/util.h @@ -219,5 +219,6 @@ private: }; int num_decimals(double num); +std::filesystem::path executableDir(); void initApp(int argc, char *argv[], bool disable_hidpi = true); QPixmap bootstrapPixmap(const QString &id); From 633d17cd128690e00b5a24dfd6747141c8d34a2e Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:30:13 -0700 Subject: [PATCH 39/67] ui: fix install update button overflow (#38696) --- openpilot/selfdrive/ui/mici/layouts/settings/software.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/software.py b/openpilot/selfdrive/ui/mici/layouts/settings/software.py index 0f12004828..32fbc2b3a7 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/software.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/software.py @@ -184,7 +184,7 @@ class CheckUpdateButton(BigButton): class InstallUpdateButton(BigButton): def __init__(self): - super().__init__("install update", "", gui_app.texture("icons_mici/settings/device/reboot.png", 64, 70)) + super().__init__("install now", "", gui_app.texture("icons_mici/settings/device/reboot.png", 64, 70)) self.set_visible(lambda: ui_state.is_offroad() and ui_state.params.get_bool("UpdateAvailable")) def _update_state(self): From 5645370f845ce70449fdf87ebfa544a2fbc36101 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:37:09 -0700 Subject: [PATCH 40/67] cabana: split utils/util into Qt-free util and qtutil (#38723) --- openpilot/tools/cabana/SConscript | 4 +- openpilot/tools/cabana/binaryview.cc | 1 + openpilot/tools/cabana/binaryview.h | 1 + openpilot/tools/cabana/cabana.cc | 10 +- openpilot/tools/cabana/chart/chart.cc | 2 +- openpilot/tools/cabana/chart/chart.h | 1 + openpilot/tools/cabana/chart/chartswidget.h | 1 + .../tools/cabana/chart/signalselector.cc | 1 + openpilot/tools/cabana/chart/sparkline.cc | 1 + openpilot/tools/cabana/chart/tiplabel.cc | 2 +- openpilot/tools/cabana/commands.cc | 1 + openpilot/tools/cabana/detailwidget.h | 2 + openpilot/tools/cabana/historylog.h | 1 + openpilot/tools/cabana/mainwin.cc | 1 + openpilot/tools/cabana/messageswidget.h | 2 + openpilot/tools/cabana/settingsdialog.cc | 2 +- openpilot/tools/cabana/signalview.cc | 3 +- .../tools/cabana/streams/abstractstream.cc | 1 + .../tools/cabana/streams/devicestream.cc | 1 + openpilot/tools/cabana/streams/livestream.cc | 1 + .../tools/cabana/streams/replaystream.cc | 1 + openpilot/tools/cabana/streamselector.cc | 1 + openpilot/tools/cabana/tools/findsignal.cc | 2 + openpilot/tools/cabana/tools/findsignal.h | 5 + openpilot/tools/cabana/utils/qtutil.cc | 236 ++++++++++ openpilot/tools/cabana/utils/qtutil.h | 139 ++++++ openpilot/tools/cabana/utils/util.cc | 410 +++++------------- openpilot/tools/cabana/utils/util.h | 212 +++------ openpilot/tools/cabana/videowidget.cc | 1 + openpilot/tools/cabana/videowidget.h | 2 +- 30 files changed, 566 insertions(+), 482 deletions(-) create mode 100644 openpilot/tools/cabana/utils/qtutil.cc create mode 100644 openpilot/tools/cabana/utils/qtutil.h diff --git a/openpilot/tools/cabana/SConscript b/openpilot/tools/cabana/SConscript index f7db3f23b7..63b725a759 100644 --- a/openpilot/tools/cabana/SConscript +++ b/openpilot/tools/cabana/SConscript @@ -100,7 +100,7 @@ cabana_env.Depends(assets, Glob('/assets/*', exclude=[assets, "assets/assets.o"] cabana_srcs = ['mainwin.cc', 'streams/pandastream.cc', 'streams/devicestream.cc', 'streams/livestream.cc', 'streams/abstractstream.cc', 'streams/replaystream.cc', 'binaryview.cc', 'historylog.cc', 'videowidget.cc', 'signalview.cc', 'routesdialog.cc', 'routes.cc', 'dbc/dbc.cc', 'dbc/dbcfile.cc', 'dbc/dbcmanager.cc', - 'utils/export.cc', 'utils/util.cc', 'utils/strings.cc', 'utils/elidedlabel.cc', + 'utils/export.cc', 'utils/util.cc', 'utils/qtutil.cc', 'utils/strings.cc', 'utils/elidedlabel.cc', 'chart/chartswidget.cc', 'chart/chart.cc', 'chart/signalselector.cc', 'chart/tiplabel.cc', 'chart/sparkline.cc', 'commands.cc', 'messageswidget.cc', 'streamselector.cc', 'settings.cc', 'settingsdialog.cc', 'panda.cc', 'cameraview.cc', 'detailwidget.cc', 'tools/findsimilarbits.cc', 'tools/findsignal.cc', 'tools/routeinfo.cc'] @@ -120,6 +120,8 @@ if GetOption('extras'): dbc_core_test_env.Object('tests/dbc_core_file', 'dbc/dbcfile.cc'), dbc_core_test_env.Object('tests/dbc_core_manager', 'dbc/dbcmanager.cc'), dbc_core_test_env.Object('tests/dbc_core_strings', 'utils/strings.cc'), + dbc_core_test_env.Object('tests/dbc_core_util', 'utils/util.cc'), + dbc_core_test_env.Object('tests/dbc_core_icons', bootstrap_icons_src), dbc_core_test_env.Object('tests/dbc_core_routes', 'routes.cc'), ] dbc_core_test_env.Program('tests/test_dbc_core', dbc_core_test_objects, LIBS=[replay_lib, common]) diff --git a/openpilot/tools/cabana/binaryview.cc b/openpilot/tools/cabana/binaryview.cc index 160aead6fd..20fd2ec1ec 100644 --- a/openpilot/tools/cabana/binaryview.cc +++ b/openpilot/tools/cabana/binaryview.cc @@ -13,6 +13,7 @@ #include #include "tools/cabana/commands.h" +#include "tools/cabana/utils/qtutil.h" // BinaryView diff --git a/openpilot/tools/cabana/binaryview.h b/openpilot/tools/cabana/binaryview.h index 0b0159b422..c918ee01e7 100644 --- a/openpilot/tools/cabana/binaryview.h +++ b/openpilot/tools/cabana/binaryview.h @@ -4,6 +4,7 @@ #include #include +#include #include #include diff --git a/openpilot/tools/cabana/cabana.cc b/openpilot/tools/cabana/cabana.cc index 6cc6046bec..b18484c556 100644 --- a/openpilot/tools/cabana/cabana.cc +++ b/openpilot/tools/cabana/cabana.cc @@ -12,6 +12,7 @@ #ifdef __linux__ #include "tools/cabana/streams/socketcanstream.h" #endif +#include "tools/cabana/utils/qtutil.h" namespace { @@ -134,7 +135,14 @@ int main(int argc, char *argv[]) { app.setApplicationDisplayName("Cabana"); //app.setWindowIcon(QIcon(":cabana-icon.png")); // TODO: do this in imgui - UnixSignalHandler signalHandler; + // Marshal exit onto the GUI thread (qApp methods are not thread-safe). + UnixSignalHandler signalHandler([]() { + QMetaObject::invokeMethod(qApp, []() { + printf("\nexiting...\n"); + qApp->closeAllWindows(); + qApp->exit(); + }, Qt::QueuedConnection); + }); utils::setTheme(settings.theme); CabanaArgs args; diff --git a/openpilot/tools/cabana/chart/chart.cc b/openpilot/tools/cabana/chart/chart.cc index beece12352..478ff3ce9d 100644 --- a/openpilot/tools/cabana/chart/chart.cc +++ b/openpilot/tools/cabana/chart/chart.cc @@ -248,7 +248,7 @@ void ChartView::updateSeries(const cabana::Signal *sig, const MessageEventsMap * } if (!can->liveStreaming()) { - s.segment_tree.build(s.vals); + s.segment_tree.build(s.vals.size(), [&vals = s.vals](int i) { return vals[i].y(); }); } } } diff --git a/openpilot/tools/cabana/chart/chart.h b/openpilot/tools/cabana/chart/chart.h index d7227b6cf8..3188e1f401 100644 --- a/openpilot/tools/cabana/chart/chart.h +++ b/openpilot/tools/cabana/chart/chart.h @@ -10,6 +10,7 @@ #include "tools/cabana/chart/tiplabel.h" #include "tools/cabana/dbc/dbcmanager.h" #include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/utils/qtutil.h" enum class SeriesType { Line = 0, diff --git a/openpilot/tools/cabana/chart/chartswidget.h b/openpilot/tools/cabana/chart/chartswidget.h index 4c143242b2..17e18a90d2 100644 --- a/openpilot/tools/cabana/chart/chartswidget.h +++ b/openpilot/tools/cabana/chart/chartswidget.h @@ -13,6 +13,7 @@ #include "tools/cabana/commands.h" #include "tools/cabana/dbc/dbcmanager.h" #include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/utils/qtutil.h" const int CHART_MIN_WIDTH = 300; diff --git a/openpilot/tools/cabana/chart/signalselector.cc b/openpilot/tools/cabana/chart/signalselector.cc index aab38f5372..90825dc402 100644 --- a/openpilot/tools/cabana/chart/signalselector.cc +++ b/openpilot/tools/cabana/chart/signalselector.cc @@ -8,6 +8,7 @@ #include #include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/utils/qtutil.h" SignalSelector::SignalSelector(QString title, QWidget *parent) : QDialog(parent) { setWindowTitle(title); diff --git a/openpilot/tools/cabana/chart/sparkline.cc b/openpilot/tools/cabana/chart/sparkline.cc index f5bef0fc2e..587a35956d 100644 --- a/openpilot/tools/cabana/chart/sparkline.cc +++ b/openpilot/tools/cabana/chart/sparkline.cc @@ -3,6 +3,7 @@ #include #include #include +#include "tools/cabana/utils/qtutil.h" void Sparkline::update(const cabana::Signal *sig, CanEventIter first, CanEventIter last, int range, QSize size) { if (first == last || size.isEmpty()) { diff --git a/openpilot/tools/cabana/chart/tiplabel.cc b/openpilot/tools/cabana/chart/tiplabel.cc index 233fa80373..c250ebf877 100644 --- a/openpilot/tools/cabana/chart/tiplabel.cc +++ b/openpilot/tools/cabana/chart/tiplabel.cc @@ -7,7 +7,7 @@ #include #include "tools/cabana/settings.h" -#include "tools/cabana/utils/util.h" +#include "tools/cabana/utils/qtutil.h" TipLabel::TipLabel(QWidget *parent) : QLabel(parent, Qt::ToolTip | Qt::FramelessWindowHint) { setAttribute(Qt::WA_ShowWithoutActivating); diff --git a/openpilot/tools/cabana/commands.cc b/openpilot/tools/cabana/commands.cc index c6cdd9b1b7..40d1c5ba18 100644 --- a/openpilot/tools/cabana/commands.cc +++ b/openpilot/tools/cabana/commands.cc @@ -1,5 +1,6 @@ #include "tools/cabana/commands.h" +#include #include // UndoStack diff --git a/openpilot/tools/cabana/detailwidget.h b/openpilot/tools/cabana/detailwidget.h index 48d320c40f..c003548da4 100644 --- a/openpilot/tools/cabana/detailwidget.h +++ b/openpilot/tools/cabana/detailwidget.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -11,6 +12,7 @@ #include "tools/cabana/historylog.h" #include "tools/cabana/signalview.h" #include "tools/cabana/utils/elidedlabel.h" +#include "tools/cabana/utils/qtutil.h" class EditMessageDialog : public QDialog { public: diff --git a/openpilot/tools/cabana/historylog.h b/openpilot/tools/cabana/historylog.h index 405fe60fbb..1b75e3b8a9 100644 --- a/openpilot/tools/cabana/historylog.h +++ b/openpilot/tools/cabana/historylog.h @@ -10,6 +10,7 @@ #include "tools/cabana/dbc/dbcmanager.h" #include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/utils/qtutil.h" class HeaderView : public QHeaderView { public: diff --git a/openpilot/tools/cabana/mainwin.cc b/openpilot/tools/cabana/mainwin.cc index 939aade269..2c19df530c 100644 --- a/openpilot/tools/cabana/mainwin.cc +++ b/openpilot/tools/cabana/mainwin.cc @@ -23,6 +23,7 @@ #include "tools/cabana/streamselector.h" #include "tools/cabana/tools/findsignal.h" #include "tools/cabana/utils/export.h" +#include "tools/cabana/utils/qtutil.h" #include "tools/replay/py_downloader.h" #include "tools/replay/util.h" diff --git a/openpilot/tools/cabana/messageswidget.h b/openpilot/tools/cabana/messageswidget.h index 28cab25463..7c9fc0253f 100644 --- a/openpilot/tools/cabana/messageswidget.h +++ b/openpilot/tools/cabana/messageswidget.h @@ -11,11 +11,13 @@ #include #include #include +#include #include #include #include "tools/cabana/dbc/dbcmanager.h" #include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/utils/qtutil.h" class MessageListModel : public QAbstractTableModel { Q_OBJECT diff --git a/openpilot/tools/cabana/settingsdialog.cc b/openpilot/tools/cabana/settingsdialog.cc index 3a49de5746..5dd8b7617c 100644 --- a/openpilot/tools/cabana/settingsdialog.cc +++ b/openpilot/tools/cabana/settingsdialog.cc @@ -9,7 +9,7 @@ #include #include "tools/cabana/settings.h" -#include "tools/cabana/utils/util.h" +#include "tools/cabana/utils/qtutil.h" const int MIN_CACHE_MINIUTES = 30; const int MAX_CACHE_MINIUTES = 120; diff --git a/openpilot/tools/cabana/signalview.cc b/openpilot/tools/cabana/signalview.cc index 3a6d4dab25..de80ce2a3f 100644 --- a/openpilot/tools/cabana/signalview.cc +++ b/openpilot/tools/cabana/signalview.cc @@ -11,10 +11,11 @@ #include #include #include +#include #include #include "tools/cabana/commands.h" -#include "tools/cabana/utils/util.h" +#include "tools/cabana/utils/qtutil.h" // SignalModel diff --git a/openpilot/tools/cabana/streams/abstractstream.cc b/openpilot/tools/cabana/streams/abstractstream.cc index cc8b2f96ad..7be579269d 100644 --- a/openpilot/tools/cabana/streams/abstractstream.cc +++ b/openpilot/tools/cabana/streams/abstractstream.cc @@ -1,5 +1,6 @@ #include "tools/cabana/streams/abstractstream.h" +#include #include #include diff --git a/openpilot/tools/cabana/streams/devicestream.cc b/openpilot/tools/cabana/streams/devicestream.cc index 3a338c7f59..986ca13558 100644 --- a/openpilot/tools/cabana/streams/devicestream.cc +++ b/openpilot/tools/cabana/streams/devicestream.cc @@ -1,5 +1,6 @@ #include "tools/cabana/streams/devicestream.h" +#include #include #include #include diff --git a/openpilot/tools/cabana/streams/livestream.cc b/openpilot/tools/cabana/streams/livestream.cc index 71b5d9a1ec..ec71a6794d 100644 --- a/openpilot/tools/cabana/streams/livestream.cc +++ b/openpilot/tools/cabana/streams/livestream.cc @@ -9,6 +9,7 @@ #include "common/timing.h" #include "common/util.h" +#include "tools/cabana/settings.h" struct LiveStream::Logger { Logger() : start_ts(seconds_since_epoch()), segment_num(-1) {} diff --git a/openpilot/tools/cabana/streams/replaystream.cc b/openpilot/tools/cabana/streams/replaystream.cc index 81ed34e8db..944cb10fd9 100644 --- a/openpilot/tools/cabana/streams/replaystream.cc +++ b/openpilot/tools/cabana/streams/replaystream.cc @@ -4,6 +4,7 @@ #include "common/timing.h" #include "common/util.h" +#include "tools/cabana/settings.h" ReplayStream::ReplayStream() { unsetenv("ZMQ"); diff --git a/openpilot/tools/cabana/streamselector.cc b/openpilot/tools/cabana/streamselector.cc index 8c95bf584b..31532737f3 100644 --- a/openpilot/tools/cabana/streamselector.cc +++ b/openpilot/tools/cabana/streamselector.cc @@ -14,6 +14,7 @@ #include "tools/cabana/streams/devicestream.h" #include "tools/cabana/streams/replaystream.h" #include "tools/cabana/routesdialog.h" +#include "tools/cabana/utils/qtutil.h" // OpenReplayWidget diff --git a/openpilot/tools/cabana/tools/findsignal.cc b/openpilot/tools/cabana/tools/findsignal.cc index 4511af7c01..2e533741cc 100644 --- a/openpilot/tools/cabana/tools/findsignal.cc +++ b/openpilot/tools/cabana/tools/findsignal.cc @@ -10,6 +10,8 @@ #include #include +#include "tools/cabana/utils/qtutil.h" + // FindSignalModel QVariant FindSignalModel::headerData(int section, Qt::Orientation orientation, int role) const { diff --git a/openpilot/tools/cabana/tools/findsignal.h b/openpilot/tools/cabana/tools/findsignal.h index 239a08c9c4..b7cae73b49 100644 --- a/openpilot/tools/cabana/tools/findsignal.h +++ b/openpilot/tools/cabana/tools/findsignal.h @@ -7,8 +7,13 @@ #include #include +#include +#include +#include #include +#include #include +#include #include #include "tools/cabana/commands.h" diff --git a/openpilot/tools/cabana/utils/qtutil.cc b/openpilot/tools/cabana/utils/qtutil.cc new file mode 100644 index 0000000000..040c90c624 --- /dev/null +++ b/openpilot/tools/cabana/utils/qtutil.cc @@ -0,0 +1,236 @@ +#include "tools/cabana/utils/qtutil.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +// MessageBytesDelegate + +MessageBytesDelegate::MessageBytesDelegate(QObject *parent, bool multiple_lines) + : font_metrics(QApplication::font()), multiple_lines(multiple_lines), QStyledItemDelegate(parent) { + fixed_font = QFontDatabase::systemFont(QFontDatabase::FixedFont); + byte_size = QFontMetrics(fixed_font).size(Qt::TextSingleLine, "00 ") + QSize(0, 2); + for (int i = 0; i < 256; ++i) { + hex_text_table[i].setText(QStringLiteral("%1").arg(i, 2, 16, QLatin1Char('0')).toUpper()); + hex_text_table[i].prepare({}, fixed_font); + } + h_margin = QApplication::style()->pixelMetric(QStyle::PM_FocusFrameHMargin) + 1; + v_margin = QApplication::style()->pixelMetric(QStyle::PM_FocusFrameVMargin) + 1; +} + +QSize MessageBytesDelegate::sizeForBytes(int n) const { + int rows = multiple_lines ? std::max(1, n / 8) : 1; + return {(n / rows) * byte_size.width() + h_margin * 2, rows * byte_size.height() + v_margin * 2}; +} + +QSize MessageBytesDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const { + auto data = index.data(BytesRole); + return sizeForBytes(data.isValid() ? static_cast *>(data.value())->size() : 0); +} + +void MessageBytesDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const { + if (option.state & QStyle::State_Selected) { + painter->fillRect(option.rect, option.palette.brush(QPalette::Normal, QPalette::Highlight)); + } + + QRect item_rect = option.rect.adjusted(h_margin, v_margin, -h_margin, -v_margin); + QColor highlighted_color = option.palette.color(QPalette::HighlightedText); + auto text_color = index.data(Qt::ForegroundRole).value(); + bool inactive = text_color.isValid(); + if (!inactive) { + text_color = option.palette.color(QPalette::Text); + } + auto data = index.data(BytesRole); + if (!data.isValid()) { + painter->setFont(option.font); + painter->setPen(option.state & QStyle::State_Selected ? highlighted_color : text_color); + QString text = font_metrics.elidedText(index.data(Qt::DisplayRole).toString(), Qt::ElideRight, item_rect.width()); + painter->drawText(item_rect, Qt::AlignLeft | Qt::AlignVCenter, text); + return; + } + + // Paint hex column + const auto &bytes = *static_cast *>(data.value()); + const auto &colors = *static_cast *>(index.data(ColorsRole).value()); + + painter->setFont(fixed_font); + const QPen text_pen(option.state & QStyle::State_Selected ? highlighted_color : text_color); + const QPoint pt = item_rect.topLeft(); + for (int i = 0; i < bytes.size(); ++i) { + int row = !multiple_lines ? 0 : i / 8; + int column = !multiple_lines ? i : i % 8; + QRect r({pt.x() + column * byte_size.width(), pt.y() + row * byte_size.height()}, byte_size); + + if (!inactive && i < colors.size() && colors[i].alpha() > 0) { + if (option.state & QStyle::State_Selected) { + painter->setPen(option.palette.color(QPalette::Text)); + painter->fillRect(r, option.palette.color(QPalette::Window)); + } + painter->fillRect(r, toQColor(colors[i])); + } else { + painter->setPen(text_pen); + } + utils::drawStaticText(painter, r, hex_text_table[bytes[i]]); + } +} + +// TabBar + +int TabBar::addTab(const QString &text) { + int index = QTabBar::addTab(text); + QToolButton *btn = new ToolButton("x", tr("Close Tab")); + int width = style()->pixelMetric(QStyle::PM_TabCloseIndicatorWidth, nullptr, btn); + int height = style()->pixelMetric(QStyle::PM_TabCloseIndicatorHeight, nullptr, btn); + btn->setFixedSize({width, height}); + setTabButton(index, QTabBar::RightSide, btn); + QObject::connect(btn, &QToolButton::clicked, this, &TabBar::closeTabClicked); + return index; +} + +void TabBar::closeTabClicked() { + QObject *object = sender(); + for (int i = 0; i < count(); ++i) { + if (tabButton(i, QTabBar::RightSide) == object) { + emit tabCloseRequested(i); + break; + } + } +} + +// validators + +static QValidator::State toQtState(ValidState s) { + switch (s) { + case ValidState::Acceptable: return QValidator::Acceptable; + case ValidState::Intermediate: return QValidator::Intermediate; + default: return QValidator::Invalid; + } +} + +QValidator::State NameValidator::validate(QString &input, int &pos) const { + std::string s = input.toStdString(); + auto state = validateName(s); + input = QString::fromStdString(s); + return toQtState(state); +} + +QValidator::State NodeValidator::validate(QString &input, int &pos) const { + return toQtState(validateNodes(input.toStdString())); +} + +QValidator::State NonWhitespaceValidator::validate(QString &input, int &pos) const { + return toQtState(validateNonWhitespace(input.toStdString())); +} + +QValidator::State IpAddressValidator::validate(QString &input, int &pos) const { + return toQtState(validateIpAddress(input.toStdString())); +} + +QValidator::State DoubleValidator::validate(QString &input, int &pos) const { + return toQtState(validateDouble(input.toLatin1().toStdString())); +} + +namespace utils { + +bool isDarkTheme() { + QColor windowColor = QApplication::palette().color(QPalette::Window); + return windowColor.lightness() < 128; +} + +QPixmap icon(const QString &id) { + bool dark_theme = isDarkTheme(); + + QPixmap pm; + QString key = "bootstrap_" % id % (dark_theme ? "1" : "0"); + if (!QPixmapCache::find(key, &pm)) { + pm = bootstrapPixmap(id); + if (dark_theme) { + QPainter p(&pm); + p.setCompositionMode(QPainter::CompositionMode_SourceIn); + p.fillRect(pm.rect(), QColor("#bbbbbb")); + } + QPixmapCache::insert(key, pm); + } + return pm; +} + +void setTheme(int theme) { + auto style = QApplication::style(); + if (!style) return; + + static int prev_theme = 0; + if (theme != prev_theme) { + prev_theme = theme; + QPalette new_palette; + if (theme == DARK_THEME) { + new_palette.setColor(QPalette::Window, toQColor(DarkTheme::window)); + new_palette.setColor(QPalette::WindowText, toQColor(DarkTheme::window_text)); + new_palette.setColor(QPalette::Base, toQColor(DarkTheme::base)); + new_palette.setColor(QPalette::AlternateBase, toQColor(DarkTheme::base)); + new_palette.setColor(QPalette::ToolTipBase, toQColor(DarkTheme::base)); + new_palette.setColor(QPalette::ToolTipText, toQColor(DarkTheme::tooltip_text)); + new_palette.setColor(QPalette::Text, toQColor(DarkTheme::text)); + new_palette.setColor(QPalette::Button, toQColor(DarkTheme::button)); + new_palette.setColor(QPalette::ButtonText, toQColor(DarkTheme::window_text)); + new_palette.setColor(QPalette::Highlight, toQColor(DarkTheme::highlight)); + new_palette.setColor(QPalette::HighlightedText, toQColor(DarkTheme::window_text)); + new_palette.setColor(QPalette::BrightText, toQColor(DarkTheme::bright_text)); + new_palette.setColor(QPalette::Disabled, QPalette::ButtonText, toQColor(DarkTheme::disabled_text)); + new_palette.setColor(QPalette::Disabled, QPalette::WindowText, toQColor(DarkTheme::disabled_text)); + new_palette.setColor(QPalette::Disabled, QPalette::Text, toQColor(DarkTheme::disabled_text)); + new_palette.setColor(QPalette::Light, toQColor(DarkTheme::light)); + new_palette.setColor(QPalette::Dark, toQColor(DarkTheme::dark)); + } else { + new_palette = style->standardPalette(); + } + qApp->setPalette(new_palette); + style->polish(qApp); + for (auto w : QApplication::allWidgets()) { + w->setPalette(new_palette); + } + } +} + +} // namespace utils + +void sigTermHandler(int s) { + std::signal(s, SIG_DFL); + qApp->quit(); +} + +void initApp(int argc, char *argv[], bool disable_hidpi) { + // setup signal handlers to exit gracefully + std::signal(SIGINT, sigTermHandler); + std::signal(SIGTERM, sigTermHandler); + +#ifdef __APPLE__ + // Get the devicePixelRatio, and scale accordingly to maintain 1:1 rendering + QApplication tmp(argc, argv); + if (disable_hidpi) { + qputenv("QT_SCALE_FACTOR", QString::number(1.0 / tmp.devicePixelRatio()).toLocal8Bit()); + } +#endif + + qputenv("QT_DBL_CLICK_DIST", "150"); + // ensure the current dir matches the exectuable's directory + std::error_code ec; + std::filesystem::current_path(executableDir(), ec); +} + +QPixmap bootstrapPixmap(const QString &id) { + QPixmap pixmap; + const std::string svg = utils::bootstrapSvg(id.toStdString()); + if (!svg.empty()) { + pixmap.loadFromData((const uchar *)svg.data(), svg.size(), "svg"); + } + return pixmap; +} diff --git a/openpilot/tools/cabana/utils/qtutil.h b/openpilot/tools/cabana/utils/qtutil.h new file mode 100644 index 0000000000..db0417ec9a --- /dev/null +++ b/openpilot/tools/cabana/utils/qtutil.h @@ -0,0 +1,139 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "tools/cabana/core/observable.h" +#include "tools/cabana/dbc/dbc.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/utils/strings.h" +#include "tools/cabana/utils/util.h" + +// needed by QVariant::fromValue() in the Qt views; goes away with QVariant +Q_DECLARE_METATYPE(MessageId) +Q_DECLARE_METATYPE(ValueDescription) + +inline QColor toQColor(const CabanaColor &color) { + return QColor(color.r, color.g, color.b, color.a); +} + +class LogSlider : public QSlider { + Q_OBJECT + +public: + LogSlider(double factor, Qt::Orientation orientation, QWidget *parent = nullptr) : scale(factor), QSlider(orientation, parent) {} + + void setRange(double min, double max) { + scale.setRange(min, max); + QSlider::setRange(min, max); + setValue(QSlider::value()); + } + int value() const { return scale.value(QSlider::value(), minimum(), maximum()); } + void setValue(int v) { QSlider::setValue(scale.position(v, minimum(), maximum())); } + +private: + LogScale scale; +}; + +enum { + ColorsRole = Qt::UserRole + 1, + BytesRole = Qt::UserRole + 2 +}; + +class MessageBytesDelegate : public QStyledItemDelegate { + Q_OBJECT +public: + MessageBytesDelegate(QObject *parent, bool multiple_lines = false); + void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; + QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; + bool multipleLines() const { return multiple_lines; } + void setMultipleLines(bool v) { multiple_lines = v; } + QSize sizeForBytes(int n) const; + +private: + std::array hex_text_table; + QFontMetrics font_metrics; + QFont fixed_font; + QSize byte_size = {}; + bool multiple_lines = false; + int h_margin, v_margin; +}; + +// QValidator wrappers around the std::string validators in util.h +#define CABANA_VALIDATOR(Name) \ + class Name : public QValidator { \ + Q_OBJECT \ + public: \ + Name(QObject *parent = nullptr) : QValidator(parent) {} \ + QValidator::State validate(QString &input, int &pos) const override; \ + }; +CABANA_VALIDATOR(NameValidator) +CABANA_VALIDATOR(NodeValidator) +CABANA_VALIDATOR(NonWhitespaceValidator) +CABANA_VALIDATOR(IpAddressValidator) +CABANA_VALIDATOR(DoubleValidator) +#undef CABANA_VALIDATOR + +namespace utils { + +QPixmap icon(const QString &id); +bool isDarkTheme(); +void setTheme(int theme); +inline void drawStaticText(QPainter *p, const QRect &r, const QStaticText &text) { + auto size = (r.size() - text.size()) / 2; + p->drawStaticText(r.left() + size.width(), r.top() + size.height(), text); +} +inline auto qbytes(const std::vector &dat) { + return decltype(QString().toUtf8())((const char *)dat.data(), (int)dat.size()); +} + +} + +class ToolButton : public QToolButton { + Q_OBJECT +public: + ToolButton(const QString &icon, const QString &tooltip = {}, QWidget *parent = nullptr) : QToolButton(parent) { + setIcon(icon); + setToolTip(tooltip); + setAutoRaise(true); + const int metric = QApplication::style()->pixelMetric(QStyle::PM_SmallIconSize); + setIconSize({metric, metric}); + theme = settings.theme; + settings_connection_ = settings.changed.connect([this]() { updateIcon(); }); + } + void setIcon(const QString &icon) { + icon_str = icon; + QToolButton::setIcon(utils::icon(icon_str)); + } + +private: + void updateIcon() { if (std::exchange(theme, settings.theme) != theme) setIcon(icon_str); } + Connection settings_connection_; + QString icon_str; + int theme; +}; + +class TabBar : public QTabBar { + Q_OBJECT + +public: + TabBar(QWidget *parent) : QTabBar(parent) {} + int addTab(const QString &text); + +private: + void closeTabClicked(); +}; + +void initApp(int argc, char *argv[], bool disable_hidpi = true); +QPixmap bootstrapPixmap(const QString &id); diff --git a/openpilot/tools/cabana/utils/util.cc b/openpilot/tools/cabana/utils/util.cc index d5cd77a8c8..7bdf225975 100644 --- a/openpilot/tools/cabana/utils/util.cc +++ b/openpilot/tools/cabana/utils/util.cc @@ -1,18 +1,19 @@ #include "tools/cabana/utils/util.h" #include +#include #include -#include #include #include #include #include +#include #include #include -#include #include #include #include +#include #include #include #include @@ -20,11 +21,6 @@ #include #endif -#include -#include -#include -#include -#include #include "common/util.h" static const std::thread::id main_thread_id = std::this_thread::get_id(); @@ -53,22 +49,21 @@ void utils::drainMainThreadQueue() { // SegmentTree -void SegmentTree::build(const std::vector &arr) { - size = arr.size(); +void SegmentTree::build(int n, const std::function &y) { + size = n; tree.resize(4 * size); // size of the tree is 4 times the size of the array if (size > 0) { - build_tree(arr, 1, 0, size - 1); + build_tree(y, 1, 0, size - 1); } } -void SegmentTree::build_tree(const std::vector &arr, int n, int left, int right) { +void SegmentTree::build_tree(const std::function &y, int n, int left, int right) { if (left == right) { - const double y = arr[left].y(); - tree[n] = {y, y}; + tree[n] = {y(left), y(left)}; } else { const int mid = (left + right) >> 1; - build_tree(arr, 2 * n, left, mid); - build_tree(arr, 2 * n + 1, mid + 1, right); + build_tree(y, 2 * n, left, mid); + build_tree(y, 2 * n + 1, mid + 1, right); tree[n] = {std::min(tree[2 * n].first, tree[2 * n + 1].first), std::max(tree[2 * n].second, tree[2 * n + 1].second)}; } } @@ -84,119 +79,22 @@ std::pair SegmentTree::get_minmax(int n, int left, int right, in return {std::min(l.first, r.first), std::max(l.second, r.second)}; } -// MessageBytesDelegate - -MessageBytesDelegate::MessageBytesDelegate(QObject *parent, bool multiple_lines) - : font_metrics(QApplication::font()), multiple_lines(multiple_lines), QStyledItemDelegate(parent) { - fixed_font = QFontDatabase::systemFont(QFontDatabase::FixedFont); - byte_size = QFontMetrics(fixed_font).size(Qt::TextSingleLine, "00 ") + QSize(0, 2); - for (int i = 0; i < 256; ++i) { - hex_text_table[i].setText(QStringLiteral("%1").arg(i, 2, 16, QLatin1Char('0')).toUpper()); - hex_text_table[i].prepare({}, fixed_font); - } - h_margin = QApplication::style()->pixelMetric(QStyle::PM_FocusFrameHMargin) + 1; - v_margin = QApplication::style()->pixelMetric(QStyle::PM_FocusFrameVMargin) + 1; -} - -QSize MessageBytesDelegate::sizeForBytes(int n) const { - int rows = multiple_lines ? std::max(1, n / 8) : 1; - return {(n / rows) * byte_size.width() + h_margin * 2, rows * byte_size.height() + v_margin * 2}; -} - -QSize MessageBytesDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const { - auto data = index.data(BytesRole); - return sizeForBytes(data.isValid() ? static_cast *>(data.value())->size() : 0); -} - -void MessageBytesDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const { - if (option.state & QStyle::State_Selected) { - painter->fillRect(option.rect, option.palette.brush(QPalette::Normal, QPalette::Highlight)); - } - - QRect item_rect = option.rect.adjusted(h_margin, v_margin, -h_margin, -v_margin); - QColor highlighted_color = option.palette.color(QPalette::HighlightedText); - auto text_color = index.data(Qt::ForegroundRole).value(); - bool inactive = text_color.isValid(); - if (!inactive) { - text_color = option.palette.color(QPalette::Text); - } - auto data = index.data(BytesRole); - if (!data.isValid()) { - painter->setFont(option.font); - painter->setPen(option.state & QStyle::State_Selected ? highlighted_color : text_color); - QString text = font_metrics.elidedText(index.data(Qt::DisplayRole).toString(), Qt::ElideRight, item_rect.width()); - painter->drawText(item_rect, Qt::AlignLeft | Qt::AlignVCenter, text); - return; - } - - // Paint hex column - const auto &bytes = *static_cast *>(data.value()); - const auto &colors = *static_cast *>(index.data(ColorsRole).value()); - - painter->setFont(fixed_font); - const QPen text_pen(option.state & QStyle::State_Selected ? highlighted_color : text_color); - const QPoint pt = item_rect.topLeft(); - for (int i = 0; i < bytes.size(); ++i) { - int row = !multiple_lines ? 0 : i / 8; - int column = !multiple_lines ? i : i % 8; - QRect r({pt.x() + column * byte_size.width(), pt.y() + row * byte_size.height()}, byte_size); - - if (!inactive && i < colors.size() && colors[i].alpha() > 0) { - if (option.state & QStyle::State_Selected) { - painter->setPen(option.palette.color(QPalette::Text)); - painter->fillRect(r, option.palette.color(QPalette::Window)); - } - painter->fillRect(r, toQColor(colors[i])); - } else { - painter->setPen(text_pen); - } - utils::drawStaticText(painter, r, hex_text_table[bytes[i]]); - } -} - -// TabBar - -int TabBar::addTab(const QString &text) { - int index = QTabBar::addTab(text); - QToolButton *btn = new ToolButton("x", tr("Close Tab")); - int width = style()->pixelMetric(QStyle::PM_TabCloseIndicatorWidth, nullptr, btn); - int height = style()->pixelMetric(QStyle::PM_TabCloseIndicatorHeight, nullptr, btn); - btn->setFixedSize({width, height}); - setTabButton(index, QTabBar::RightSide, btn); - QObject::connect(btn, &QToolButton::clicked, this, &TabBar::closeTabClicked); - return index; -} - -void TabBar::closeTabClicked() { - QObject *object = sender(); - for (int i = 0; i < count(); ++i) { - if (tabButton(i, QTabBar::RightSide) == object) { - emit tabCloseRequested(i); - break; - } - } -} - // UnixSignalHandler -UnixSignalHandler::UnixSignalHandler() { +UnixSignalHandler::UnixSignalHandler(std::function on_signal) { if (::socketpair(AF_UNIX, SOCK_STREAM, 0, sig_fd)) { - qFatal("Couldn't create TERM socketpair"); + fprintf(stderr, "Couldn't create TERM socketpair\n"); + abort(); } - waiter = std::thread([this]() { + waiter = std::thread([this, on_signal = std::move(on_signal)]() { int tmp = 0; while (::read(sig_fd[1], &tmp, sizeof(tmp)) < 0) { if (errno != EINTR) return; } if (shutting_down.load()) return; - // Marshal exit onto the GUI thread (qApp methods are not thread-safe). - QMetaObject::invokeMethod(qApp, []() { - printf("\nexiting...\n"); - qApp->closeAllWindows(); - qApp->exit(); - }, Qt::QueuedConnection); + on_signal(); }); std::signal(SIGINT, signalHandler); @@ -216,118 +114,131 @@ void UnixSignalHandler::signalHandler(int s) { (void)!::write(sig_fd[0], &s, sizeof(s)); } -// NameValidator +// validators -NameValidator::NameValidator(QObject *parent) : QValidator(parent) {} - -QValidator::State NameValidator::validate(QString &input, int &pos) const { - Q_UNUSED(pos); - input.replace(' ', '_'); - if (input.isEmpty()) return QValidator::Intermediate; - for (const QChar &c : input) { - if (!c.isLetterOrNumber() && c != '_') return QValidator::Invalid; +ValidState validateName(std::string &input) { + std::replace(input.begin(), input.end(), ' ', '_'); + if (input.empty()) return ValidState::Intermediate; + for (const unsigned char c : input) { + if (!std::isalnum(c) && c != '_') return ValidState::Invalid; } - return QValidator::Acceptable; + return ValidState::Acceptable; } -// NodeValidator - -NodeValidator::NodeValidator(QObject *parent) : QValidator(parent) {} - -QValidator::State NodeValidator::validate(QString &input, int &pos) const { - Q_UNUSED(pos); - if (input.isEmpty()) return QValidator::Intermediate; +ValidState validateNodes(const std::string &input) { + if (input.empty()) return ValidState::Intermediate; // Match ^\w+(,\w+)*$ ; a trailing comma is Intermediate (user still typing). bool need_word = true; - for (const QChar &c : input) { - if (c.isLetterOrNumber() || c == '_') { + for (const unsigned char c : input) { + if (std::isalnum(c) || c == '_') { need_word = false; } else if (c == ',' && !need_word) { need_word = true; } else { - return QValidator::Invalid; + return ValidState::Invalid; } } - return need_word ? QValidator::Intermediate : QValidator::Acceptable; + return need_word ? ValidState::Intermediate : ValidState::Acceptable; } -// NonWhitespaceValidator - -NonWhitespaceValidator::NonWhitespaceValidator(QObject *parent) : QValidator(parent) {} - -QValidator::State NonWhitespaceValidator::validate(QString &input, int &pos) const { - Q_UNUSED(pos); - if (input.isEmpty()) return QValidator::Intermediate; - for (const QChar &c : input) { - if (c.isSpace()) return QValidator::Invalid; +ValidState validateNonWhitespace(const std::string &input) { + if (input.empty()) return ValidState::Intermediate; + for (const unsigned char c : input) { + if (std::isspace(c)) return ValidState::Invalid; } - return QValidator::Acceptable; + return ValidState::Acceptable; } -// IpAddressValidator - -IpAddressValidator::IpAddressValidator(QObject *parent) : QValidator(parent) {} - -QValidator::State IpAddressValidator::validate(QString &input, int &pos) const { - Q_UNUSED(pos); - if (input.isEmpty()) return QValidator::Intermediate; +ValidState validateIpAddress(const std::string &input) { + if (input.empty()) return ValidState::Intermediate; int dots = 0; int value = 0; bool has_digit = false; - for (const QChar &c : input) { - if (c.isDigit()) { - value = has_digit ? value * 10 + c.digitValue() : c.digitValue(); - if (value > 255) return QValidator::Invalid; + for (const unsigned char c : input) { + if (std::isdigit(c)) { + value = has_digit ? value * 10 + (c - '0') : (c - '0'); + if (value > 255) return ValidState::Invalid; has_digit = true; } else if (c == '.') { - if (!has_digit || dots >= 3) return QValidator::Invalid; + if (!has_digit || dots >= 3) return ValidState::Invalid; ++dots; has_digit = false; value = 0; } else { - return QValidator::Invalid; + return ValidState::Invalid; } } - return (dots == 3 && has_digit) ? QValidator::Acceptable : QValidator::Intermediate; + return (dots == 3 && has_digit) ? ValidState::Acceptable : ValidState::Intermediate; } -DoubleValidator::DoubleValidator(QObject *parent) : QValidator(parent) {} +ValidState validateDouble(const std::string &input) { + if (input.empty()) return ValidState::Intermediate; -QValidator::State DoubleValidator::validate(QString &input, int &pos) const { - Q_UNUSED(pos); - if (input.isEmpty()) return QValidator::Intermediate; - - // Match QString::toDouble(): C locale, no hex floats / inf / nan. - const std::string bytes = input.toLatin1().toStdString(); - // strtod accepts 0x… hex floats and p-exponents; QString::toDouble does not. - if (bytes.find_first_of("xXpP") != std::string::npos) { - return QValidator::Invalid; + // C locale, no hex floats / p-exponents / inf / nan (strtod accepts them, the DBC parser does not) + if (input.find_first_of("xXpP") != std::string::npos) { + return ValidState::Invalid; } - const char *start = bytes.c_str(); + const char *start = input.c_str(); char *end = nullptr; const double value = std::strtod(start, &end); if (end == start) { // Still typing a sign, decimal point, or exponent prefix. if (input == "-" || input == "+" || input == "." || input == "-." || input == "+.") { - return QValidator::Intermediate; + return ValidState::Intermediate; } - return QValidator::Invalid; + return ValidState::Invalid; } if (*end == '\0') { - // Reject inf/nan (strtod accepts them; QDoubleValidator / toDouble path should not). - return std::isfinite(value) ? QValidator::Acceptable : QValidator::Invalid; + return std::isfinite(value) ? ValidState::Acceptable : ValidState::Invalid; } // Partial exponent / trailing sign while typing (e.g. "1e", "1e-", "1."). for (const char *p = end; *p; ++p) { const char c = *p; if (!(c == 'e' || c == 'E' || c == '+' || c == '-' || c == '.' || (c >= '0' && c <= '9'))) { - return QValidator::Invalid; + return ValidState::Invalid; } } - return QValidator::Intermediate; + return ValidState::Intermediate; +} + +// embedded at build time from the bootstrap_icons package (see SConscript) +extern const unsigned char bootstrap_icons_svg[]; +extern const size_t bootstrap_icons_svg_len; + +static std::unordered_map load_bootstrap_icons() { + std::unordered_map icons; + + const std::string content(reinterpret_cast(bootstrap_icons_svg), bootstrap_icons_svg_len); + const std::string sym_open = " with + svg_str.replace(0, 7, " ""); // "" (9) -> "" (6) + icons[id] = std::move(svg_str); + } + } + pos = end; + } + return icons; } namespace utils { @@ -384,77 +295,19 @@ bool setClipboardText(const std::string &text) { return false; } -bool isDarkTheme() { - QColor windowColor = QApplication::palette().color(QPalette::Window); - return windowColor.lightness() < 128; -} - -QPixmap icon(const QString &id) { - bool dark_theme = isDarkTheme(); - - QPixmap pm; - QString key = "bootstrap_" % id % (dark_theme ? "1" : "0"); - if (!QPixmapCache::find(key, &pm)) { - pm = bootstrapPixmap(id); - if (dark_theme) { - QPainter p(&pm); - p.setCompositionMode(QPainter::CompositionMode_SourceIn); - p.fillRect(pm.rect(), QColor("#bbbbbb")); - } - QPixmapCache::insert(key, pm); - } - return pm; -} - -void setTheme(int theme) { - auto style = QApplication::style(); - if (!style) return; - - static int prev_theme = 0; - if (theme != prev_theme) { - prev_theme = theme; - QPalette new_palette; - if (theme == DARK_THEME) { - // "Darcula" like dark theme - new_palette.setColor(QPalette::Window, QColor("#353535")); - new_palette.setColor(QPalette::WindowText, QColor("#bbbbbb")); - new_palette.setColor(QPalette::Base, QColor("#3c3f41")); - new_palette.setColor(QPalette::AlternateBase, QColor("#3c3f41")); - new_palette.setColor(QPalette::ToolTipBase, QColor("#3c3f41")); - new_palette.setColor(QPalette::ToolTipText, QColor("#bbb")); - new_palette.setColor(QPalette::Text, QColor("#bbbbbb")); - new_palette.setColor(QPalette::Button, QColor("#3c3f41")); - new_palette.setColor(QPalette::ButtonText, QColor("#bbbbbb")); - new_palette.setColor(QPalette::Highlight, QColor("#2f65ca")); - new_palette.setColor(QPalette::HighlightedText, QColor("#bbbbbb")); - new_palette.setColor(QPalette::BrightText, QColor("#f0f0f0")); - new_palette.setColor(QPalette::Disabled, QPalette::ButtonText, QColor("#777777")); - new_palette.setColor(QPalette::Disabled, QPalette::WindowText, QColor("#777777")); - new_palette.setColor(QPalette::Disabled, QPalette::Text, QColor("#777777")); - new_palette.setColor(QPalette::Light, QColor("#777777")); - new_palette.setColor(QPalette::Dark, QColor("#353535")); - } else { - new_palette = style->standardPalette(); - } - qApp->setPalette(new_palette); - style->polish(qApp); - for (auto w : QApplication::allWidgets()) { - w->setPalette(new_palette); - } - } +std::string bootstrapSvg(const std::string &id) { + static auto icons = load_bootstrap_icons(); + auto it = icons.find(id); + return it != icons.end() ? it->second : std::string(); } } // namespace utils int num_decimals(double num) { - const QString string = QString::number(num); - auto dot_pos = string.indexOf('.'); - return dot_pos == -1 ? 0 : string.size() - dot_pos - 1; -} - -void sigTermHandler(int s) { - std::signal(s, SIG_DFL); - qApp->quit(); + char buf[32]; + snprintf(buf, sizeof(buf), "%g", num); + const char *dot = strpbrk(buf, ".,"); // Qt sets LC_ALL from the environment so the decimal mark may be a comma + return dot ? (int)strlen(dot + 1) : 0; } std::filesystem::path executableDir() { @@ -469,70 +322,3 @@ std::filesystem::path executableDir() { return std::filesystem::path(util::readlink("/proc/self/exe")).parent_path(); #endif } - -void initApp(int argc, char *argv[], bool disable_hidpi) { - // setup signal handlers to exit gracefully - std::signal(SIGINT, sigTermHandler); - std::signal(SIGTERM, sigTermHandler); - -#ifdef __APPLE__ - // Get the devicePixelRatio, and scale accordingly to maintain 1:1 rendering - QApplication tmp(argc, argv); - if (disable_hidpi) { - qputenv("QT_SCALE_FACTOR", QString::number(1.0 / tmp.devicePixelRatio()).toLocal8Bit()); - } -#endif - - qputenv("QT_DBL_CLICK_DIST", "150"); - // ensure the current dir matches the exectuable's directory - std::error_code ec; - std::filesystem::current_path(executableDir(), ec); -} - -// embedded at build time from the bootstrap_icons package (see SConscript) -extern const unsigned char bootstrap_icons_svg[]; -extern const size_t bootstrap_icons_svg_len; - -static std::unordered_map load_bootstrap_icons() { - std::unordered_map icons; - - const std::string content(reinterpret_cast(bootstrap_icons_svg), bootstrap_icons_svg_len); - const std::string sym_open = " with - svg_str.replace(0, 7, " ""); // "" (9) -> "" (6) - icons[id] = std::move(svg_str); - } - } - pos = end; - } - return icons; -} - -QPixmap bootstrapPixmap(const QString &id) { - static auto icons = load_bootstrap_icons(); - - QPixmap pixmap; - auto it = icons.find(id.toStdString()); - if (it != icons.end()) { - pixmap.loadFromData((const uchar *)it->second.data(), it->second.size(), "svg"); - } - return pixmap; -} diff --git a/openpilot/tools/cabana/utils/util.h b/openpilot/tools/cabana/utils/util.h index e0d13208a6..324c966b5a 100644 --- a/openpilot/tools/cabana/utils/util.h +++ b/openpilot/tools/cabana/utils/util.h @@ -1,6 +1,6 @@ #pragma once -#include +#include #include #include #include @@ -10,136 +10,68 @@ #include #include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/core/observable.h" -#include "tools/cabana/dbc/dbc.h" -#include "tools/cabana/settings.h" -#include "tools/cabana/utils/strings.h" - -// needed by QVariant::fromValue() in the Qt views; goes away with QVariant -Q_DECLARE_METATYPE(MessageId) -Q_DECLARE_METATYPE(ValueDescription) - -inline QColor toQColor(const CabanaColor &color) { - return QColor(color.r, color.g, color.b, color.a); -} - -class LogSlider : public QSlider { - Q_OBJECT +#include "tools/cabana/core/color.h" +class SegmentTree { public: - LogSlider(double factor, Qt::Orientation orientation, QWidget *parent = nullptr) : factor(factor), QSlider(orientation, parent) {} + SegmentTree() = default; + void build(int n, const std::function &y); + inline std::pair minmax(int left, int right) const { return get_minmax(1, 0, size - 1, left, right); } +private: + std::pair get_minmax(int n, int left, int right, int range_left, int range_right) const; + void build_tree(const std::function &y, int n, int left, int right); + std::vector> tree; + int size = 0; +}; + +// maps a linear slider position onto a log10 scale +class LogScale { +public: + LogScale(double factor) : factor(factor) {} void setRange(double min, double max) { log_min = factor * std::log10(min); log_max = factor * std::log10(max); - QSlider::setRange(min, max); - setValue(QSlider::value()); } - int value() const { - double v = log_min + (log_max - log_min) * ((QSlider::value() - minimum()) / double(maximum() - minimum())); + int value(int pos, int pos_min, int pos_max) const { + double v = log_min + (log_max - log_min) * ((pos - pos_min) / double(pos_max - pos_min)); return std::lround(std::pow(10, v / factor)); } - void setValue(int v) { + int position(int v, int pos_min, int pos_max) const { double log_v = std::clamp(factor * std::log10(v), log_min, log_max); - v = minimum() + (maximum() - minimum()) * ((log_v - log_min) / (log_max - log_min)); - QSlider::setValue(v); + return pos_min + (pos_max - pos_min) * ((log_v - log_min) / (log_max - log_min)); } private: double factor, log_min = 0, log_max = 1; }; -enum { - ColorsRole = Qt::UserRole + 1, - BytesRole = Qt::UserRole + 2 -}; +enum class ValidState { Invalid, Intermediate, Acceptable }; -class SegmentTree { -public: - SegmentTree() = default; - void build(const std::vector &arr); - inline std::pair minmax(int left, int right) const { return get_minmax(1, 0, size - 1, left, right); } +// single identifier: one or more [A-Za-z0-9_], spaces rewritten to '_' +ValidState validateName(std::string &input); +// comma-separated identifiers: \w+(,\w+)* +ValidState validateNodes(const std::string &input); +// one or more non-whitespace characters (\S+) +ValidState validateNonWhitespace(const std::string &input); +// dotted IPv4 address (0-255 per octet) +ValidState validateIpAddress(const std::string &input); +// C-locale floating-point +ValidState validateDouble(const std::string &input); -private: - std::pair get_minmax(int n, int left, int right, int range_left, int range_right) const; - void build_tree(const std::vector &arr, int n, int left, int right); - std::vector> tree; - int size = 0; -}; - -class MessageBytesDelegate : public QStyledItemDelegate { - Q_OBJECT -public: - MessageBytesDelegate(QObject *parent, bool multiple_lines = false); - void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; - QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; - bool multipleLines() const { return multiple_lines; } - void setMultipleLines(bool v) { multiple_lines = v; } - QSize sizeForBytes(int n) const; - -private: - std::array hex_text_table; - QFontMetrics font_metrics; - QFont fixed_font; - QSize byte_size = {}; - bool multiple_lines = false; - int h_margin, v_margin; -}; - -// Accepts a single identifier: one or more [A-Za-z0-9_], spaces rewritten to '_'. -class NameValidator : public QValidator { - Q_OBJECT -public: - NameValidator(QObject *parent=nullptr); - QValidator::State validate(QString &input, int &pos) const override; -}; - -// Accepts comma-separated identifiers: \w+(,\w+)* -class NodeValidator : public QValidator { - Q_OBJECT -public: - NodeValidator(QObject *parent=nullptr); - QValidator::State validate(QString &input, int &pos) const override; -}; - -// Accepts one or more non-whitespace characters (\S+). -class NonWhitespaceValidator : public QValidator { - Q_OBJECT -public: - NonWhitespaceValidator(QObject *parent=nullptr); - QValidator::State validate(QString &input, int &pos) const override; -}; - -// Accepts a dotted IPv4 address (0-255 per octet). -class IpAddressValidator : public QValidator { - Q_OBJECT -public: - IpAddressValidator(QObject *parent=nullptr); - QValidator::State validate(QString &input, int &pos) const override; -}; - -// C-locale floating-point validator (matches QString::toDouble). -class DoubleValidator : public QValidator { - Q_OBJECT -public: - DoubleValidator(QObject *parent = nullptr); - QValidator::State validate(QString &input, int &pos) const override; +// "Darcula" like dark theme +struct DarkTheme { + static constexpr CabanaColor window{0x35, 0x35, 0x35}; + static constexpr CabanaColor window_text{0xbb, 0xbb, 0xbb}; + static constexpr CabanaColor base{0x3c, 0x3f, 0x41}; + static constexpr CabanaColor tooltip_text{0xbb, 0xbb, 0xbb}; + static constexpr CabanaColor text{0xbb, 0xbb, 0xbb}; + static constexpr CabanaColor button{0x3c, 0x3f, 0x41}; + static constexpr CabanaColor highlight{0x2f, 0x65, 0xca}; + static constexpr CabanaColor bright_text{0xf0, 0xf0, 0xf0}; + static constexpr CabanaColor disabled_text{0x77, 0x77, 0x77}; + static constexpr CabanaColor light{0x77, 0x77, 0x77}; + static constexpr CabanaColor dark{0x35, 0x35, 0x35}; }; namespace utils { @@ -148,67 +80,23 @@ bool isMainThread(); // inline on the main thread, queued until drainMainThreadQueue() otherwise void runOnMainThread(std::function fn); void drainMainThreadQueue(); -QPixmap icon(const QString &id); std::string homePath(); std::filesystem::path configPath(); bool getClipboardText(std::string *text); // false if no clipboard tool is available bool setClipboardText(const std::string &text); -bool isDarkTheme(); -void setTheme(int theme); -inline void drawStaticText(QPainter *p, const QRect &r, const QStaticText &text) { - auto size = (r.size() - text.size()) / 2; - p->drawStaticText(r.left() + size.width(), r.top() + size.height(), text); -} +std::string bootstrapSvg(const std::string &id); // empty if unknown // boundary conversions for the remaining Qt byte-array based state APIs template std::vector toBytes(const T &dat) { return {dat.begin(), dat.end()}; } -inline auto qbytes(const std::vector &dat) { - return decltype(QString().toUtf8())((const char *)dat.data(), (int)dat.size()); -} } -class ToolButton : public QToolButton { - Q_OBJECT -public: - ToolButton(const QString &icon, const QString &tooltip = {}, QWidget *parent = nullptr) : QToolButton(parent) { - setIcon(icon); - setToolTip(tooltip); - setAutoRaise(true); - const int metric = QApplication::style()->pixelMetric(QStyle::PM_SmallIconSize); - setIconSize({metric, metric}); - theme = settings.theme; - settings_connection_ = settings.changed.connect([this]() { updateIcon(); }); - } - void setIcon(const QString &icon) { - icon_str = icon; - QToolButton::setIcon(utils::icon(icon_str)); - } - -private: - void updateIcon() { if (std::exchange(theme, settings.theme) != theme) setIcon(icon_str); } - Connection settings_connection_; - QString icon_str; - int theme; -}; - -class TabBar : public QTabBar { - Q_OBJECT - -public: - TabBar(QWidget *parent) : QTabBar(parent) {} - int addTab(const QString &text); - -private: - void closeTabClicked(); -}; - -// Watches SIGINT/SIGTERM via a self-pipe and a dedicated waiter thread -// (no Qt notifiers/timers). Exit is marshaled onto the GUI thread. +// Watches SIGINT/SIGTERM via a self-pipe and a dedicated waiter thread. +// on_signal runs on the waiter thread; the caller marshals to the GUI thread. class UnixSignalHandler { public: - UnixSignalHandler(); + UnixSignalHandler(std::function on_signal); ~UnixSignalHandler(); static void signalHandler(int s); @@ -220,5 +108,3 @@ private: int num_decimals(double num); std::filesystem::path executableDir(); -void initApp(int argc, char *argv[], bool disable_hidpi = true); -QPixmap bootstrapPixmap(const QString &id); diff --git a/openpilot/tools/cabana/videowidget.cc b/openpilot/tools/cabana/videowidget.cc index 23f60f29f1..f7b1b41628 100644 --- a/openpilot/tools/cabana/videowidget.cc +++ b/openpilot/tools/cabana/videowidget.cc @@ -12,6 +12,7 @@ #include #include "tools/cabana/tools/routeinfo.h" +#include "tools/cabana/utils/qtutil.h" const int MIN_VIDEO_HEIGHT = 100; const int THUMBNAIL_MARGIN = 3; diff --git a/openpilot/tools/cabana/videowidget.h b/openpilot/tools/cabana/videowidget.h index 1b6d81406e..8416eece59 100644 --- a/openpilot/tools/cabana/videowidget.h +++ b/openpilot/tools/cabana/videowidget.h @@ -12,7 +12,7 @@ #include #include "tools/cabana/cameraview.h" -#include "tools/cabana/utils/util.h" +#include "tools/cabana/utils/qtutil.h" #include "tools/replay/logreader.h" #include "tools/cabana/streams/replaystream.h" From 839d3f500474e7726f50cc273fe750e8a05222bd Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:06:33 -0700 Subject: [PATCH 41/67] ui: guard branch switcher before internet connected (#38692) --- openpilot/selfdrive/ui/mici/layouts/settings/software.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/software.py b/openpilot/selfdrive/ui/mici/layouts/settings/software.py index 32fbc2b3a7..33f6e0e4ab 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/software.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/software.py @@ -246,6 +246,9 @@ class TargetBranchButton(BigButton): self.set_value(target) def _on_click(self): + if not ui_state.params.get("UpdaterAvailableBranches"): + gui_app.push_widget(BigDialog("", tr("Please connect to Wi-Fi to switch branches."))) + return gui_app.push_widget(BranchSelectPage(self._on_select)) def _on_select(self, branch: str): From e571e21d14dc6247177bd4713283c7bb9ef4ebdd Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:07:15 -0700 Subject: [PATCH 42/67] ui: check for update on target branch switch (#38693) --- .../selfdrive/ui/layouts/settings/software.py | 3 +++ .../ui/mici/layouts/settings/software.py | 23 ++++++++++++------- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/openpilot/selfdrive/ui/layouts/settings/software.py b/openpilot/selfdrive/ui/layouts/settings/software.py index bf986e614e..0d3191aca6 100644 --- a/openpilot/selfdrive/ui/layouts/settings/software.py +++ b/openpilot/selfdrive/ui/layouts/settings/software.py @@ -197,6 +197,9 @@ class SoftwareLayout(Widget): selection = self._branch_dialog.selection ui_state.params.put("UpdaterTargetBranch", selection, block=True) self._branch_btn.action_item.set_value(selection) + self._download_btn.action_item.set_enabled(False) + self._waiting_for_updater = True + self._waiting_start_ts = time.monotonic() subprocess.run("pkill -SIGUSR1 -f openpilot.system.updated.updated", shell=True) self._branch_dialog = None diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/software.py b/openpilot/selfdrive/ui/mici/layouts/settings/software.py index 33f6e0e4ab..eeb6eb9bf1 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/software.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/software.py @@ -97,15 +97,20 @@ class CheckUpdateButton(BigButton): gui_app.push_widget(dlg) return + self._signal_updater("SIGHUP" if self.get_value() == "download update" else "SIGUSR1") + + def check_for_update(self): + self._signal_updater("SIGUSR1") + + def _signal_updater(self, sig: str): self.set_enabled(False) self._state = UpdaterState.WAITING_FOR_UPDATER + self._hide_value_t = None + self.set_value("") self.set_icon(self._txt_update_icon) def run(): - if self.get_value() == "download update": - subprocess.run("pkill -SIGHUP -f openpilot.system.updated.updated", shell=True) - else: - subprocess.run("pkill -SIGUSR1 -f openpilot.system.updated.updated", shell=True) + subprocess.run(f"pkill -{sig} -f openpilot.system.updated.updated", shell=True) threading.Thread(target=run, daemon=True).start() @@ -232,8 +237,9 @@ class BranchSelectPage(NavScroller): class TargetBranchButton(BigButton): - def __init__(self): + def __init__(self, check_update_btn: CheckUpdateButton): super().__init__("target branch", ui_state.params.get("UpdaterTargetBranch") or "") + self._check_update_btn = check_update_btn self.set_click_callback(self._on_click) self.set_visible(not ui_state.params.get_bool("IsTestedBranch")) self.set_enabled(lambda: ui_state.is_offroad()) @@ -254,7 +260,7 @@ class TargetBranchButton(BigButton): def _on_select(self, branch: str): ui_state.params.put("UpdaterTargetBranch", branch, block=True) self.set_value(branch) - subprocess.run("pkill -SIGUSR1 -f openpilot.system.updated.updated", shell=True) + self._check_update_btn.check_for_update() class SoftwareLayoutMici(NavScroller): @@ -268,10 +274,11 @@ class SoftwareLayoutMici(NavScroller): gui_app.texture("icons_mici/settings/device/uninstall.png", 64, 64), uninstall_openpilot_callback, exit_on_confirm=False) + check_update_btn = CheckUpdateButton() self._scroller.add_widgets([ SoftwareInfoLayoutMici(), - CheckUpdateButton(), + check_update_btn, InstallUpdateButton(), - TargetBranchButton(), + TargetBranchButton(check_update_btn), uninstall_openpilot_btn, ]) From a67cdf9a5138f2b3e0b1692fb4c92e6fa7c0fc8f Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Fri, 28 Aug 2026 15:08:18 -0700 Subject: [PATCH 43/67] ui: sync gpu loading to offroad (#38727) ui: sync gpu loading state --- openpilot/selfdrive/ui/mici/layouts/home.py | 7 ++++++- openpilot/system/ui/widgets/icon_widget.py | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/openpilot/selfdrive/ui/mici/layouts/home.py b/openpilot/selfdrive/ui/mici/layouts/home.py index 64f2961326..b567b405a8 100644 --- a/openpilot/selfdrive/ui/mici/layouts/home.py +++ b/openpilot/selfdrive/ui/mici/layouts/home.py @@ -1,4 +1,5 @@ import datetime +import math import time from openpilot.cereal import log @@ -140,6 +141,7 @@ class MiciHomeLayout(Widget): self._experimental_icon = IconWidget("icons_mici/experimental_mode.png", (48, 48)) self._chestnut_icon = IconWidget("icons_mici/chestnut_green.png", (68, 40)) + self._chestnut_loading_icon = IconWidget("icons_mici/chestnut.png", (68, 40)) self._chestnut_failed_icon = IconWidget("icons_mici/chestnut_orange.png", (68, 40)) self._mic_icon = IconWidget("icons_mici/microphone.png", (32, 46)) self._body_icon = IconWidget("icons_mici/body.png", (54, 37)) @@ -151,6 +153,7 @@ class MiciHomeLayout(Widget): NetworkIcon(), self._experimental_icon, self._chestnut_icon, + self._chestnut_loading_icon, self._chestnut_failed_icon, self._body_icon, self._mic_icon, @@ -248,7 +251,9 @@ class MiciHomeLayout(Widget): # ***** Center-aligned bottom section icons ***** self._experimental_icon.set_visible(ui_state.experimental_mode) - self._chestnut_icon.set_visible(ui_state.chestnut_state in (ChestnutState.READY, ChestnutState.LOADING, ChestnutState.ACTIVE)) + self._chestnut_icon.set_visible(ui_state.chestnut_state in (ChestnutState.READY, ChestnutState.ACTIVE)) + self._chestnut_loading_icon.set_visible(ui_state.chestnut_state == ChestnutState.LOADING) + self._chestnut_loading_icon.set_opacity(0.35 + 0.65 * (0.5 - 0.5 * math.cos(rl.get_time() * 6.0))) self._chestnut_failed_icon.set_visible(ui_state.chestnut_state in (ChestnutState.UNCOMPILED, ChestnutState.FAILED)) self._mic_icon.set_visible(ui_state.recording_audio) self._body_icon.set_visible(bool(ui_state.is_body)) diff --git a/openpilot/system/ui/widgets/icon_widget.py b/openpilot/system/ui/widgets/icon_widget.py index bf7790b937..f6f8a47231 100644 --- a/openpilot/system/ui/widgets/icon_widget.py +++ b/openpilot/system/ui/widgets/icon_widget.py @@ -14,3 +14,6 @@ class IconWidget(Widget): def _render(self, _) -> None: color = rl.Color(255, 255, 255, int(self._opacity * 255)) rl.draw_texture_ex(self._texture, rl.Vector2(self._rect.x, self._rect.y), 0.0, 1.0, color) + + def set_opacity(self, opacity: float) -> None: + self._opacity = opacity From 682b6a20dfe6764774a40f2ef51148c8078b7c79 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Fri, 28 Aug 2026 15:46:56 -0700 Subject: [PATCH 44/67] add chestnut offroad alerts (#38706) * system: add chestnut offroad alerts * system: refine chestnut offroad alerts * system: refine chestnut power alerts * system: confirm chestnut power recovery from PCIe * system: detect missing chestnut power from INA voltage --- openpilot/common/hardware/usb.py | 8 +- openpilot/common/params_keys.h | 6 ++ openpilot/selfdrive/modeld/helpers.py | 4 +- .../selfdrive/selfdrived/alerts_offroad.json | 24 +++++ openpilot/system/hardware/chestnut/status.py | 96 +++++++++++++++++++ openpilot/system/hardware/hardwared.py | 28 ++++-- 6 files changed, 154 insertions(+), 12 deletions(-) create mode 100644 openpilot/system/hardware/chestnut/status.py diff --git a/openpilot/common/hardware/usb.py b/openpilot/common/hardware/usb.py index b9f6db2757..c3c2050b6b 100644 --- a/openpilot/common/hardware/usb.py +++ b/openpilot/common/hardware/usb.py @@ -4,11 +4,17 @@ from pathlib import Path CHESTNUT_FW_VERSION = "ed4e39b7" CHESTNUT_USB_IDS = ((0xADD1, 0x0001), (0x3801, 0x0001)) CHESTNUT_ROM_USB_IDS = ((0x174C, 0x2464), (0x174C, 0x2463)) +CHESTNUT_USB_PRODUCT = f"custom {CHESTNUT_FW_VERSION}-CLEAN" USB_DEVICES_PATH = Path("/sys/bus/usb/devices") TYPEC_CC_ORIENTATION_PATH = Path("/sys/class/power_supply/usb/typec_cc_orientation") PRIMARY_USB_CONTROLLER = "a600000.ssusb" +def is_chestnut_usb_id(vendor_id: int, product_id: int, include_bootloader: bool = False) -> bool: + ids = CHESTNUT_USB_IDS + CHESTNUT_ROM_USB_IDS if include_bootloader else CHESTNUT_USB_IDS + return (vendor_id, product_id) in ids + + def get_usb_topology() -> set[str]: try: return set(os.listdir(USB_DEVICES_PATH)) @@ -81,7 +87,7 @@ def set_usb_state(device_state, devices: list[dict]) -> None: entry.linkErrorCount = device["linkErrorCount"] entry.usb3Lane = device.get("usb3Lane", "unknown") - if (entry.vendorId, entry.productId) in CHESTNUT_USB_IDS: + if is_chestnut_usb_id(entry.vendorId, entry.productId): chestnut_present = True device_state.chestnutPresent = chestnut_present diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index 21b7463d92..ba6eae3dd5 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -92,6 +92,12 @@ inline static std::unordered_map keys = { {"ObdMultiplexingEnabled", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}}, {"Offroad_CarUnrecognized", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}}, {"Offroad_ChestnutBranch", {CLEAR_ON_MANAGER_START, JSON}}, + {"Offroad_ChestnutNotDetected", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}}, + {"Offroad_ChestnutOverheated", {CLEAR_ON_MANAGER_START, JSON}}, + {"Offroad_ChestnutPcieUnavailable", {CLEAR_ON_MANAGER_START, JSON}}, + {"Offroad_ChestnutUncompiled", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}}, + {"Offroad_ChestnutUpdateFailed", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}}, + {"Offroad_ChestnutUsbSlow", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}}, {"Offroad_ConnectivityNeeded", {CLEAR_ON_MANAGER_START, JSON}}, {"Offroad_ConnectivityNeededPrompt", {CLEAR_ON_MANAGER_START, JSON}}, {"Offroad_ExcessiveActuation", {PERSISTENT, JSON}}, diff --git a/openpilot/selfdrive/modeld/helpers.py b/openpilot/selfdrive/modeld/helpers.py index 84236f3fd0..d081050055 100644 --- a/openpilot/selfdrive/modeld/helpers.py +++ b/openpilot/selfdrive/modeld/helpers.py @@ -7,7 +7,7 @@ import tempfile from pathlib import Path from openpilot.common.file_chunker import get_manifest_path -from openpilot.common.hardware.usb import CHESTNUT_FW_VERSION, CHESTNUT_USB_IDS, USB_DEVICES_PATH +from openpilot.common.hardware.usb import CHESTNUT_USB_PRODUCT, USB_DEVICES_PATH, is_chestnut_usb_id MODELS_DIR = Path(__file__).resolve().parent / 'models' TG_INPUT_DEVICES_PATH = MODELS_DIR / 'tg_input_devices.json' @@ -50,7 +50,7 @@ def chestnut_present() -> bool: try: usb_id = (int((d / "idVendor").read_text(), 16), int((d / "idProduct").read_text(), 16)) product = (d / "product").read_text().strip() - if usb_id in CHESTNUT_USB_IDS and product == f"custom {CHESTNUT_FW_VERSION}-CLEAN": + if is_chestnut_usb_id(*usb_id) and product == CHESTNUT_USB_PRODUCT: return True except Exception: pass diff --git a/openpilot/selfdrive/selfdrived/alerts_offroad.json b/openpilot/selfdrive/selfdrived/alerts_offroad.json index b0179c0ac3..33fec6e330 100644 --- a/openpilot/selfdrive/selfdrived/alerts_offroad.json +++ b/openpilot/selfdrive/selfdrived/alerts_offroad.json @@ -19,6 +19,30 @@ }, "Offroad_ChestnutBranch": { "text": "Chestnut detected! Switch to the release-chestnut branch to use chestnut-class models.", + "severity": -1 + }, + "Offroad_ChestnutNotDetected": { + "text": "Chestnut not detected. Check USB and 12V connections.", + "severity": 0 + }, + "Offroad_ChestnutOverheated": { + "text": "Chestnut overheated. Ensure good airflow. Current GPU temperature is %1.", + "severity": 0 + }, + "Offroad_ChestnutPcieUnavailable": { + "text": "%1", + "severity": 0 + }, + "Offroad_ChestnutUncompiled": { + "text": "Chestnut model not compiled. Keep ignition on and reboot the comma.", + "severity": 0 + }, + "Offroad_ChestnutUpdateFailed": { + "text": "Chestnut update failed. Check the USB cable.", + "severity": 0 + }, + "Offroad_ChestnutUsbSlow": { + "text": "Chestnut USB link is slow. Check the USB cable. The current speed is %1.", "severity": 0 }, "Offroad_UnregisteredHardware": { diff --git a/openpilot/system/hardware/chestnut/status.py b/openpilot/system/hardware/chestnut/status.py new file mode 100644 index 0000000000..c3321a3971 --- /dev/null +++ b/openpilot/system/hardware/chestnut/status.py @@ -0,0 +1,96 @@ +import time + +from openpilot.common.hardware.usb import CHESTNUT_USB_PRODUCT, is_chestnut_usb_id +from openpilot.selfdrive.modeld.helpers import chestnut_compiled + + +CHESTNUT_RELEASE_BRANCHES = ("release-chestnut", "release-chestnut-staging") +CHESTNUT_POWERED_VOLTAGE = 5000 +GPU_TEMP_LIMIT = 100. +MEMORY_TEMP_LIMIT = 95. +TEMP_HYSTERESIS = 5. + + +class ChestnutStatus: + def __init__(self): + self.started = time.monotonic() + self.offroad = True + self.pcie_failed = False + self.power_seen = False + self.power_unavailable = False + self.power_lost = False + self.power_restored = False + self.link_failures = 0 + self.model_loading_seen = False + self.model_attempted = False + self.overheated = False + self.usb_seen = False + self.usb_failed = False + + def update(self, offroad: bool, branch: str, usb_state: list[dict], firmware_failed: bool, + model_loading: bool, model_active: bool | None, state, set_alert) -> None: + detected = [d for d in usb_state if is_chestnut_usb_id(d["vendorId"], d["productId"], include_bootloader=True)] + devices = [d for d in detected if is_chestnut_usb_id(d["vendorId"], d["productId"])] + firmware_ok = len(devices) == 1 and devices[0]["product"] == CHESTNUT_USB_PRODUCT + + if self.offroad and not offroad: + self.pcie_failed = False + self.power_seen = False + self.power_unavailable = False + self.power_lost = False + self.power_restored = False + self.link_failures = 0 + self.model_loading_seen = False + self.model_attempted = False + self.usb_seen = firmware_ok + self.usb_failed = False + + self.model_loading_seen |= model_loading + self.model_attempted |= self.model_loading_seen and not model_loading and model_active is not None + + if not offroad and self.usb_seen and not firmware_ok: + self.usb_failed = True + + if not offroad and state is not None: + powered = state.supplyVoltage >= CHESTNUT_POWERED_VOLTAGE + power_lost = state.supplyFault or not powered + if self.model_attempted and power_lost and not self.power_lost: + self.power_unavailable = not self.power_seen + self.power_seen |= powered + + if not offroad and self.model_attempted and state is not None: + self.link_failures = self.link_failures + 1 if state.pcieLtssm != 0x78 else 0 + self.pcie_failed |= self.link_failures >= 2 or power_lost + self.power_lost |= power_lost + + if self.pcie_failed and self.power_lost and state is not None: + self.power_restored |= not state.supplyFault and state.supplyVoltage >= CHESTNUT_POWERED_VOLTAGE + if self.usb_failed: + self.pcie_failed = False + self.power_seen = False + self.power_unavailable = False + self.power_lost = False + self.power_restored = False + + if state is not None: + gpu_limit = GPU_TEMP_LIMIT - (TEMP_HYSTERESIS if self.overheated else 0.) + memory_limit = MEMORY_TEMP_LIMIT - (TEMP_HYSTERESIS if self.overheated else 0.) + self.overheated = state.tempC >= gpu_limit or state.memoryTempC >= memory_limit + + release = branch in CHESTNUT_RELEASE_BRANCHES + missing = self.usb_failed or (offroad and release and time.monotonic() - self.started > 10. and len(detected) != 1) + slow_usb = offroad and len(devices) == 1 and devices[0]["speedMbps"] < 5000 + set_alert("Offroad_ChestnutBranch", not release and len(devices) == 1) + set_alert("Offroad_ChestnutNotDetected", missing) + set_alert("Offroad_ChestnutOverheated", self.overheated, f"{state.tempC:.0f} °C" if state is not None else None) + set_alert("Offroad_ChestnutUsbSlow", slow_usb, f"{devices[0]['speedMbps']} Mbps" if slow_usb else None) + if self.power_lost: + pcie_alert = ("Chestnut power restored. 12V is stable again, cycle ignition." if self.power_restored else + "Chestnut power disconnected. Check 12V connection, then cycle ignition." if self.power_unavailable else + "Chestnut power lost. Possibly caused by an engine-crank voltage drop. Check 12V connection, then cycle ignition.") + else: + pcie_alert = "Chestnut GPU unavailable. PCIe link is not up. Check the GPU is securely seated." + set_alert("Offroad_ChestnutPcieUnavailable", self.pcie_failed, pcie_alert) + set_alert("Offroad_ChestnutUncompiled", offroad and firmware_ok and not chestnut_compiled()) + set_alert("Offroad_ChestnutUpdateFailed", offroad and firmware_failed) + self.offroad = offroad diff --git a/openpilot/system/hardware/hardwared.py b/openpilot/system/hardware/hardwared.py index 64ca4415d1..11fe41400d 100755 --- a/openpilot/system/hardware/hardwared.py +++ b/openpilot/system/hardware/hardwared.py @@ -16,16 +16,17 @@ from openpilot.common.utils import strip_deprecated_keys from openpilot.common.filter_simple import FirstOrderFilter from openpilot.common.params import Params from openpilot.common.realtime import DT_HW -from openpilot.selfdrive.modeld.helpers import MODELS_DIR, chestnut_compiled from openpilot.selfdrive.selfdrived.alertmanager import set_offroad_alert from openpilot.common.hardware import HARDWARE, COMMA_HARDWARE, PC from openpilot.common.basedir import BASEDIR -from openpilot.common.hardware.usb import CHESTNUT_FW_VERSION, CHESTNUT_ROM_USB_IDS, CHESTNUT_USB_IDS, get_usb_state, get_usb_topology, set_usb_state +from openpilot.common.git import get_short_branch +from openpilot.common.hardware.usb import CHESTNUT_FW_VERSION, CHESTNUT_USB_PRODUCT, get_usb_state, get_usb_topology, is_chestnut_usb_id, set_usb_state from openpilot.common.linux import LinuxSystemStats from openpilot.system.loggerd.config import get_available_percent from openpilot.common.swaglog import cloudlog from openpilot.system.hardware.power_monitoring import PowerMonitoring from openpilot.system.hardware.fan_controller import FanController +from openpilot.system.hardware.chestnut.status import ChestnutStatus from openpilot.common.version import terms_version, training_version from openpilot.system.athena.registration import UNREGISTERED_DONGLE_ID @@ -48,6 +49,11 @@ class Chestnut: self.attempts = 0 self.last_attempt = 0. self.flashed = False + self.mismatch = False + + @property + def failed(self) -> bool: + return self.mismatch and self.attempts >= self.MAX_ATTEMPTS and self.thread is not None and not self.thread.is_alive() and not self.flashed def flash(self) -> None: ret = subprocess.run(["sudo", sys.executable, os.path.join(BASEDIR, "openpilot/system/hardware/chestnut/flash.py"), CHESTNUT_FW_VERSION], @@ -56,9 +62,9 @@ class Chestnut: self.flashed = ret.returncode == 0 def update(self, offroad: bool, usb_state: list[dict]) -> None: - mismatch = any((d["vendorId"], d["productId"]) in CHESTNUT_USB_IDS + CHESTNUT_ROM_USB_IDS and - d["product"] != f"custom {CHESTNUT_FW_VERSION}-CLEAN" for d in usb_state) - if not mismatch: + self.mismatch = any(is_chestnut_usb_id(d["vendorId"], d["productId"], include_bootloader=True) and + d["product"] != CHESTNUT_USB_PRODUCT for d in usb_state) + if not self.mismatch: self.flashed = False return @@ -190,7 +196,7 @@ def hw_state_thread(end_event, hw_queue): def hardware_thread(end_event, hw_queue) -> None: system_stats = LinuxSystemStats() pm = messaging.PubMaster(['deviceState']) - sm = messaging.SubMaster(["peripheralState", "gpsLocationExternal", "selfdriveState", "pandaStates"], poll="pandaStates") + sm = messaging.SubMaster(["peripheralState", "gpsLocationExternal", "selfdriveState", "pandaStates", "chestnutState"], poll="pandaStates") count = 0 @@ -238,7 +244,8 @@ def hardware_thread(end_event, hw_queue) -> None: fan_controller = FanController(int(1./DT_HW)) chestnut = Chestnut() - big_model_available = (MODELS_DIR / 'big_driving_supercombo.onnx').is_file() or chestnut_compiled() + chestnut_status = ChestnutStatus() + branch = get_short_branch() while not end_event.is_set(): sm.update(PANDA_STATES_TIMEOUT) @@ -300,8 +307,11 @@ def hardware_thread(end_event, hw_queue) -> None: set_usb_state(msg.deviceState, last_hw_state.usb_state) chestnut.update(started_ts is None, last_hw_state.usb_state) - set_offroad_alert_if_changed("Offroad_ChestnutBranch", msg.deviceState.chestnutPresent and not big_model_available) - + chestnut_state = sm["chestnutState"] + chestnut_valid = sm.alive["chestnutState"] and sm.valid["chestnutState"] + chestnut_status.update(started_ts is None, branch, last_hw_state.usb_state, chestnut.failed, + params.get_bool("ChestnutLoading"), params.get("ChestnutActive"), + chestnut_state if chestnut_valid else None, set_offroad_alert_if_changed) # this subset is only used for offroad temp_sources = [ msg.deviceState.memoryTempC, From 7cf55c3b7a2d9bcee87821e413fa322866f64c5b Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Fri, 28 Aug 2026 22:11:52 -0700 Subject: [PATCH 45/67] common: fix OpenpilotPrefix cleanup on macOS (#38728) The destructor built its cleanup commands as "rm %s -rf", with the flags after the operand. GNU rm permutes arguments so this works on device and in CI, but BSD rm on macOS stops option parsing at the first operand and treats "-rf" as a second filename: $ mkdir -p /tmp/rmtest/sub && rm /tmp/rmtest -rf rm: /tmp/rmtest: is a directory rm: -rf: No such file or directory exit=1 So nothing is removed, and each of the four calls prints two errors plus "system command failed (256)" from check_system. Every run of a tool that owns an OpenpilotPrefix (replay, cabana) leaks its params dir, its comma_home and its /tmp/msgq_ dir; 33 of each had accumulated on my machine. Pass the flags first. --- openpilot/common/prefix.h | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openpilot/common/prefix.h b/openpilot/common/prefix.h index 89f346b9e5..0f2c592527 100644 --- a/openpilot/common/prefix.h +++ b/openpilot/common/prefix.h @@ -27,14 +27,14 @@ public: auto param_path = Params().getParamPath(); if (util::file_exists(param_path)) { std::string real_path = util::readlink(param_path); - util::check_system(util::string_format("rm %s -rf", real_path.c_str())); + util::check_system(util::string_format("rm -rf %s", real_path.c_str())); unlink(param_path.c_str()); } if (getenv("COMMA_CACHE") == nullptr) { - util::check_system(util::string_format("rm %s -rf", Path::download_cache_root().c_str())); + util::check_system(util::string_format("rm -rf %s", Path::download_cache_root().c_str())); } - util::check_system(util::string_format("rm %s -rf", Path::comma_home().c_str())); - util::check_system(util::string_format("rm %s -rf", msgq_path.c_str())); + util::check_system(util::string_format("rm -rf %s", Path::comma_home().c_str())); + util::check_system(util::string_format("rm -rf %s", msgq_path.c_str())); unsetenv("OPENPILOT_PREFIX"); } From 0e320594844b1c1d80aada3f8c51b6e0a1c14115 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 30 Aug 2026 09:32:36 -0700 Subject: [PATCH 46/67] replay: capture downloader's stderr so download progress is reported again (#38734) --- openpilot/tools/replay/py_downloader.cc | 47 +++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/openpilot/tools/replay/py_downloader.cc b/openpilot/tools/replay/py_downloader.cc index a265dfd6a3..db2b2be127 100644 --- a/openpilot/tools/replay/py_downloader.cc +++ b/openpilot/tools/replay/py_downloader.cc @@ -2,8 +2,11 @@ #include #include +#include +#include #include #include +#include #include #include @@ -14,8 +17,15 @@ namespace { static std::mutex handler_mutex; static DownloadProgressHandler progress_handler = nullptr; -// Run a Python command and capture stdout. Stderr is left attached to the parent. -// Returns stdout content. If abort is signaled, kills the child process. +void reportProgress(const char *line) { + uint64_t cur = 0, total = 0; + if (sscanf(line, "PROGRESS:%llu:%llu", (unsigned long long *)&cur, (unsigned long long *)&total) != 2) return; + std::lock_guard lk(handler_mutex); + if (progress_handler && total > 0) progress_handler(cur, total, true); +} + +// Run a Python command and capture stdout. Stderr is scanned for PROGRESS lines and otherwise passed +// through to the parent's stderr. Returns stdout content. If abort is signaled, kills the child process. std::string runPython(const std::vector &args, std::atomic *abort = nullptr) { // Build argv for execvp std::vector argv; @@ -27,16 +37,22 @@ std::string runPython(const std::vector &args, std::atomic *a } argv.push_back(nullptr); - int stdout_pipe[2]; + int stdout_pipe[2], stderr_pipe[2]; if (pipe(stdout_pipe) != 0) { rWarning("py_downloader: pipe() failed"); return {}; } + if (pipe(stderr_pipe) != 0) { + rWarning("py_downloader: pipe() failed"); + close(stdout_pipe[0]); close(stdout_pipe[1]); + return {}; + } pid_t pid = fork(); if (pid < 0) { rWarning("py_downloader: fork() failed"); close(stdout_pipe[0]); close(stdout_pipe[1]); + close(stderr_pipe[0]); close(stderr_pipe[1]); return {}; } @@ -57,6 +73,9 @@ std::string runPython(const std::vector &args, std::atomic *a close(stdout_pipe[0]); dup2(stdout_pipe[1], STDOUT_FILENO); close(stdout_pipe[1]); + close(stderr_pipe[0]); + dup2(stderr_pipe[1], STDERR_FILENO); + close(stderr_pipe[1]); execvp("python3", const_cast(argv.data())); _exit(127); @@ -64,6 +83,27 @@ std::string runPython(const std::vector &args, std::atomic *a // Parent process close(stdout_pipe[1]); + close(stderr_pipe[1]); + + // stderr carries the progress lines, so a thread reads it while the loop below waits on stdout + std::thread stderr_thread([fd = stderr_pipe[0]]() { + FILE *f = fdopen(fd, "r"); + if (!f) { + close(fd); + return; + } + char *line = nullptr; + size_t cap = 0; + while (getline(&line, &cap, f) > 0) { + if (strncmp(line, "PROGRESS:", 9) == 0) { + reportProgress(line); + } else { + fputs(line, stderr); + } + } + free(line); + fclose(f); + }); std::string stdout_data; char buf[4096]; @@ -102,6 +142,7 @@ std::string runPython(const std::vector &args, std::atomic *a stdout_data.append(buf, n); } close(stdout_pipe[0]); + stderr_thread.join(); int status; waitpid(pid, &status, 0); From de197ba6fab5b99f8d322139ca156d111f8cfec1 Mon Sep 17 00:00:00 2001 From: royjr Date: Sun, 30 Aug 2026 16:20:16 -0400 Subject: [PATCH 47/67] chestnut: alert when big model ready (#1947) egpu: alert when big model ready Co-authored-by: Jason Wen --- openpilot/cereal/custom.capnp | 1 + openpilot/selfdrive/selfdrived/selfdrived.py | 1 + openpilot/sunnypilot/selfdrive/selfdrived/events.py | 8 ++++++++ 3 files changed, 10 insertions(+) diff --git a/openpilot/cereal/custom.capnp b/openpilot/cereal/custom.capnp index 086b10c01c..cfbbbe472a 100644 --- a/openpilot/cereal/custom.capnp +++ b/openpilot/cereal/custom.capnp @@ -353,6 +353,7 @@ struct OnroadEventSP @0xda96579883444c35 { speedLimitPending @22; e2eChime @23; laneChangeRoadEdge @24; + bigModelReady @25; } } diff --git a/openpilot/selfdrive/selfdrived/selfdrived.py b/openpilot/selfdrive/selfdrived/selfdrived.py index 180b58c916..dfaa94e013 100755 --- a/openpilot/selfdrive/selfdrived/selfdrived.py +++ b/openpilot/selfdrive/selfdrived/selfdrived.py @@ -198,6 +198,7 @@ class SelfdriveD(CruiseHelper): loading = self.params.get_bool("ChestnutLoading") if self.big_model_loading and not loading: self.big_model_ready_t = time.monotonic() + self.events_sp.add(custom.OnroadEventSP.EventName.bigModelReady) self.big_model_loading = loading if self.big_model_loading: self.events.add(EventName.bigModelLoading) diff --git a/openpilot/sunnypilot/selfdrive/selfdrived/events.py b/openpilot/sunnypilot/selfdrive/selfdrived/events.py index 3c010cc776..b50873dea4 100644 --- a/openpilot/sunnypilot/selfdrive/selfdrived/events.py +++ b/openpilot/sunnypilot/selfdrive/selfdrived/events.py @@ -252,4 +252,12 @@ EVENTS_SP: dict[int, dict[str, Alert | AlertCallbackType]] = { AlertStatus.userPrompt, AlertSize.small, Priority.LOW, VisualAlert.none, AudibleAlert.prompt, 0.1), }, + + EventNameSP.bigModelReady: { + ET.PERMANENT: Alert( + "Big Model Ready", + "", + AlertStatus.normal, AlertSize.small, + Priority.LOW, VisualAlert.none, AudibleAlert.prompt, 2.), + }, } From 70df7f227b8b580fd4f6afbf0cdc5e5fa9155432 Mon Sep 17 00:00:00 2001 From: Robbe Derks Date: Mon, 31 Aug 2026 14:01:20 +0200 Subject: [PATCH 48/67] bump panda (new health packet) (#38736) pandad: support compact health packet --- openpilot/selfdrive/pandad/pandad.cc | 20 ++++++++++---------- panda | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/openpilot/selfdrive/pandad/pandad.cc b/openpilot/selfdrive/pandad/pandad.cc index 78d12bd2ed..d0b65586aa 100644 --- a/openpilot/selfdrive/pandad/pandad.cc +++ b/openpilot/selfdrive/pandad/pandad.cc @@ -111,22 +111,22 @@ void fill_panda_state(cereal::PandaState::Builder &ps, cereal::PandaState::Panda ps.setUptime(health.uptime_pkt); ps.setSafetyTxBlocked(health.safety_tx_blocked_pkt); ps.setSafetyRxInvalid(health.safety_rx_invalid_pkt); - ps.setIgnitionLine(health.ignition_line_pkt); - ps.setIgnitionCan(health.ignition_can_pkt); - ps.setControlsAllowed(health.controls_allowed_pkt); + ps.setIgnitionLine((health.flags_pkt & HEALTH_FLAG_IGNITION_LINE) != 0U); + ps.setIgnitionCan((health.flags_pkt & HEALTH_FLAG_IGNITION_CAN) != 0U); + ps.setControlsAllowed((health.flags_pkt & HEALTH_FLAG_CONTROLS_ALLOWED) != 0U); ps.setTxBufferOverflow(health.tx_buffer_overflow_pkt); ps.setRxBufferOverflow(health.rx_buffer_overflow_pkt); ps.setPandaType(hw_type); ps.setSafetyModel(cereal::CarParams::SafetyModel(health.safety_mode_pkt)); ps.setSafetyParam(health.safety_param_pkt); ps.setFaultStatus(cereal::PandaState::FaultStatus(health.fault_status_pkt)); - ps.setPowerSaveEnabled((bool)(health.power_save_enabled_pkt)); - ps.setHeartbeatLost((bool)(health.heartbeat_lost_pkt)); + ps.setPowerSaveEnabled((health.flags_pkt & HEALTH_FLAG_POWER_SAVE_ENABLED) != 0U); + ps.setHeartbeatLost((health.flags_pkt & HEALTH_FLAG_HEARTBEAT_LOST) != 0U); ps.setAlternativeExperience(health.alternative_experience_pkt); ps.setHarnessStatus(cereal::PandaState::HarnessStatus(health.car_harness_status_pkt)); - ps.setInterruptLoad(health.interrupt_load_pkt); + ps.setInterruptLoad(health.interrupt_load_pkt / 255.0f); ps.setFanPower(health.fan_power); - ps.setSafetyRxChecksInvalid((bool)(health.safety_rx_checks_invalid_pkt)); + ps.setSafetyRxChecksInvalid((health.flags_pkt & HEALTH_FLAG_SAFETY_RX_CHECKS_INVALID) != 0U); ps.setSpiErrorCount(health.spi_error_count_pkt); ps.setSbu1Voltage(health.sbu1_voltage_mV / 1000.0f); ps.setSbu2Voltage(health.sbu2_voltage_mV / 1000.0f); @@ -184,10 +184,10 @@ std::optional send_panda_states(PubMaster *pm, Panda *panda, bool is_onroa } if (spoofing_started) { - health.ignition_line_pkt = 1; + health.flags_pkt |= HEALTH_FLAG_IGNITION_LINE; } - bool ignition_local = ((health.ignition_line_pkt != 0) || (health.ignition_can_pkt != 0)); + bool ignition_local = (health.flags_pkt & (HEALTH_FLAG_IGNITION_LINE | HEALTH_FLAG_IGNITION_CAN)) != 0U; // Make sure CAN buses are live: safety_setter_thread does not work if Panda CAN are silent and there is only one other CAN node if (health.safety_mode_pkt == (uint8_t)(cereal::CarParams::SafetyModel::SILENT)) { @@ -195,7 +195,7 @@ std::optional send_panda_states(PubMaster *pm, Panda *panda, bool is_onroa } bool power_save_desired = !ignition_local; - if (health.power_save_enabled_pkt != power_save_desired) { + if (((health.flags_pkt & HEALTH_FLAG_POWER_SAVE_ENABLED) != 0U) != power_save_desired) { panda->set_power_saving(power_save_desired); } diff --git a/panda b/panda index dd8a5b3df7..75aa44bec9 160000 --- a/panda +++ b/panda @@ -1 +1 @@ -Subproject commit dd8a5b3df77706337a11555377e7180c5adc8726 +Subproject commit 75aa44bec9140849868239b1f1e3f22624adb8fe From 4adbb85742b465108ac579da680bcc633eedb2cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Mon, 31 Aug 2026 09:25:32 -0700 Subject: [PATCH 49/67] BMRLNAP (#38681) --- openpilot/selfdrive/modeld/SConscript | 6 ++++-- openpilot/selfdrive/modeld/compile_modeld.py | 12 +++++++----- .../modeld/models/big_driving_supercombo.onnx | 4 ++-- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/openpilot/selfdrive/modeld/SConscript b/openpilot/selfdrive/modeld/SConscript index f046be9915..f65aebf2f9 100644 --- a/openpilot/selfdrive/modeld/SConscript +++ b/openpilot/selfdrive/modeld/SConscript @@ -24,7 +24,9 @@ tinygrad_files = ["#"+x for x in glob.glob(env.Dir("#tinygrad_repo").relpath + " if 'pycache' not in x and os.path.isfile(os.path.join(tinygrad_root, x))] def estimate_pickle_max_size(onnx_size): - return 1.2 * onnx_size + 10 * 1024 * 1024 # 20% + 10MB is plenty + # QCOM programs for models with spatial recurrent features can approach 2x + # the ONNX size. Overestimating only adds an empty trailing chunk. + return 2.0 * onnx_size + 10 * 1024 * 1024 if arch == 'comma_arm64': tg_backend = 'QCOM' @@ -45,7 +47,7 @@ tg_devices = { # which device to put jit inputs to at runtime CHESTNUT = chestnut_present() if CHESTNUT: - chestnut_tg_flags = f'DEBUG=2 DEV=USB+AMD:LLVM WARP_DEV={tg_backend} FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2' + chestnut_tg_flags = f'DEBUG=1 DEV=USB+AMD:LLVM WARP_DEV={tg_backend} FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2' # the USB+AMD GPU takes an exclusive flock; serialize all targets that touch it chestnut_lock = File("models/.chestnut.lock").abspath diff --git a/openpilot/selfdrive/modeld/compile_modeld.py b/openpilot/selfdrive/modeld/compile_modeld.py index 2d27a41496..f54d09e869 100755 --- a/openpilot/selfdrive/modeld/compile_modeld.py +++ b/openpilot/selfdrive/modeld/compile_modeld.py @@ -139,16 +139,18 @@ def get_policy_npy_shapes(input_shapes): dp = input_shapes['desire_pulse'] # (1, 25, 8) tc = input_shapes['traffic_convention'] # (1, 2) at = input_shapes['action_t'] # (1, 2) - fb = input_shapes['features_buffer'] # (1, 24, 512) + fb = input_shapes['features_buffer'] # (1, T-1, ...) e.g. (1, 24, 32, 512) with spatial features + feat_dim = math.prod(fb[2:]) # TODO prev_feat shouldn't exist and be handled inside the JIT, but corrupt on QCOM for now - shapes = {'desire': (dp[2],), 'traffic_convention': tuple(tc), 'action_t': tuple(at), 'prev_feat': (fb[0], fb[2])} + shapes = {'desire': (dp[2],), 'traffic_convention': tuple(tc), 'action_t': tuple(at), 'prev_feat': (fb[0], feat_dim)} return shapes, [math.prod(s) for s in shapes.values()] def make_input_queues(input_shapes, frame_skip, device): input_queues, npy = make_warp_input_queues(input_shapes, frame_skip, device) - fb = input_shapes['features_buffer'] # (1, 24, 512), past features only; the model appends the current frame's feature + fb = input_shapes['features_buffer'] # (1, T-1, ...), past features only; the model appends the current frame's feature + feat_dim = math.prod(fb[2:]) dp = input_shapes['desire_pulse'] # (1, 25, 8) shapes, sizes = get_policy_npy_shapes(input_shapes) @@ -156,7 +158,7 @@ def make_input_queues(input_shapes, frame_skip, device): # views into the packed inputs, to be refilled at runtime npy.update({k: v.reshape(s) for (k, s), v in zip(shapes.items(), np.split(packed_npy_inputs, np.cumsum(sizes[:-1])), strict=True)}) input_queues.update({ - 'feat_q': Tensor(np.zeros((frame_skip * fb[1], fb[0], fb[2]), dtype=np.float32), device=device).contiguous().realize(), + 'feat_q': Tensor(np.zeros((frame_skip * fb[1], fb[0], feat_dim), dtype=np.float32), device=device).contiguous().realize(), 'desire_q': Tensor(np.zeros((frame_skip * dp[1], dp[0], dp[2]), dtype=np.float32), device=device).contiguous().realize(), 'packed_npy_inputs': Tensor(packed_npy_inputs, device='NPY').realize(), }) @@ -211,7 +213,7 @@ def make_run_policy(model_runner, model_metadata, frame_skip): inputs = { 'img': img, 'big_img': big_img, - 'features_buffer': feat_buf, + 'features_buffer': feat_buf.reshape(model_metadata['input_shapes']['features_buffer']), 'desire_pulse': desire_buf, 'traffic_convention': traffic_convention, 'action_t': action_t, diff --git a/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx b/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx index 4a04bd7833..bd92b1b876 100644 --- a/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx +++ b/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a501760a9d1d5fef0eab2b8c5d122d06124fc26dc8e0782e0aa94b82a208f0ff -size 1757355221 +oid sha256:a086d5249fc308bb73993d1e64630c669d4c7df5bde85f42ad61902543648525 +size 765953504 From 9fa7ef3d1722020ea83868c10182812d2650a336 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:46:52 -0700 Subject: [PATCH 50/67] ui: clarify branch switcher error message (#38732) --- openpilot/selfdrive/ui/mici/layouts/settings/software.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/software.py b/openpilot/selfdrive/ui/mici/layouts/settings/software.py index eeb6eb9bf1..7a3d84e1e7 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/software.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/software.py @@ -253,7 +253,7 @@ class TargetBranchButton(BigButton): def _on_click(self): if not ui_state.params.get("UpdaterAvailableBranches"): - gui_app.push_widget(BigDialog("", tr("Please connect to Wi-Fi to switch branches."))) + gui_app.push_widget(BigDialog("", tr("Failed to get available branches. Ensure you're connected to the internet and try again."))) return gui_app.push_widget(BranchSelectPage(self._on_select)) From da8ce858ec5a2478f17ac2e0b94bfd478f1c7a6e Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:55:16 -0700 Subject: [PATCH 51/67] ui(mici): name updater signal constants (#38731) * mici: name updater signal constants * drop SIGNAL_ prefix * self contained --------- Co-authored-by: Shane Smiskol --- .../selfdrive/ui/mici/layouts/settings/software.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/software.py b/openpilot/selfdrive/ui/mici/layouts/settings/software.py index 7a3d84e1e7..539ebaca11 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/software.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/software.py @@ -74,6 +74,10 @@ class SoftwareInfoLayoutMici(Widget): class CheckUpdateButton(BigButton): + UPDATER_PROC = "openpilot.system.updated.updated" + CHECK_FOR_UPDATE = "SIGUSR1" + DOWNLOAD_UPDATE = "SIGHUP" + def __init__(self): self._txt_update_icon = gui_app.texture("icons_mici/settings/device/update.png", 64, 75) self._txt_up_to_date_icon = gui_app.texture("icons_mici/settings/device/up_to_date.png", 64, 64) @@ -97,10 +101,10 @@ class CheckUpdateButton(BigButton): gui_app.push_widget(dlg) return - self._signal_updater("SIGHUP" if self.get_value() == "download update" else "SIGUSR1") + self._signal_updater(self.DOWNLOAD_UPDATE if self.get_value() == "download update" else self.CHECK_FOR_UPDATE) def check_for_update(self): - self._signal_updater("SIGUSR1") + self._signal_updater(self.CHECK_FOR_UPDATE) def _signal_updater(self, sig: str): self.set_enabled(False) @@ -110,7 +114,7 @@ class CheckUpdateButton(BigButton): self.set_icon(self._txt_update_icon) def run(): - subprocess.run(f"pkill -{sig} -f openpilot.system.updated.updated", shell=True) + subprocess.run(f"pkill -{sig} -f {self.UPDATER_PROC}", shell=True) threading.Thread(target=run, daemon=True).start() From 98ed8111f6bb5136aa1c7f0cf3078f3ac3a43210 Mon Sep 17 00:00:00 2001 From: James Vecellio-Grant <159560811+Discountchubbs@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:15:23 -0700 Subject: [PATCH 52/67] modeld_v2: big to small model fallback (#1974) --- openpilot/sunnypilot/modeld_v2/modeld.py | 72 +++++++++++-------- .../modeld_v2/tests/test_fallback.py | 62 ++++++++++++++++ openpilot/sunnypilot/models/default_model.py | 22 +++++- openpilot/sunnypilot/models/manager.py | 6 ++ openpilot/sunnypilot/models/model_name.py | 2 + 5 files changed, 133 insertions(+), 31 deletions(-) create mode 100644 openpilot/sunnypilot/modeld_v2/tests/test_fallback.py diff --git a/openpilot/sunnypilot/modeld_v2/modeld.py b/openpilot/sunnypilot/modeld_v2/modeld.py index d9c04d7824..cd421d70d5 100755 --- a/openpilot/sunnypilot/modeld_v2/modeld.py +++ b/openpilot/sunnypilot/modeld_v2/modeld.py @@ -8,22 +8,22 @@ See the LICENSE.md file in the root directory for more details. import os os.environ['GMMU'] = '0' +import numpy as np +import threading +import time +from setproctitle import setproctitle +from tinygrad.tensor import Tensor + +import openpilot.cereal.messaging as messaging from openpilot.common.hardware import COMMA_HARDWARE from openpilot.selfdrive.modeld.helpers import chestnut_present, load_oob -import time -import numpy as np -import openpilot.cereal.messaging as messaging from openpilot.cereal import log from opendbc.car.structs import car from openpilot.cereal.services import SERVICE_LIST -from setproctitle import setproctitle from openpilot.cereal.messaging import PubMaster, SubMaster from openpilot.cereal.visionipc import VisionStreamType from msgq.visionipc import VisionIpcClient, VisionBuf from opendbc.car.car_helpers import get_demo_car_params - -from tinygrad.tensor import Tensor - from openpilot.common.file_chunker import open_file_chunked from openpilot.common.swaglog import cloudlog from openpilot.common.params import Params @@ -42,13 +42,13 @@ from openpilot.sunnypilot.modeld_v2.constants import Plan from openpilot.sunnypilot.modeld_v2.meta_helper import load_meta_constants from openpilot.sunnypilot.modeld_v2.camera_offset_helper import CameraOffsetHelper from openpilot.sunnypilot.modeld_v2.compile_modeld import derive_frame_skip, make_split_input_queues, make_supercombo_input_queues, WARP_INPUTS, POLICY_INPUTS - from openpilot.sunnypilot.livedelay.helpers import get_lat_delay from openpilot.sunnypilot.modeld_v2.modeld_base import ModelStateBase from openpilot.sunnypilot.models.helpers import get_active_bundle from openpilot.sunnypilot.selfdrive.controls.lib.relc import RoadEdgeLaneChangeController PROCESS_NAME = "openpilot.selfdrive.modeld.modeld_tinygrad" +BIG_MODEL_TIMEOUT = 60 def _pkl_exists(path): @@ -68,6 +68,7 @@ def _find_driving_pkl(bundle): pkl_path = os.path.join(model_root, pkl_name) if _pkl_exists(pkl_path): return pkl_path + return None class FrameMeta: @@ -102,7 +103,7 @@ class ModelState(ModelStateBase): self.chestnut = chestnut pkl_path = _find_driving_pkl(model_bundle) - assert pkl_path is not None, "No driving pkl found — all models must be compiled with compile_modeld.py" + assert pkl_path is not None, f"No driving pkl found for {'chestnut' if chestnut else 'small model'} — all models must be compiled with compile_modeld.py" self._init_combined(pkl_path, cam_w, cam_h, model_bundle) def _init_combined(self, pkl_path, cam_w, cam_h, bundle): @@ -185,9 +186,6 @@ class ModelState(ModelStateBase): else: self.warp(**{k: self.input_queues[k] for k in WARP_INPUTS}, frame=frame_tensor, big_frame=big_frame_tensor) - if self.chestnut: - self.warmup() - def warmup(self) -> None: dummy_frames = {k: np.zeros(self.frame_buf_params[k][3], dtype=np.uint8) for k in self._vision_input_names} transforms = {k: np.eye(3, dtype=np.float32) for k in [self._road_key, self._wide_key] if k} @@ -288,8 +286,7 @@ class ModelState(ModelStateBase): buf[0, -1, :] = outputs['desired_curvature'][0, :] if not self.mlsim else 0 if self.chestnut and not np.all(np.isfinite(outputs.get('plan', np.array([0.])))): - cloudlog.error("model output not finite, dropping frame") - return None + raise RuntimeError("model output not finite") return outputs @@ -363,21 +360,26 @@ def main(demo=False): model = None if CHESTNUT: - import threading - def load(): - nonlocal model - model = ModelState(cam_w=vipc_client_main.width, cam_h=vipc_client_main.height, chestnut=True) - t = threading.Thread(target=load, daemon=True) - t.start() - t.join(60) - if model is None: - params.put_bool("ChestnutActive", False) - raise RuntimeError("chestnut model load failed or timed out (60s)") - params.put_bool("ChestnutActive", True) - else: - model = ModelState(cam_w=vipc_client_main.width, cam_h=vipc_client_main.height, chestnut=False) + big_model = None + def load_big(): + nonlocal big_model + try: + m = ModelState(cam_w=vipc_client_main.width, cam_h=vipc_client_main.height, chestnut=True) + m.warmup() + big_model = m + except Exception: + cloudlog.exception("chestnut load failed") + loader = threading.Thread(target=load_big, daemon=True) + loader.start() + loader.join(BIG_MODEL_TIMEOUT) + model = big_model + params.put_bool("ChestnutActive", model is not None) + small_model = ModelState(cam_w=vipc_client_main.width, cam_h=vipc_client_main.height, chestnut=False) if model is None or CHESTNUT else None + if model is None: + model = small_model params.put_bool("ChestnutLoading", False) + assert model is not None cloudlog.warning(f"models loaded in {time.monotonic() - st:.1f}s, modeld starting") # messaging @@ -386,7 +388,7 @@ def main(demo=False): sm = SubMaster(["deviceState", "carState", "narrowRoadCameraState", "extrinsicsCalibration", "driverMonitoringState", "carControl", "lateralDelay"]) publish_state = PublishState() - chestnut_state = ChestnutState(pm, CHESTNUT) if CHESTNUT else None + chestnut_state = ChestnutState(pm, model.chestnut) if CHESTNUT else None # setup filter to track dropped frames frame_dropped_filter = FirstOrderFilter(0., 10., 1. / model.constants.MODEL_FREQ) @@ -509,7 +511,19 @@ def main(demo=False): inputs['action_t'] = np.array([lat_action_t, long_action_t], dtype=np.float32) mt1 = time.perf_counter() - model_output = model.run(bufs, transforms, inputs, prepare_only) + try: + model_output = model.run(bufs, transforms, inputs, prepare_only) + except Exception: + if not params.get_bool("ChestnutActive"): + raise + cloudlog.exception("chestnut failed, falling back to small") + params.put_bool("ChestnutActive", False) + assert small_model is not None + model = small_model + if chestnut_state is not None: + chestnut_state.big = False + run_count = 0 + model_output = None mt2 = time.perf_counter() model_execution_time = mt2 - mt1 diff --git a/openpilot/sunnypilot/modeld_v2/tests/test_fallback.py b/openpilot/sunnypilot/modeld_v2/tests/test_fallback.py new file mode 100644 index 0000000000..1cc8410f06 --- /dev/null +++ b/openpilot/sunnypilot/modeld_v2/tests/test_fallback.py @@ -0,0 +1,62 @@ +""" +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 requests + +from openpilot.common.file_chunker import get_chunk_name +from openpilot.common.hardware import hw +from openpilot.common.test import OpenpilotTestCase +from openpilot.selfdrive.modeld.helpers import dump_oob +import openpilot.sunnypilot.modeld_v2.modeld as modeld_module +from openpilot.sunnypilot.modeld_v2.tests import helpers as tests_helpers +from openpilot.sunnypilot.modeld_v2.tests.helpers import DummyModel, DummyBundle, CAM_W, CAM_H +from openpilot.sunnypilot.models.fetcher import ModelParser, ModelFetcher + +tmp_path = tests_helpers.tmp_path + + +class TestFallback(OpenpilotTestCase): + def test_find_dual_model_in_bundle(self, tmp_path, monkeypatch): + lebowski_file = 'driving_lebowski.pkl' + tsfdo_file = 'driving_tsfdo.pkl' + (tmp_path / lebowski_file).write_bytes(b'fkasdjfkljf') + (tmp_path / tsfdo_file).write_bytes(b'dskfajklsdjlsfka') + + monkeypatch.setattr(hw.Paths, 'model_root', staticmethod(lambda: str(tmp_path))) + big_bundle = DummyBundle(models=[DummyModel('supercombo', lebowski_file)]) + small_bundle = DummyBundle(models=[DummyModel('supercombo', tsfdo_file)]) + big_pkl = modeld_module._find_driving_pkl(big_bundle) + small_pkl = modeld_module._find_driving_pkl(small_bundle) + + assert big_pkl is not None and lebowski_file in big_pkl + assert small_pkl is not None and tsfdo_file in small_pkl + + def test_download_models_and_init_modelstate_fallback(self, tmp_path, monkeypatch): + monkeypatch.setattr(hw.Paths, 'model_root', staticmethod(lambda: str(tmp_path))) + big_json = requests.get(ModelFetcher.MODEL_URL_CHESTNUT).json() + big_bundle = ModelParser.parse_models(big_json)[-1] + small_json = requests.get(ModelFetcher.MODEL_URL).json() + small_bundle = ModelParser.parse_models(small_json)[-1] + + buf = io.BytesIO() + dump_oob(tests_helpers.make_pkl_data(tests_helpers.ARCHETYPES['supercombo_non20hz']), buf) + oob_bytes = buf.getvalue() + + for bundle in (big_bundle, small_bundle): + artifact = bundle.models[0].artifact + for i in range(len(artifact.chunks)): + (tmp_path / get_chunk_name(artifact.fileName, i, len(artifact.chunks))).write_bytes(oob_bytes if i == 0 else b"") + + monkeypatch.setattr(modeld_module, 'get_active_bundle', lambda params=None, *, chestnut=None: small_bundle) + assert modeld_module.ModelState(CAM_W, CAM_H, chestnut=False).chestnut is False + + monkeypatch.setattr(modeld_module, 'get_active_bundle', lambda params=None, *, chestnut=None: big_bundle) + try: + assert modeld_module.ModelState(CAM_W, CAM_H, chestnut=True).chestnut is True + except Exception as e: + assert "AMD" in str(e) or "device" in str(e).lower() diff --git a/openpilot/sunnypilot/models/default_model.py b/openpilot/sunnypilot/models/default_model.py index 69f08c2cd3..e3c9360835 100755 --- a/openpilot/sunnypilot/models/default_model.py +++ b/openpilot/sunnypilot/models/default_model.py @@ -1,11 +1,14 @@ import argparse import os import hashlib +import requests +import re from openpilot.common.basedir import BASEDIR from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.sunnypilot import get_file_hash from openpilot.sunnypilot.models.model_name import DEFAULT_MODEL, DEFAULT_BIG_MODEL +from openpilot.sunnypilot.models.fetcher import ModelFetcher def get_default_model() -> str: @@ -30,14 +33,29 @@ def update_model_hash(): print(f"Generated and updated new combined model hash to {MODEL_HASH_PATH}") +def get_ref_for_name(url: str, name: str) -> str: + response = requests.get(url, timeout=10) + if response.status_code == 200: + bundles = response.json()["bundles"] + matching = [b for b in bundles if re.search(name, f"{b['short_name']} {b['display_name']}", re.IGNORECASE)] + if matching: + return max(matching, key=lambda b: int(b["index"]))["ref"] + return "" + + def update_default_model_names(default_model_name: str, default_big_model_name: str): print("[CHANGE DEFAULT MODEL NAMES]") + small_ref = get_ref_for_name(ModelFetcher.MODEL_URL, default_model_name) + big_ref = get_ref_for_name(ModelFetcher.MODEL_URL_CHESTNUT, default_big_model_name) + with open(DEFAULT_MODEL_NAME_PATH, "w") as f: f.write(f'DEFAULT_MODEL = "{default_model_name}"\n') + f.write(f'DEFAULT_MODEL_REF = "{small_ref}"\n') f.write(f'DEFAULT_BIG_MODEL = "{default_big_model_name}"\n') + f.write(f'DEFAULT_BIG_MODEL_REF = "{big_ref}"\n') - print(f'New default small model name: "{default_model_name}"') - print(f'New default big model name: "{default_big_model_name}"') + print(f'New default small model name: "{default_model_name}" (ref: {small_ref})') + print(f'New default big model name: "{default_big_model_name}" (ref: {big_ref})') print("[DONE]") diff --git a/openpilot/sunnypilot/models/manager.py b/openpilot/sunnypilot/models/manager.py index 16253db0ff..035f59891c 100644 --- a/openpilot/sunnypilot/models/manager.py +++ b/openpilot/sunnypilot/models/manager.py @@ -328,6 +328,12 @@ class ModelManagerSP: validate_active_bundles(self.params, self.source_models) self.active_bundle = get_active_bundle(self.params, chestnut=self.chestnut_present) + if get_selected_bundle(self.params, "chestnut") is not None and get_selected_bundle(self.params, "qcom") is None: + if self.params.get("ModelManager_DownloadRef") is None: + from openpilot.sunnypilot.models.model_name import DEFAULT_MODEL_REF + if DEFAULT_MODEL_REF: + self.params.put("ModelManager_DownloadRef", DEFAULT_MODEL_REF) + self._process_download_requests() if self.params.get("ModelManager_ClearCache"): diff --git a/openpilot/sunnypilot/models/model_name.py b/openpilot/sunnypilot/models/model_name.py index 374e8473df..fce14d0990 100644 --- a/openpilot/sunnypilot/models/model_name.py +++ b/openpilot/sunnypilot/models/model_name.py @@ -1,2 +1,4 @@ DEFAULT_MODEL = "CD210" +DEFAULT_MODEL_REF = "5b6436a90cf6902b8aaa71c2b6f3d7164d8ae391" DEFAULT_BIG_MODEL = "Lebowski" +DEFAULT_BIG_MODEL_REF = "fa0c6876d3cf070e91e25e5353ceadc68a5b3285" From e10c0fd960016e385f1135d9af4af03b634a7ad2 Mon Sep 17 00:00:00 2001 From: XiaoXX Date: Tue, 1 Sep 2026 12:35:56 +0800 Subject: [PATCH 53/67] modem.py: accept hex chars in ICCID (#38735) E.118 specifies decimal digits, but many real SIMs carry hex characters in EF_ICCID (e.g. China Mobile's 898600B5... range, some MVNO/IoT SIMs). AT+QCCID returns them verbatim, and the strict isdigit() check blanked the ICCID, leaving the modem daemon stuck in INITIALIZING forever and cellular dead. ModemManager parses ICCID as hex for the same reason. Verified on a comma four with a China Mobile SIM (EG916Q-GL): previously stuck retrying 'identity read incomplete', now dials and passes traffic. --- openpilot/common/hardware/comma/modem.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpilot/common/hardware/comma/modem.py b/openpilot/common/hardware/comma/modem.py index 171ed8c0e4..901efae2b1 100755 --- a/openpilot/common/hardware/comma/modem.py +++ b/openpilot/common/hardware/comma/modem.py @@ -5,6 +5,7 @@ import logging import os import select import signal +import string import struct import subprocess import tempfile @@ -354,7 +355,7 @@ class Modem: imei = "" iccid = (self._atv("AT+QCCID", "+QCCID:") or "").rstrip("F") - if not iccid.isdigit(): + if not all(c in string.hexdigits for c in iccid): iccid = "" imsi = first_line("AT+CIMI") From 51987a62d07c44cc9e14b4d85dcb445edecd17d3 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Tue, 1 Sep 2026 01:11:30 -0400 Subject: [PATCH 54/67] ci: route build_model runner by hardware type --- .github/workflows/sunnypilot-build-model.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sunnypilot-build-model.yaml b/.github/workflows/sunnypilot-build-model.yaml index 459fa74595..10be63ee22 100644 --- a/.github/workflows/sunnypilot-build-model.yaml +++ b/.github/workflows/sunnypilot-build-model.yaml @@ -121,7 +121,7 @@ jobs: if-no-files-found: error build_model: - runs-on: [self-hosted, chestnut] + runs-on: [self-hosted, "${{ inputs.target_hardware == 'chestnut' && 'chestnut' || 'tici' }}"] needs: get_model env: MODEL_NAME: ${{ inputs.custom_name || inputs.upstream_branch }} (${{ needs.get_model.outputs.model_date }}) From a2e422eee0e92523101329437b24bafa09538aa7 Mon Sep 17 00:00:00 2001 From: YassineYousfi Date: Mon, 31 Aug 2026 22:30:28 -0700 Subject: [PATCH 55/67] TGC (#38739) * 23e6a04e-e6e5-462b-a0bb-e4088275ee43/12864 tgc * here --- openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx | 4 ++-- tinygrad_repo | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx b/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx index bd92b1b876..7b51732275 100644 --- a/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx +++ b/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a086d5249fc308bb73993d1e64630c669d4c7df5bde85f42ad61902543648525 -size 765953504 +oid sha256:1791d5940b2c048d0639813426dd2cf1d6f2a6727ed51e17c8bcea8bbe754123 +size 765950064 diff --git a/tinygrad_repo b/tinygrad_repo index c015351ac5..b87159cee1 160000 --- a/tinygrad_repo +++ b/tinygrad_repo @@ -1 +1 @@ -Subproject commit c015351ac5c00c10c58dbdaf530ef5b2883ab948 +Subproject commit b87159cee1b137c327f901a6aef69f394aa629f6 From 7d5596d5c3527f274a3615181f52095909ffb8e9 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Tue, 1 Sep 2026 05:59:02 -0700 Subject: [PATCH 56/67] monitor chestnut USB in hardwared (#38741) hardwared: monitor chestnut USB independently --- openpilot/cereal/log.capnp | 1 + openpilot/cereal/services.py | 1 + openpilot/selfdrive/modeld/modeld.py | 67 ++-------- .../system/hardware/chestnut/monitoring.py | 124 ++++++++++++++++++ openpilot/system/hardware/chestnut/status.py | 37 ++++-- openpilot/system/hardware/hardwared.py | 25 +++- 6 files changed, 180 insertions(+), 75 deletions(-) create mode 100644 openpilot/system/hardware/chestnut/monitoring.py diff --git a/openpilot/cereal/log.capnp b/openpilot/cereal/log.capnp index fb4867b2a6..9ca1092e89 100644 --- a/openpilot/cereal/log.capnp +++ b/openpilot/cereal/log.capnp @@ -2593,6 +2593,7 @@ struct Event { clocks @35 :Clocks; deviceState @6 :DeviceState; chestnutState @152 :ChestnutState; + chestnutGpuState @153 :ChestnutState; logMessage @18 :Text; errorLogMessage @85 :Text; diff --git a/openpilot/cereal/services.py b/openpilot/cereal/services.py index 08633d6975..f906e4610f 100755 --- a/openpilot/cereal/services.py +++ b/openpilot/cereal/services.py @@ -26,6 +26,7 @@ _services: dict[str, tuple] = { "temperatureSensor": (True, 2., 200), "deviceState": (True, 2., 1), "chestnutState": (True, 10., 10), + "chestnutGpuState": (False, 10.), "touch": (True, 20., 1), "can": (True, 100., 2053, QueueSize.BIG), # decimation gives ~3 msgs in a full segment "controlsState": (True, 100., 10, QueueSize.MEDIUM), diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index a795f366ce..1178349c47 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -6,8 +6,6 @@ import os os.environ['GMMU'] = '0' # for chestnut fast loading, noop for qcom from tinygrad.tensor import Tensor from tinygrad.device import Device -import usb1 -import struct import threading import time import numpy as np @@ -32,7 +30,6 @@ from openpilot.selfdrive.modeld.parse_model_outputs import Parser from openpilot.selfdrive.modeld.compile_modeld import make_input_queues, WARP_INPUTS, POLICY_INPUTS from openpilot.selfdrive.modeld.fill_model_msg import fill_model_msg, fill_driving_model_data, fill_pose_msg, PublishState from openpilot.common.file_chunker import open_file_chunked -from openpilot.common.hardware.usb import CHESTNUT_USB_IDS from openpilot.selfdrive.modeld.constants import ModelConstants, Plan from openpilot.selfdrive.modeld.helpers import chestnut_present, chestnut_compiled, modeld_pkl_path, get_tg_input_devices, load_oob @@ -73,45 +70,14 @@ def get_action_from_model(model_output: dict[str, np.ndarray], prev_action: log. shouldStop=bool(stop)) -class ChestnutState: - # only modeld can access chestnut +class ChestnutGpuState: + # SMU metrics are only accessible from modeld. def __init__(self, pm: PubMaster, big: bool): self.pm = pm self.big = big self.valid = True self.sends = 0 self.metrics = {} - self._asm_usb = None - - def _close_asm_usb(self) -> None: - if self._asm_usb is not None: - self._asm_usb.close() - self._asm_usb = None - - def _open_asm_usb(self): - context = usb1.USBContext() - for vendor_id, product_id in CHESTNUT_USB_IDS: - if (handle := context.openByVendorIDAndProductID(vendor_id, product_id, skip_on_error=True)) is not None: - return handle - context.close() - - def _read_ina(self) -> tuple[int, int, bool]: - if "AMD" in Device._opened_devices and self._asm_usb is None: - try: - raw = Device["AMD"].iface.pci_dev.usb.usb.control_read(0xC0, 5) - return struct.unpack(' int: @@ -119,8 +85,6 @@ class ChestnutState: return smu._send_msg(smu.smu_mod.PPSMC_MSG_GetPptLimit, 0, read_back_arg=True, timeout=100) def send(self) -> None: - msg = messaging.new_message('chestnutState') - state = msg.chestnutState self.sends += 1 if self.big and "AMD" in Device._opened_devices and self.sends % 100 == 1: try: @@ -142,25 +106,14 @@ class ChestnutState: cloudlog.exception("chestnut state read failed") self.valid = False self.metrics.clear() + + msg = messaging.new_message('chestnutGpuState') + state = msg.chestnutGpuState if self.big: for k, v in self.metrics.items(): setattr(state, k, v) - - asm_valid = False - try: - # ASM runs on USB-C power, these still read without a gpu - state.supplyVoltage, state.supplyCurrent, state.supplyFault = self._read_ina() - asm_valid = True - except Exception: - pass - if "AMD" in Device._opened_devices: - try: - state.pcieLtssm = Device["AMD"].iface.pci_dev.usb.read(0xB450, 1)[0] - except Exception: - pass - - msg.valid = asm_valid and (not self.big or self.valid) - self.pm.send('chestnutState', msg) + msg.valid = self.big and self.valid + self.pm.send('chestnutGpuState', msg) class FrameMeta: @@ -310,13 +263,13 @@ def main(demo=False): cloudlog.warning(f"models loaded in {time.monotonic() - st:.1f}s, modeld starting") # messaging - pub_socks = ["modelV2", "drivingModelData", "cameraOdometry"] + (["chestnutState"] if CHESTNUT else []) + pub_socks = ["modelV2", "drivingModelData", "cameraOdometry"] + (["chestnutGpuState"] if CHESTNUT else []) pm = PubMaster(pub_socks) sm = SubMaster(["deviceState", "carState", "narrowRoadCameraState", "extrinsicsCalibration", "driverMonitoringState", "carControl", "lateralDelay"]) publish_state = PublishState() params = Params() - chestnut_state = ChestnutState(pm, model.chestnut) if CHESTNUT else None + chestnut_state = ChestnutGpuState(pm, model.chestnut) if CHESTNUT else None # setup filter to track dropped frames frame_dropped_filter = FirstOrderFilter(0., 10., 1. / ModelConstants.MODEL_RUN_FREQ) @@ -425,7 +378,7 @@ def main(demo=False): mt1 = time.perf_counter() try: send_chestnut = (chestnut_state is not None and - run_count % round(ModelConstants.MODEL_RUN_FREQ / SERVICE_LIST['chestnutState'].frequency) == 0) + run_count % round(ModelConstants.MODEL_RUN_FREQ / SERVICE_LIST['chestnutGpuState'].frequency) == 0) model_output = model.run(bufs, transforms, inputs, chestnut_state.send if send_chestnut else None) except Exception: if not params.get_bool("ChestnutActive"): diff --git a/openpilot/system/hardware/chestnut/monitoring.py b/openpilot/system/hardware/chestnut/monitoring.py new file mode 100644 index 0000000000..829db99618 --- /dev/null +++ b/openpilot/system/hardware/chestnut/monitoring.py @@ -0,0 +1,124 @@ +import struct +from contextlib import suppress + +import usb1 + +import openpilot.cereal.messaging as messaging +from openpilot.cereal.services import SERVICE_LIST +from openpilot.common.hardware.usb import CHESTNUT_USB_IDS + + +USB_TIMEOUT_MS = 100 +PCIE_LTSSM_ADDRESS = 0xB450 + + +class ChestnutUsb: + def __init__(self): + self.context: usb1.USBContext | None = None + self.handle = None + + def close(self) -> None: + handle, context = self.handle, self.context + self.handle = None + self.context = None + with suppress(Exception): + if handle is not None: + handle.close() + with suppress(Exception): + if context is not None: + context.close() + + def connect(self) -> bool: + if self.handle is not None: + return True + + context = usb1.USBContext() + for vendor_id, product_id in CHESTNUT_USB_IDS: + handle = context.openByVendorIDAndProductID(vendor_id, product_id, skip_on_error=True) + if handle is not None: + self.context = context + self.handle = handle + return True + context.close() + return False + + def _read(self, request: int, value: int, length: int) -> bytes: + if self.handle is None: + raise usb1.USBErrorNoDevice + raw = bytes(self.handle.controlRead(0xC0, request, value, 0, length, timeout=USB_TIMEOUT_MS)) + if len(raw) != length: + raise ValueError(f"short chestnut USB response: {len(raw)}/{length}") + return raw + + def read_ina(self) -> tuple[int, int, bool]: + return struct.unpack(' int: + return self._read(0xE4, PCIE_LTSSM_ADDRESS, 1)[0] + + +class ChestnutMonitoring: + def __init__(self, usb: ChestnutUsb | None = None): + self.usb = usb or ChestnutUsb() + self.gpu_state = None + self.seen = False + self.enabled = False + self.usb_failed = False + + def set_enabled(self, enabled: bool) -> None: + if self.enabled == enabled: + return + self.enabled = enabled + self.usb.close() + self.usb_failed = False + + def retry(self) -> None: + self.usb_failed = False + + def model_alive(self, sm: messaging.SubMaster, now: float) -> bool: + modeld = next((p for p in sm['managerState'].processes if p.name == 'modeld'), None) + if modeld is not None and modeld.shouldBeRunning and not modeld.running: + return False + recv_time = sm.recv_time['chestnutGpuState'] + return recv_time > 0. and now - recv_time < 10. / SERVICE_LIST['chestnutGpuState'].frequency + + def update_gpu_state(self, sm: messaging.SubMaster, now: float) -> None: + if sm.updated['chestnutGpuState']: + self.gpu_state = sm['chestnutGpuState'] if sm.valid['chestnutGpuState'] else None + elif not self.model_alive(sm, now): + self.gpu_state = None + + def update(self, sm: messaging.SubMaster, now: float, model_loading: bool = False): + self.update_gpu_state(sm, now) + return self.build_message(model_loading) + + def build_message(self, model_loading: bool = False): + if not self.enabled: + return None + + msg = messaging.new_message('chestnutState') + if self.gpu_state is not None: + msg.chestnutState = self.gpu_state + state = msg.chestnutState + + if self.usb_failed: + return msg if self.seen else None + + try: + if not self.usb.connect(): + self.usb_failed = True + return msg if self.seen else None + + self.seen = True + voltage, current, fault = self.usb.read_ina() + pcie_ltssm = self.usb.read_pcie_ltssm() + state.supplyVoltage = voltage + state.supplyCurrent = current + state.supplyFault = fault + state.pcieLtssm = pcie_ltssm + msg.valid = True + except Exception as e: + if not model_loading or not isinstance(e, usb1.USBErrorTimeout): + self.usb.close() + self.usb_failed = True + return msg diff --git a/openpilot/system/hardware/chestnut/status.py b/openpilot/system/hardware/chestnut/status.py index c3321a3971..34456b2c11 100644 --- a/openpilot/system/hardware/chestnut/status.py +++ b/openpilot/system/hardware/chestnut/status.py @@ -21,14 +21,13 @@ class ChestnutStatus: self.power_lost = False self.power_restored = False self.link_failures = 0 - self.model_loading_seen = False self.model_attempted = False self.overheated = False self.usb_seen = False self.usb_failed = False def update(self, offroad: bool, branch: str, usb_state: list[dict], firmware_failed: bool, - model_loading: bool, model_active: bool | None, state, set_alert) -> None: + model_active: bool | None, state, usb_failed: bool, set_alert) -> None: detected = [d for d in usb_state if is_chestnut_usb_id(d["vendorId"], d["productId"], include_bootloader=True)] devices = [d for d in detected if is_chestnut_usb_id(d["vendorId"], d["productId"])] firmware_ok = len(devices) == 1 and devices[0]["product"] == CHESTNUT_USB_PRODUCT @@ -40,16 +39,15 @@ class ChestnutStatus: self.power_lost = False self.power_restored = False self.link_failures = 0 - self.model_loading_seen = False self.model_attempted = False self.usb_seen = firmware_ok self.usb_failed = False - self.model_loading_seen |= model_loading - self.model_attempted |= self.model_loading_seen and not model_loading and model_active is not None + self.model_attempted |= model_active is not None - if not offroad and self.usb_seen and not firmware_ok: - self.usb_failed = True + if not offroad: + self.usb_seen |= firmware_ok + self.usb_failed = not offroad and self.usb_seen and (not firmware_ok or usb_failed) if not offroad and state is not None: powered = state.supplyVoltage >= CHESTNUT_POWERED_VOLTAGE @@ -80,17 +78,28 @@ class ChestnutStatus: release = branch in CHESTNUT_RELEASE_BRANCHES missing = self.usb_failed or (offroad and release and time.monotonic() - self.started > 10. and len(detected) != 1) slow_usb = offroad and len(devices) == 1 and devices[0]["speedMbps"] < 5000 - set_alert("Offroad_ChestnutBranch", not release and len(devices) == 1) - set_alert("Offroad_ChestnutNotDetected", missing) - set_alert("Offroad_ChestnutOverheated", self.overheated, f"{state.tempC:.0f} °C" if state is not None else None) - set_alert("Offroad_ChestnutUsbSlow", slow_usb, f"{devices[0]['speedMbps']} Mbps" if slow_usb else None) + update_failed = offroad and firmware_failed + compiled = firmware_ok and chestnut_compiled() + uncompiled = offroad and firmware_ok and not compiled + if self.power_lost: pcie_alert = ("Chestnut power restored. 12V is stable again, cycle ignition." if self.power_restored else "Chestnut power disconnected. Check 12V connection, then cycle ignition." if self.power_unavailable else "Chestnut power lost. Possibly caused by an engine-crank voltage drop. Check 12V connection, then cycle ignition.") else: pcie_alert = "Chestnut GPU unavailable. PCIe link is not up. Check the GPU is securely seated." - set_alert("Offroad_ChestnutPcieUnavailable", self.pcie_failed, pcie_alert) - set_alert("Offroad_ChestnutUncompiled", offroad and firmware_ok and not chestnut_compiled()) - set_alert("Offroad_ChestnutUpdateFailed", offroad and firmware_failed) + + alerts = ( + ("Offroad_ChestnutNotDetected", missing, None), + ("Offroad_ChestnutUpdateFailed", update_failed, None), + ("Offroad_ChestnutUncompiled", uncompiled, None), + ("Offroad_ChestnutPcieUnavailable", self.pcie_failed, pcie_alert), + ("Offroad_ChestnutOverheated", self.overheated, f"{state.tempC:.0f} °C" if state is not None else None), + ("Offroad_ChestnutUsbSlow", slow_usb, f"{devices[0]['speedMbps']} Mbps" if slow_usb else None), + ) + active_alert = next((name for name, active, _ in alerts if active), None) + + set_alert("Offroad_ChestnutBranch", not release and len(devices) == 1 and not missing) + for name, _, extra_text in alerts: + set_alert(name, name == active_alert, extra_text) self.offroad = offroad diff --git a/openpilot/system/hardware/hardwared.py b/openpilot/system/hardware/hardwared.py index 11fe41400d..c7d11fa5b0 100755 --- a/openpilot/system/hardware/hardwared.py +++ b/openpilot/system/hardware/hardwared.py @@ -27,6 +27,7 @@ from openpilot.common.swaglog import cloudlog from openpilot.system.hardware.power_monitoring import PowerMonitoring from openpilot.system.hardware.fan_controller import FanController from openpilot.system.hardware.chestnut.status import ChestnutStatus +from openpilot.system.hardware.chestnut.monitoring import ChestnutMonitoring from openpilot.common.version import terms_version, training_version from openpilot.system.athena.registration import UNREGISTERED_DONGLE_ID @@ -195,8 +196,9 @@ def hw_state_thread(end_event, hw_queue): def hardware_thread(end_event, hw_queue) -> None: system_stats = LinuxSystemStats() - pm = messaging.PubMaster(['deviceState']) - sm = messaging.SubMaster(["peripheralState", "gpsLocationExternal", "selfdriveState", "pandaStates", "chestnutState"], poll="pandaStates") + pm = messaging.PubMaster(['deviceState', 'chestnutState']) + sm = messaging.SubMaster(["peripheralState", "gpsLocationExternal", "selfdriveState", "pandaStates", + "chestnutState", "chestnutGpuState", "managerState"], poll="pandaStates") count = 0 @@ -244,7 +246,9 @@ def hardware_thread(end_event, hw_queue) -> None: fan_controller = FanController(int(1./DT_HW)) chestnut = Chestnut() + chestnut_monitoring = ChestnutMonitoring() chestnut_status = ChestnutStatus() + model_loading = params.get_bool("ChestnutLoading") branch = get_short_branch() while not end_event.is_set(): @@ -276,6 +280,8 @@ def hardware_thread(end_event, hw_queue) -> None: # Run at 2Hz, plus either edge of ignition ign_edge = (started_ts is not None) != all(onroad_conditions.values()) if (sm.frame % round(SERVICE_LIST['pandaStates'].frequency * DT_HW) != 0) and not ign_edge: + if (chestnut_msg := chestnut_monitoring.update(sm, time.monotonic(), model_loading)) is not None: + pm.send('chestnutState', chestnut_msg) continue msg = messaging.new_message('deviceState', valid=True) @@ -309,9 +315,11 @@ def hardware_thread(end_event, hw_queue) -> None: chestnut.update(started_ts is None, last_hw_state.usb_state) chestnut_state = sm["chestnutState"] chestnut_valid = sm.alive["chestnutState"] and sm.valid["chestnutState"] + model_loading = params.get_bool("ChestnutLoading") + model_active = params.get("ChestnutActive") chestnut_status.update(started_ts is None, branch, last_hw_state.usb_state, chestnut.failed, - params.get_bool("ChestnutLoading"), params.get("ChestnutActive"), - chestnut_state if chestnut_valid else None, set_offroad_alert_if_changed) + model_active, chestnut_state if chestnut_valid else None, chestnut_monitoring.usb_failed, + set_offroad_alert_if_changed) # this subset is only used for offroad temp_sources = [ msg.deviceState.memoryTempC, @@ -419,6 +427,15 @@ def hardware_thread(end_event, hw_queue) -> None: if off_ts is None: off_ts = time.monotonic() + chestnut_usb_ready = any(is_chestnut_usb_id(d["vendorId"], d["productId"]) and d["product"] == CHESTNUT_USB_PRODUCT + for d in last_hw_state.usb_state) + flash_active = chestnut.thread is not None and chestnut.thread.is_alive() + chestnut_monitoring.set_enabled(started_ts is not None and (chestnut_usb_ready or chestnut_monitoring.seen) and not flash_active) + if chestnut_usb_ready and chestnut_monitoring.usb_failed: + chestnut_monitoring.retry() + if (chestnut_msg := chestnut_monitoring.update(sm, time.monotonic(), model_loading)) is not None: + pm.send('chestnutState', chestnut_msg) + # Offroad power monitoring voltage = None if peripheralState.pandaType == log.PandaState.PandaType.unknown else peripheralState.voltage power_monitor.calculate(voltage, onroad_conditions["ignition"]) From 06af2abe67db217f96e6152ebf4f018fa1cb4bca Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Tue, 1 Sep 2026 07:20:56 -0700 Subject: [PATCH 57/67] modeld: wait for stable chestnut (#38742) modeld: wait for stable chestnut --- openpilot/common/params_keys.h | 1 + openpilot/selfdrive/modeld/helpers.py | 6 ++++++ openpilot/selfdrive/modeld/modeld.py | 24 +++++++++++++++++++++--- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index ba6eae3dd5..07a2283ece 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -135,5 +135,6 @@ inline static std::unordered_map keys = { {"UptimeOnroad", {PERSISTENT, FLOAT, "0.0"}}, {"ChestnutActive", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}}, {"ChestnutLoading", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}}, + {"ChestnutModelError", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}}, {"Version", {PERSISTENT, STRING}}, }; diff --git a/openpilot/selfdrive/modeld/helpers.py b/openpilot/selfdrive/modeld/helpers.py index d081050055..23eb6cbd21 100644 --- a/openpilot/selfdrive/modeld/helpers.py +++ b/openpilot/selfdrive/modeld/helpers.py @@ -11,6 +11,8 @@ from openpilot.common.hardware.usb import CHESTNUT_USB_PRODUCT, USB_DEVICES_PATH MODELS_DIR = Path(__file__).resolve().parent / 'models' TG_INPUT_DEVICES_PATH = MODELS_DIR / 'tg_input_devices.json' +CHESTNUT_POWERED_VOLTAGE = 5000 +CHESTNUT_PCIE_READY = 0x78 def get_tg_input_devices(process_name: str, chestnut: bool): @@ -58,3 +60,7 @@ def chestnut_present() -> bool: def chestnut_compiled() -> bool: return Path(get_manifest_path(modeld_pkl_path(chestnut=True))).is_file() + + +def chestnut_ready(state) -> bool: + return state.supplyVoltage >= CHESTNUT_POWERED_VOLTAGE and not state.supplyFault and state.pcieLtssm == CHESTNUT_PCIE_READY diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index 1178349c47..c31418ee0d 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -31,7 +31,7 @@ from openpilot.selfdrive.modeld.compile_modeld import make_input_queues, WARP_IN from openpilot.selfdrive.modeld.fill_model_msg import fill_model_msg, fill_driving_model_data, fill_pose_msg, PublishState from openpilot.common.file_chunker import open_file_chunked from openpilot.selfdrive.modeld.constants import ModelConstants, Plan -from openpilot.selfdrive.modeld.helpers import chestnut_present, chestnut_compiled, modeld_pkl_path, get_tg_input_devices, load_oob +from openpilot.selfdrive.modeld.helpers import chestnut_present, chestnut_compiled, chestnut_ready, modeld_pkl_path, get_tg_input_devices, load_oob PROCESS_NAME = "openpilot.selfdrive.modeld.modeld" SEND_RAW_PRED = os.getenv('SEND_RAW_PRED') @@ -205,12 +205,25 @@ class ModelState: def main(demo=False): cloudlog.warning("modeld init") - CHESTNUT = chestnut_present() and chestnut_compiled() + chestnut_available = chestnut_present() and chestnut_compiled() + CHESTNUT = False + if chestnut_available: + poller = messaging.Poller() + sock = messaging.sub_sock("chestnutState", poller=poller, conflate=True) + deadline = time.monotonic() + 4. / SERVICE_LIST['deviceState'].frequency + while not CHESTNUT and (remaining := deadline - time.monotonic()) > 0.: + if not poller.poll(round(remaining * 1000)): + break + msg = messaging.recv_one_or_none(sock) + CHESTNUT = msg is not None and msg.valid and chestnut_ready(msg.chestnutState) if CHESTNUT: os.environ['HCQDEV_WAIT_TIMEOUT_MS'] = '3000' params = Params() params.put_bool("ChestnutLoading", CHESTNUT) - params.remove("ChestnutActive") + if chestnut_available and not CHESTNUT: + params.put_bool("ChestnutActive", False) + else: + params.remove("ChestnutActive") config_realtime_process(7, 54) @@ -254,7 +267,11 @@ def main(demo=False): loader.start() loader.join(BIG_MODEL_TIMEOUT) model = big_model + if model is None: + params.put_bool("ChestnutModelError", True) params.put_bool("ChestnutActive", model is not None) + if model is not None: + params.remove("ChestnutModelError") small_model = ModelState(vipc_client_main.width, vipc_client_main.height, False) if model is None or CHESTNUT else None if model is None: @@ -385,6 +402,7 @@ def main(demo=False): raise # fallback to small model cloudlog.exception("big model failed, fall back to small") + params.put_bool("ChestnutModelError", True) params.put_bool("ChestnutActive", False) model = small_model if chestnut_state is not None: From c9f1602040149be793ea3d3e17a5fd8d915408c8 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Tue, 1 Sep 2026 11:13:30 -0700 Subject: [PATCH 58/67] Revert "monitor chestnut USB in hardwared (#38741)" (#38744) This reverts commit 7d5596d5c3527f274a3615181f52095909ffb8e9. --- openpilot/cereal/log.capnp | 1 - openpilot/cereal/services.py | 1 - openpilot/selfdrive/modeld/modeld.py | 67 ++++++++-- .../system/hardware/chestnut/monitoring.py | 124 ------------------ openpilot/system/hardware/chestnut/status.py | 37 ++---- openpilot/system/hardware/hardwared.py | 25 +--- 6 files changed, 75 insertions(+), 180 deletions(-) delete mode 100644 openpilot/system/hardware/chestnut/monitoring.py diff --git a/openpilot/cereal/log.capnp b/openpilot/cereal/log.capnp index 9ca1092e89..fb4867b2a6 100644 --- a/openpilot/cereal/log.capnp +++ b/openpilot/cereal/log.capnp @@ -2593,7 +2593,6 @@ struct Event { clocks @35 :Clocks; deviceState @6 :DeviceState; chestnutState @152 :ChestnutState; - chestnutGpuState @153 :ChestnutState; logMessage @18 :Text; errorLogMessage @85 :Text; diff --git a/openpilot/cereal/services.py b/openpilot/cereal/services.py index f906e4610f..08633d6975 100755 --- a/openpilot/cereal/services.py +++ b/openpilot/cereal/services.py @@ -26,7 +26,6 @@ _services: dict[str, tuple] = { "temperatureSensor": (True, 2., 200), "deviceState": (True, 2., 1), "chestnutState": (True, 10., 10), - "chestnutGpuState": (False, 10.), "touch": (True, 20., 1), "can": (True, 100., 2053, QueueSize.BIG), # decimation gives ~3 msgs in a full segment "controlsState": (True, 100., 10, QueueSize.MEDIUM), diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index c31418ee0d..de66decf5c 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -6,6 +6,8 @@ import os os.environ['GMMU'] = '0' # for chestnut fast loading, noop for qcom from tinygrad.tensor import Tensor from tinygrad.device import Device +import usb1 +import struct import threading import time import numpy as np @@ -30,6 +32,7 @@ from openpilot.selfdrive.modeld.parse_model_outputs import Parser from openpilot.selfdrive.modeld.compile_modeld import make_input_queues, WARP_INPUTS, POLICY_INPUTS from openpilot.selfdrive.modeld.fill_model_msg import fill_model_msg, fill_driving_model_data, fill_pose_msg, PublishState from openpilot.common.file_chunker import open_file_chunked +from openpilot.common.hardware.usb import CHESTNUT_USB_IDS from openpilot.selfdrive.modeld.constants import ModelConstants, Plan from openpilot.selfdrive.modeld.helpers import chestnut_present, chestnut_compiled, chestnut_ready, modeld_pkl_path, get_tg_input_devices, load_oob @@ -70,14 +73,45 @@ def get_action_from_model(model_output: dict[str, np.ndarray], prev_action: log. shouldStop=bool(stop)) -class ChestnutGpuState: - # SMU metrics are only accessible from modeld. +class ChestnutState: + # only modeld can access chestnut def __init__(self, pm: PubMaster, big: bool): self.pm = pm self.big = big self.valid = True self.sends = 0 self.metrics = {} + self._asm_usb = None + + def _close_asm_usb(self) -> None: + if self._asm_usb is not None: + self._asm_usb.close() + self._asm_usb = None + + def _open_asm_usb(self): + context = usb1.USBContext() + for vendor_id, product_id in CHESTNUT_USB_IDS: + if (handle := context.openByVendorIDAndProductID(vendor_id, product_id, skip_on_error=True)) is not None: + return handle + context.close() + + def _read_ina(self) -> tuple[int, int, bool]: + if "AMD" in Device._opened_devices and self._asm_usb is None: + try: + raw = Device["AMD"].iface.pci_dev.usb.usb.control_read(0xC0, 5) + return struct.unpack(' int: @@ -85,6 +119,8 @@ class ChestnutGpuState: return smu._send_msg(smu.smu_mod.PPSMC_MSG_GetPptLimit, 0, read_back_arg=True, timeout=100) def send(self) -> None: + msg = messaging.new_message('chestnutState') + state = msg.chestnutState self.sends += 1 if self.big and "AMD" in Device._opened_devices and self.sends % 100 == 1: try: @@ -106,14 +142,25 @@ class ChestnutGpuState: cloudlog.exception("chestnut state read failed") self.valid = False self.metrics.clear() - - msg = messaging.new_message('chestnutGpuState') - state = msg.chestnutGpuState if self.big: for k, v in self.metrics.items(): setattr(state, k, v) - msg.valid = self.big and self.valid - self.pm.send('chestnutGpuState', msg) + + asm_valid = False + try: + # ASM runs on USB-C power, these still read without a gpu + state.supplyVoltage, state.supplyCurrent, state.supplyFault = self._read_ina() + asm_valid = True + except Exception: + pass + if "AMD" in Device._opened_devices: + try: + state.pcieLtssm = Device["AMD"].iface.pci_dev.usb.read(0xB450, 1)[0] + except Exception: + pass + + msg.valid = asm_valid and (not self.big or self.valid) + self.pm.send('chestnutState', msg) class FrameMeta: @@ -280,13 +327,13 @@ def main(demo=False): cloudlog.warning(f"models loaded in {time.monotonic() - st:.1f}s, modeld starting") # messaging - pub_socks = ["modelV2", "drivingModelData", "cameraOdometry"] + (["chestnutGpuState"] if CHESTNUT else []) + pub_socks = ["modelV2", "drivingModelData", "cameraOdometry"] + (["chestnutState"] if CHESTNUT else []) pm = PubMaster(pub_socks) sm = SubMaster(["deviceState", "carState", "narrowRoadCameraState", "extrinsicsCalibration", "driverMonitoringState", "carControl", "lateralDelay"]) publish_state = PublishState() params = Params() - chestnut_state = ChestnutGpuState(pm, model.chestnut) if CHESTNUT else None + chestnut_state = ChestnutState(pm, model.chestnut) if CHESTNUT else None # setup filter to track dropped frames frame_dropped_filter = FirstOrderFilter(0., 10., 1. / ModelConstants.MODEL_RUN_FREQ) @@ -395,7 +442,7 @@ def main(demo=False): mt1 = time.perf_counter() try: send_chestnut = (chestnut_state is not None and - run_count % round(ModelConstants.MODEL_RUN_FREQ / SERVICE_LIST['chestnutGpuState'].frequency) == 0) + run_count % round(ModelConstants.MODEL_RUN_FREQ / SERVICE_LIST['chestnutState'].frequency) == 0) model_output = model.run(bufs, transforms, inputs, chestnut_state.send if send_chestnut else None) except Exception: if not params.get_bool("ChestnutActive"): diff --git a/openpilot/system/hardware/chestnut/monitoring.py b/openpilot/system/hardware/chestnut/monitoring.py deleted file mode 100644 index 829db99618..0000000000 --- a/openpilot/system/hardware/chestnut/monitoring.py +++ /dev/null @@ -1,124 +0,0 @@ -import struct -from contextlib import suppress - -import usb1 - -import openpilot.cereal.messaging as messaging -from openpilot.cereal.services import SERVICE_LIST -from openpilot.common.hardware.usb import CHESTNUT_USB_IDS - - -USB_TIMEOUT_MS = 100 -PCIE_LTSSM_ADDRESS = 0xB450 - - -class ChestnutUsb: - def __init__(self): - self.context: usb1.USBContext | None = None - self.handle = None - - def close(self) -> None: - handle, context = self.handle, self.context - self.handle = None - self.context = None - with suppress(Exception): - if handle is not None: - handle.close() - with suppress(Exception): - if context is not None: - context.close() - - def connect(self) -> bool: - if self.handle is not None: - return True - - context = usb1.USBContext() - for vendor_id, product_id in CHESTNUT_USB_IDS: - handle = context.openByVendorIDAndProductID(vendor_id, product_id, skip_on_error=True) - if handle is not None: - self.context = context - self.handle = handle - return True - context.close() - return False - - def _read(self, request: int, value: int, length: int) -> bytes: - if self.handle is None: - raise usb1.USBErrorNoDevice - raw = bytes(self.handle.controlRead(0xC0, request, value, 0, length, timeout=USB_TIMEOUT_MS)) - if len(raw) != length: - raise ValueError(f"short chestnut USB response: {len(raw)}/{length}") - return raw - - def read_ina(self) -> tuple[int, int, bool]: - return struct.unpack(' int: - return self._read(0xE4, PCIE_LTSSM_ADDRESS, 1)[0] - - -class ChestnutMonitoring: - def __init__(self, usb: ChestnutUsb | None = None): - self.usb = usb or ChestnutUsb() - self.gpu_state = None - self.seen = False - self.enabled = False - self.usb_failed = False - - def set_enabled(self, enabled: bool) -> None: - if self.enabled == enabled: - return - self.enabled = enabled - self.usb.close() - self.usb_failed = False - - def retry(self) -> None: - self.usb_failed = False - - def model_alive(self, sm: messaging.SubMaster, now: float) -> bool: - modeld = next((p for p in sm['managerState'].processes if p.name == 'modeld'), None) - if modeld is not None and modeld.shouldBeRunning and not modeld.running: - return False - recv_time = sm.recv_time['chestnutGpuState'] - return recv_time > 0. and now - recv_time < 10. / SERVICE_LIST['chestnutGpuState'].frequency - - def update_gpu_state(self, sm: messaging.SubMaster, now: float) -> None: - if sm.updated['chestnutGpuState']: - self.gpu_state = sm['chestnutGpuState'] if sm.valid['chestnutGpuState'] else None - elif not self.model_alive(sm, now): - self.gpu_state = None - - def update(self, sm: messaging.SubMaster, now: float, model_loading: bool = False): - self.update_gpu_state(sm, now) - return self.build_message(model_loading) - - def build_message(self, model_loading: bool = False): - if not self.enabled: - return None - - msg = messaging.new_message('chestnutState') - if self.gpu_state is not None: - msg.chestnutState = self.gpu_state - state = msg.chestnutState - - if self.usb_failed: - return msg if self.seen else None - - try: - if not self.usb.connect(): - self.usb_failed = True - return msg if self.seen else None - - self.seen = True - voltage, current, fault = self.usb.read_ina() - pcie_ltssm = self.usb.read_pcie_ltssm() - state.supplyVoltage = voltage - state.supplyCurrent = current - state.supplyFault = fault - state.pcieLtssm = pcie_ltssm - msg.valid = True - except Exception as e: - if not model_loading or not isinstance(e, usb1.USBErrorTimeout): - self.usb.close() - self.usb_failed = True - return msg diff --git a/openpilot/system/hardware/chestnut/status.py b/openpilot/system/hardware/chestnut/status.py index 34456b2c11..c3321a3971 100644 --- a/openpilot/system/hardware/chestnut/status.py +++ b/openpilot/system/hardware/chestnut/status.py @@ -21,13 +21,14 @@ class ChestnutStatus: self.power_lost = False self.power_restored = False self.link_failures = 0 + self.model_loading_seen = False self.model_attempted = False self.overheated = False self.usb_seen = False self.usb_failed = False def update(self, offroad: bool, branch: str, usb_state: list[dict], firmware_failed: bool, - model_active: bool | None, state, usb_failed: bool, set_alert) -> None: + model_loading: bool, model_active: bool | None, state, set_alert) -> None: detected = [d for d in usb_state if is_chestnut_usb_id(d["vendorId"], d["productId"], include_bootloader=True)] devices = [d for d in detected if is_chestnut_usb_id(d["vendorId"], d["productId"])] firmware_ok = len(devices) == 1 and devices[0]["product"] == CHESTNUT_USB_PRODUCT @@ -39,15 +40,16 @@ class ChestnutStatus: self.power_lost = False self.power_restored = False self.link_failures = 0 + self.model_loading_seen = False self.model_attempted = False self.usb_seen = firmware_ok self.usb_failed = False - self.model_attempted |= model_active is not None + self.model_loading_seen |= model_loading + self.model_attempted |= self.model_loading_seen and not model_loading and model_active is not None - if not offroad: - self.usb_seen |= firmware_ok - self.usb_failed = not offroad and self.usb_seen and (not firmware_ok or usb_failed) + if not offroad and self.usb_seen and not firmware_ok: + self.usb_failed = True if not offroad and state is not None: powered = state.supplyVoltage >= CHESTNUT_POWERED_VOLTAGE @@ -78,28 +80,17 @@ class ChestnutStatus: release = branch in CHESTNUT_RELEASE_BRANCHES missing = self.usb_failed or (offroad and release and time.monotonic() - self.started > 10. and len(detected) != 1) slow_usb = offroad and len(devices) == 1 and devices[0]["speedMbps"] < 5000 - update_failed = offroad and firmware_failed - compiled = firmware_ok and chestnut_compiled() - uncompiled = offroad and firmware_ok and not compiled - + set_alert("Offroad_ChestnutBranch", not release and len(devices) == 1) + set_alert("Offroad_ChestnutNotDetected", missing) + set_alert("Offroad_ChestnutOverheated", self.overheated, f"{state.tempC:.0f} °C" if state is not None else None) + set_alert("Offroad_ChestnutUsbSlow", slow_usb, f"{devices[0]['speedMbps']} Mbps" if slow_usb else None) if self.power_lost: pcie_alert = ("Chestnut power restored. 12V is stable again, cycle ignition." if self.power_restored else "Chestnut power disconnected. Check 12V connection, then cycle ignition." if self.power_unavailable else "Chestnut power lost. Possibly caused by an engine-crank voltage drop. Check 12V connection, then cycle ignition.") else: pcie_alert = "Chestnut GPU unavailable. PCIe link is not up. Check the GPU is securely seated." - - alerts = ( - ("Offroad_ChestnutNotDetected", missing, None), - ("Offroad_ChestnutUpdateFailed", update_failed, None), - ("Offroad_ChestnutUncompiled", uncompiled, None), - ("Offroad_ChestnutPcieUnavailable", self.pcie_failed, pcie_alert), - ("Offroad_ChestnutOverheated", self.overheated, f"{state.tempC:.0f} °C" if state is not None else None), - ("Offroad_ChestnutUsbSlow", slow_usb, f"{devices[0]['speedMbps']} Mbps" if slow_usb else None), - ) - active_alert = next((name for name, active, _ in alerts if active), None) - - set_alert("Offroad_ChestnutBranch", not release and len(devices) == 1 and not missing) - for name, _, extra_text in alerts: - set_alert(name, name == active_alert, extra_text) + set_alert("Offroad_ChestnutPcieUnavailable", self.pcie_failed, pcie_alert) + set_alert("Offroad_ChestnutUncompiled", offroad and firmware_ok and not chestnut_compiled()) + set_alert("Offroad_ChestnutUpdateFailed", offroad and firmware_failed) self.offroad = offroad diff --git a/openpilot/system/hardware/hardwared.py b/openpilot/system/hardware/hardwared.py index c7d11fa5b0..11fe41400d 100755 --- a/openpilot/system/hardware/hardwared.py +++ b/openpilot/system/hardware/hardwared.py @@ -27,7 +27,6 @@ from openpilot.common.swaglog import cloudlog from openpilot.system.hardware.power_monitoring import PowerMonitoring from openpilot.system.hardware.fan_controller import FanController from openpilot.system.hardware.chestnut.status import ChestnutStatus -from openpilot.system.hardware.chestnut.monitoring import ChestnutMonitoring from openpilot.common.version import terms_version, training_version from openpilot.system.athena.registration import UNREGISTERED_DONGLE_ID @@ -196,9 +195,8 @@ def hw_state_thread(end_event, hw_queue): def hardware_thread(end_event, hw_queue) -> None: system_stats = LinuxSystemStats() - pm = messaging.PubMaster(['deviceState', 'chestnutState']) - sm = messaging.SubMaster(["peripheralState", "gpsLocationExternal", "selfdriveState", "pandaStates", - "chestnutState", "chestnutGpuState", "managerState"], poll="pandaStates") + pm = messaging.PubMaster(['deviceState']) + sm = messaging.SubMaster(["peripheralState", "gpsLocationExternal", "selfdriveState", "pandaStates", "chestnutState"], poll="pandaStates") count = 0 @@ -246,9 +244,7 @@ def hardware_thread(end_event, hw_queue) -> None: fan_controller = FanController(int(1./DT_HW)) chestnut = Chestnut() - chestnut_monitoring = ChestnutMonitoring() chestnut_status = ChestnutStatus() - model_loading = params.get_bool("ChestnutLoading") branch = get_short_branch() while not end_event.is_set(): @@ -280,8 +276,6 @@ def hardware_thread(end_event, hw_queue) -> None: # Run at 2Hz, plus either edge of ignition ign_edge = (started_ts is not None) != all(onroad_conditions.values()) if (sm.frame % round(SERVICE_LIST['pandaStates'].frequency * DT_HW) != 0) and not ign_edge: - if (chestnut_msg := chestnut_monitoring.update(sm, time.monotonic(), model_loading)) is not None: - pm.send('chestnutState', chestnut_msg) continue msg = messaging.new_message('deviceState', valid=True) @@ -315,11 +309,9 @@ def hardware_thread(end_event, hw_queue) -> None: chestnut.update(started_ts is None, last_hw_state.usb_state) chestnut_state = sm["chestnutState"] chestnut_valid = sm.alive["chestnutState"] and sm.valid["chestnutState"] - model_loading = params.get_bool("ChestnutLoading") - model_active = params.get("ChestnutActive") chestnut_status.update(started_ts is None, branch, last_hw_state.usb_state, chestnut.failed, - model_active, chestnut_state if chestnut_valid else None, chestnut_monitoring.usb_failed, - set_offroad_alert_if_changed) + params.get_bool("ChestnutLoading"), params.get("ChestnutActive"), + chestnut_state if chestnut_valid else None, set_offroad_alert_if_changed) # this subset is only used for offroad temp_sources = [ msg.deviceState.memoryTempC, @@ -427,15 +419,6 @@ def hardware_thread(end_event, hw_queue) -> None: if off_ts is None: off_ts = time.monotonic() - chestnut_usb_ready = any(is_chestnut_usb_id(d["vendorId"], d["productId"]) and d["product"] == CHESTNUT_USB_PRODUCT - for d in last_hw_state.usb_state) - flash_active = chestnut.thread is not None and chestnut.thread.is_alive() - chestnut_monitoring.set_enabled(started_ts is not None and (chestnut_usb_ready or chestnut_monitoring.seen) and not flash_active) - if chestnut_usb_ready and chestnut_monitoring.usb_failed: - chestnut_monitoring.retry() - if (chestnut_msg := chestnut_monitoring.update(sm, time.monotonic(), model_loading)) is not None: - pm.send('chestnutState', chestnut_msg) - # Offroad power monitoring voltage = None if peripheralState.pandaType == log.PandaState.PandaType.unknown else peripheralState.voltage power_monitor.calculate(voltage, onroad_conditions["ignition"]) From cb85ac1f0e78425752cf744b2ca2cd268720a337 Mon Sep 17 00:00:00 2001 From: YassineYousfi Date: Tue, 1 Sep 2026 13:59:55 -0700 Subject: [PATCH 59/67] amd warp (#38684) * modeld: fuse warp and policy TinyJit * bump tg * fix? * this simple trick... * debug 1 * bump tg * pack all * wips * fix * BIG_INTO_SMALL remove * slower --- openpilot/selfdrive/modeld/SConscript | 24 ++- openpilot/selfdrive/modeld/compile_modeld.py | 147 ++++++++++-------- openpilot/selfdrive/modeld/modeld.py | 43 ++--- .../test/process_replay/model_replay.py | 10 +- 4 files changed, 108 insertions(+), 116 deletions(-) diff --git a/openpilot/selfdrive/modeld/SConscript b/openpilot/selfdrive/modeld/SConscript index f65aebf2f9..5a99aa890c 100644 --- a/openpilot/selfdrive/modeld/SConscript +++ b/openpilot/selfdrive/modeld/SConscript @@ -10,11 +10,6 @@ from openpilot.selfdrive.modeld.constants import ModelConstants from openpilot.selfdrive.modeld.helpers import TG_INPUT_DEVICES_PATH, chestnut_present, modeld_pkl_path -CAMERA_CONFIGS = [ - (_ar_ox_fisheye.width, _ar_ox_fisheye.height), # tici: 1928x1208 - (_os_fisheye.width, _os_fisheye.height), # mici: 1344x760 -] - Import('env', 'arch') chunker_file = File("#openpilot/common/file_chunker.py") lenv = env.Clone() @@ -29,17 +24,17 @@ def estimate_pickle_max_size(onnx_size): return 2.0 * onnx_size + 10 * 1024 * 1024 if arch == 'comma_arm64': + from openpilot.common.hardware import HARDWARE + camera = _os_fisheye if HARDWARE.get_device_type() == "mici" else _ar_ox_fisheye + camera_configs = [(camera.width, camera.height)] tg_backend = 'QCOM' tg_flags = f'DEV={tg_backend} IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1' else: + camera_configs = [(c.width, c.height) for c in (_ar_ox_fisheye, _os_fisheye)] tg_backend = 'CPU' tg_flags = f'DEV=CPU' if arch == 'Darwin' else 'DEV=CPU:LLVM' tg_devices = { # which device to put jit inputs to at runtime - 'openpilot.selfdrive.modeld.modeld': { - 'default': {'WARP_DEV': tg_backend, 'QUEUE_DEV': tg_backend}, - 'chestnut': {'WARP_DEV': tg_backend, 'QUEUE_DEV': 'AMD'} - }, 'openpilot.selfdrive.modeld.dmonitoringmodeld': { 'default': {'DEV': tg_backend} }, @@ -47,7 +42,7 @@ tg_devices = { # which device to put jit inputs to at runtime CHESTNUT = chestnut_present() if CHESTNUT: - chestnut_tg_flags = f'DEBUG=1 DEV=USB+AMD:LLVM WARP_DEV={tg_backend} FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2' + chestnut_tg_flags = 'DEBUG=1 DEV=USB+AMD:LLVM FRAME_DEV=CPU FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2 TC_OCCUPANCY_OPT=1' # the USB+AMD GPU takes an exclusive flock; serialize all targets that touch it chestnut_lock = File("models/.chestnut.lock").abspath @@ -77,10 +72,9 @@ frame_skip = ModelConstants.MODEL_RUN_FREQ // ModelConstants.MODEL_CONTEXT_FREQ for chestnut in [False, True] if CHESTNUT else [False]: target_pkl_path = File(modeld_pkl_path(chestnut)).abspath - # BIG_INTO_SMALL=1 builds the default target from the big model, e.g. to test it without a chestnut - file_prefix, cmd_flags = ('big_', chestnut_tg_flags) if chestnut else ('big_' if os.getenv('BIG_INTO_SMALL') else '', tg_flags) + file_prefix, cmd_flags = ('big_', chestnut_tg_flags) if chestnut else ('', tg_flags) driving_onnx_deps = get_existing_chunks(File(f"models/{file_prefix}driving_supercombo.onnx").abspath) - camera_res_args = ' '.join(f'{cw}x{ch}' for cw, ch in CAMERA_CONFIGS) + camera_res_args = ' '.join(f'{cw}x{ch}' for cw, ch in camera_configs) # CPU 7 is isolated with isolcpus on AGNOS, so explicitly pin the compiler to it. taskset = 'taskset -c 7 ' if arch == 'comma_arm64' else '' cmd = (f'{cmd_flags} {mac_brew_string} {taskset}python3 {modeld_dir}/compile_modeld.py ' @@ -108,7 +102,7 @@ for chestnut in [False, True] if CHESTNUT else [False]: actions = Action(do_compile, " [CHESTNUT] $TARGET") if chestnut else [cmd, Action(do_chunk, " [CHUNK] $TARGET")] node = lenv.Command( chunk_targets, - tinygrad_files + compile_modeld_script + driving_onnx_deps + [Value(chunk_targets), chunker_file], + tinygrad_files + compile_modeld_script + driving_onnx_deps + [Value(camera_res_args), Value(chunk_targets), chunker_file], actions, ) if chestnut: @@ -122,7 +116,7 @@ lenv.Command(fn + "_metadata.pkl", [fn + ".onnx"] + tinygrad_files + script_file dm_w, dm_h = DM_INPUT_SIZE compile_dm_warp_script = [File(f"{modeld_dir}/compile_dm_warp.py")] -for cam_w, cam_h in CAMERA_CONFIGS: +for cam_w, cam_h in camera_configs: dm_pkl_path = File(f"models/dm_warp_{cam_w}x{cam_h}_tinygrad.pkl").abspath cmd = (f'{tg_flags} {mac_brew_string} python3 {modeld_dir}/compile_dm_warp.py ' f'--camera-resolution {cam_w}x{cam_h} --warp-to {dm_w}x{dm_h} ' diff --git a/openpilot/selfdrive/modeld/compile_modeld.py b/openpilot/selfdrive/modeld/compile_modeld.py index f54d09e869..d851c3cc86 100755 --- a/openpilot/selfdrive/modeld/compile_modeld.py +++ b/openpilot/selfdrive/modeld/compile_modeld.py @@ -37,17 +37,12 @@ from tinygrad.engine.jit import TinyJit NV12Frame = namedtuple("NV12Frame", ['width', 'height', 'stride', 'y_height', 'uv_height', 'size']) -WARP_INPUTS = ['tfm', 'big_tfm'] -POLICY_INPUTS = ['img_q', 'big_img_q', 'feat_q', 'desire_q', 'packed_npy_inputs'] - -UV_SCALE_MATRIX = np.array([[0.5, 0, 0], [0, 0.5, 0], [0, 0, 1]], dtype=np.float32) -UV_SCALE_MATRIX_INV = np.linalg.inv(UV_SCALE_MATRIX) - -WARP_DEV = os.getenv('WARP_DEV') +MODELD_INPUTS = ['img_q', 'big_img_q', 'feat_q', 'desire_q', 'packed_npy_inputs'] -def make_random_images(keys, shape, device=None): - return {k: Tensor.randint(shape, low=0, high=256, dtype='uint8', device=device).realize() for k in keys} +def nv12_copy_size(stride: int, y_height: int, uv_height: int) -> int: + # Retain the padded Y and UV plane storage, but skip the trailing kernel/guard allocation. + return stride * (y_height + uv_height) def warp_perspective_tinygrad(src_flat, M_inv, dst_shape, src_shape, stride_pad, border_fill_val=None): @@ -99,7 +94,7 @@ def make_frame_prepare(nv12: NV12Frame, model_w, model_h): def frame_prepare_tinygrad(input_frame, M_inv): # UV_SCALE @ M_inv @ UV_SCALE_INV simplifies to elementwise scaling - M_inv_uv = M_inv * Tensor([[1.0, 1.0, 0.5], [1.0, 1.0, 0.5], [2.0, 2.0, 1.0]], device=WARP_DEV) + M_inv_uv = M_inv * Tensor([[1.0, 1.0, 0.5], [1.0, 1.0, 0.5], [2.0, 2.0, 1.0]], device=Device.DEFAULT) # deinterleave NV12 UV plane (UVUV... -> separate U, V) uv = input_frame[uv_offset:uv_offset + uv_height * stride].reshape(uv_height, stride) with Context(SPLIT_REDUCEOP=0): @@ -118,23 +113,6 @@ def make_frame_prepare(nv12: NV12Frame, model_w, model_h): return frame_prepare_tinygrad -def make_warp_input_queues(vision_input_shapes, frame_skip, device): - img = vision_input_shapes['img'] # (1, 12, 128, 256) - n_frames = img[1] // 6 - img_buf_shape = (frame_skip * (n_frames - 1) + 1, 6, img[2], img[3]) - - npy = { - 'tfm': np.zeros((3, 3), dtype=np.float32), - 'big_tfm': np.zeros((3, 3), dtype=np.float32), - } - input_queues = { - 'img_q': Tensor(np.zeros(img_buf_shape, dtype=np.uint8), device=device).contiguous().realize(), - 'big_img_q': Tensor(np.zeros(img_buf_shape, dtype=np.uint8), device=device).contiguous().realize(), - **{k: Tensor(v, device='NPY').realize() for k, v in npy.items()}, - } - return input_queues, npy - - def get_policy_npy_shapes(input_shapes): dp = input_shapes['desire_pulse'] # (1, 25, 8) tc = input_shapes['traffic_convention'] # (1, 2) @@ -146,23 +124,32 @@ def get_policy_npy_shapes(input_shapes): return shapes, [math.prod(s) for s in shapes.values()] -def make_input_queues(input_shapes, frame_skip, device): - input_queues, npy = make_warp_input_queues(input_shapes, frame_skip, device) - +def make_input_queues(input_shapes, frame_skip, device, frame_copy_size): + img = input_shapes['img'] # (1, 12, 128, 256) fb = input_shapes['features_buffer'] # (1, T-1, ...), past features only; the model appends the current frame's feature feat_dim = math.prod(fb[2:]) dp = input_shapes['desire_pulse'] # (1, 25, 8) + n_frames = img[1] // 6 + img_buf_shape = (frame_skip * (n_frames - 1) + 1, 6, img[2], img[3]) - shapes, sizes = get_policy_npy_shapes(input_shapes) - packed_npy_inputs = np.zeros(sum(sizes), dtype=np.float32) + policy_shapes, _ = get_policy_npy_shapes(input_shapes) + shapes = {'tfm': (3, 3), 'big_tfm': (3, 3)} | policy_shapes + sizes = [math.prod(s) for s in shapes.values()] + packed_npy_size = sum(sizes) * np.dtype(np.float32).itemsize + packed_input = np.zeros(packed_npy_size + 2 * frame_copy_size, dtype=np.uint8) + packed_npy_inputs = packed_input[:packed_npy_size].view(np.float32) + frames = packed_input[packed_npy_size:] + frame_views = {'img': frames[:frame_copy_size], 'big_img': frames[frame_copy_size:]} # views into the packed inputs, to be refilled at runtime - npy.update({k: v.reshape(s) for (k, s), v in zip(shapes.items(), np.split(packed_npy_inputs, np.cumsum(sizes[:-1])), strict=True)}) - input_queues.update({ + npy = {k: v.reshape(s) for (k, s), v in zip(shapes.items(), np.split(packed_npy_inputs, np.cumsum(sizes[:-1])), strict=True)} + input_queues = { + 'img_q': Tensor(np.zeros(img_buf_shape, dtype=np.uint8), device=device).contiguous().realize(), + 'big_img_q': Tensor(np.zeros(img_buf_shape, dtype=np.uint8), device=device).contiguous().realize(), 'feat_q': Tensor(np.zeros((frame_skip * fb[1], fb[0], feat_dim), dtype=np.float32), device=device).contiguous().realize(), 'desire_q': Tensor(np.zeros((frame_skip * dp[1], dp[0], dp[2]), dtype=np.float32), device=device).contiguous().realize(), - 'packed_npy_inputs': Tensor(packed_npy_inputs, device='NPY').realize(), - }) - return input_queues, npy + 'packed_npy_inputs': Tensor(packed_input, device='NPY').realize(), + } + return input_queues, npy, frame_views def shift_and_sample(buf, new_val, sample_fn): @@ -178,13 +165,15 @@ def sample_desire(buf, frame_skip): return buf.reshape(-1, frame_skip, *buf.shape[1:]).max(1).flatten(0, 1).unsqueeze(0) -def make_warp(nv12, model_w, model_h, frame_skip): +def make_warp(nv12, model_w, model_h): frame_prepare = make_frame_prepare(nv12, model_w, model_h) def warp(tfm, big_tfm, frame, big_frame): - tfm = tfm.to(WARP_DEV) - big_tfm = big_tfm.to(WARP_DEV) - Tensor.realize(tfm, big_tfm) + tfm = tfm.to(Device.DEFAULT) + big_tfm = big_tfm.to(Device.DEFAULT) + frame = frame.to(Device.DEFAULT) + big_frame = big_frame.to(Device.DEFAULT) + Tensor.realize(tfm, big_tfm, frame, big_frame) warped_frame = frame_prepare(frame, tfm).unsqueeze(0) warped_big_frame = frame_prepare(big_frame, big_tfm).unsqueeze(0) @@ -197,10 +186,10 @@ def make_run_policy(model_runner, model_metadata, frame_skip): sample_desire_fn = partial(sample_desire, frame_skip=frame_skip) sample_skip_fn = partial(sample_skip, frame_skip=frame_skip) npy_shapes, npy_sizes = get_policy_npy_shapes(model_metadata['input_shapes']) + model_input_dtypes = {name: spec.dtype for name, spec in model_runner.graph_inputs.items()} def run_policy(warped, img_q, big_img_q, feat_q, desire_q, packed_npy_inputs): packed_npy_inputs = packed_npy_inputs.to(Device.DEFAULT) - warped = warped.to(Device.DEFAULT) Tensor.realize(packed_npy_inputs, warped) img = shift_and_sample(img_q, warped[0:1], sample_skip_fn) @@ -218,28 +207,45 @@ def make_run_policy(model_runner, model_metadata, frame_skip): 'traffic_convention': traffic_convention, 'action_t': action_t, } + inputs = {name: value.cast(model_input_dtypes[name]) for name, value in inputs.items()} out = next(iter(model_runner(inputs).values())).cast('float32') return out, return run_policy -def compile_jit(jit, make_random_inputs, input_keys, make_queues): - SEED = 42 - def random_inputs_run(fn, seed, test_val=None, test_buffers=None, expect_match=True): - input_queues, npy = make_queues(Device.DEFAULT) - rng = np.random.default_rng(seed) - Tensor.manual_seed(seed) +def make_run_model(warp, run_policy, model_metadata, frame_copy_size): + _, policy_sizes = get_policy_npy_shapes(model_metadata['input_shapes']) + packed_npy_size = (18 + sum(policy_sizes)) * np.dtype(np.float32).itemsize - testing = test_val is not None or test_buffers is not None - n_runs = 1 if testing else 3 + def run_model(img_q, big_img_q, feat_q, desire_q, packed_npy_inputs): + packed_input = packed_npy_inputs.to(Device.DEFAULT) + Tensor.realize(packed_input) + packed_npy_inputs = packed_input[:packed_npy_size].bitcast('float32') + frame = packed_input[packed_npy_size:packed_npy_size + frame_copy_size] + big_frame = packed_input[packed_npy_size + frame_copy_size:] + tfm, big_tfm, policy_inputs = packed_npy_inputs.split([9, 9, sum(policy_sizes)]) + warped = warp(tfm.reshape(3, 3), big_tfm.reshape(3, 3), frame, big_frame) + return run_policy(warped, img_q, big_img_q, feat_q, desire_q, policy_inputs) + return run_model + + +def compile_jit(jit, input_keys, make_queues, benchmark_runs): + if benchmark_runs < 1: + raise ValueError("benchmark_runs must be at least 1") + + SEED = 42 + def random_inputs_run(fn, seed, n_runs, test_val=None, test_buffers=None, expect_match=True): + input_queues, npy, frame_views = make_queues(Device.DEFAULT) + rng = np.random.default_rng(seed) for i in range(n_runs): for v in npy.values(): v[:] = rng.standard_normal(v.shape).astype(v.dtype) + for v in frame_views.values(): + v[:] = rng.integers(0, 256, size=v.shape, dtype=np.uint8) Device.default.synchronize() - random_inputs = make_random_inputs() st = time.perf_counter() - outs = fn(**{k: input_queues[k] for k in input_keys}, **random_inputs) + outs = fn(**{k: input_queues[k] for k in input_keys}) mt = time.perf_counter() Device.default.synchronize() et = time.perf_counter() @@ -258,14 +264,15 @@ def compile_jit(jit, make_random_inputs, input_keys, make_queues): return val, buffers print('capture + replay') - test_val, test_buffers = random_inputs_run(jit, SEED) - print('pickle round trip') + test_val, test_buffers = random_inputs_run(jit, SEED, 3) + print(f'pickle round trip ({benchmark_runs} runs per seed)') with tempfile.TemporaryFile(dir=".") as f: dump_oob(jit, f) f.seek(0) - jit = load_oob(f) - random_inputs_run(jit, SEED, test_val, test_buffers, expect_match=True) - random_inputs_run(jit, SEED+1, test_val, test_buffers, expect_match=False) + loaded_jit = load_oob(f) + random_inputs_run(loaded_jit, SEED, benchmark_runs, test_val, test_buffers, expect_match=True) + random_inputs_run(loaded_jit, SEED+1, benchmark_runs, test_val, test_buffers, expect_match=False) + # Keep the original so per-resolution JITs share model weight buffers in the final pickle. return jit @@ -294,27 +301,31 @@ if __name__ == "__main__": p.add_argument('--onnx', required=True) p.add_argument('--output', required=True) p.add_argument('--frame-skip', type=int, required=True) + p.add_argument('--benchmark-runs', type=int, default=1, + help='timed loaded-JIT runs for each correctness seed') args = p.parse_args() model_path = read_file_chunked_to_disk(args.onnx) model_w, model_h = args.model_size model_runner = OnnxRunner(model_path) - out = {'metadata': make_metadata_dict(model_path)} + out = { + 'metadata': make_metadata_dict(model_path), + 'input_devices': {'model': Device.DEFAULT}, + 'run_model': {}, + } - run_policy_jit = TinyJit(make_run_policy(model_runner, out['metadata'], args.frame_skip), prune=True) - - make_policy_queues = partial(make_input_queues, out['metadata']['input_shapes'], args.frame_skip) - make_random_model_inputs = partial(make_random_images, keys=['warped'], shape=(2, 6, *out['metadata']['input_shapes']['img'][2:]), device=WARP_DEV) - out['run_policy'] = compile_jit(run_policy_jit, make_random_model_inputs, POLICY_INPUTS, - make_policy_queues) + run_policy = make_run_policy(model_runner, out['metadata'], args.frame_skip) for cam_w, cam_h in args.camera_resolutions: nv12 = NV12Frame(cam_w, cam_h, *get_nv12_info(cam_w, cam_h)) - make_random_warp_inputs = partial(make_random_images, keys=['frame', 'big_frame'], shape=nv12.size, device=WARP_DEV) - warp = TinyJit(make_warp(nv12, model_w, model_h, args.frame_skip), prune=True) - make_warp_queues = partial(make_warp_input_queues, out['metadata']['input_shapes'], args.frame_skip) - out[(cam_w,cam_h)] = compile_jit(warp, make_random_warp_inputs, WARP_INPUTS, make_warp_queues) + frame_copy_size = nv12_copy_size(nv12.stride, nv12.y_height, nv12.uv_height) + make_model_queues = partial(make_input_queues, out['metadata']['input_shapes'], args.frame_skip, + frame_copy_size=frame_copy_size) + warp = make_warp(nv12, model_w, model_h) + run_model_jit = TinyJit(make_run_model(warp, run_policy, out['metadata'], frame_copy_size), prune=True) + out['run_model'][(cam_w,cam_h)] = compile_jit(run_model_jit, MODELD_INPUTS, make_model_queues, + args.benchmark_runs) with open(args.output, "wb") as f: dump_oob(out, f) diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index de66decf5c..040c6b980f 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -4,7 +4,6 @@ import ctypes from functools import cached_property import os os.environ['GMMU'] = '0' # for chestnut fast loading, noop for qcom -from tinygrad.tensor import Tensor from tinygrad.device import Device import usb1 import struct @@ -29,14 +28,13 @@ from openpilot.common.transformations.model import get_warp_matrix from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper from openpilot.selfdrive.controls.lib.drive_helpers import get_accel_from_plan, should_stop, smooth_value, get_curvature_from_plan from openpilot.selfdrive.modeld.parse_model_outputs import Parser -from openpilot.selfdrive.modeld.compile_modeld import make_input_queues, WARP_INPUTS, POLICY_INPUTS +from openpilot.selfdrive.modeld.compile_modeld import make_input_queues, nv12_copy_size, MODELD_INPUTS from openpilot.selfdrive.modeld.fill_model_msg import fill_model_msg, fill_driving_model_data, fill_pose_msg, PublishState from openpilot.common.file_chunker import open_file_chunked from openpilot.common.hardware.usb import CHESTNUT_USB_IDS from openpilot.selfdrive.modeld.constants import ModelConstants, Plan -from openpilot.selfdrive.modeld.helpers import chestnut_present, chestnut_compiled, chestnut_ready, modeld_pkl_path, get_tg_input_devices, load_oob +from openpilot.selfdrive.modeld.helpers import chestnut_present, chestnut_compiled, chestnut_ready, modeld_pkl_path, load_oob -PROCESS_NAME = "openpilot.selfdrive.modeld.modeld" SEND_RAW_PRED = os.getenv('SEND_RAW_PRED') LAT_SMOOTH_SECONDS = 0.0 @@ -177,9 +175,9 @@ class ModelState: prev_desire: np.ndarray # for tracking the rising edge of the pulse def __init__(self, cam_w: int, cam_h: int, chestnut: bool): - input_devices = get_tg_input_devices(PROCESS_NAME, chestnut) - self.WARP_DEV, self.QUEUE_DEV = input_devices['WARP_DEV'], input_devices['QUEUE_DEV'] jits = load_oob(open_file_chunked(modeld_pkl_path(chestnut))) + input_devices = jits['input_devices'] + self.model_device = input_devices['model'] metadata = jits['metadata'] self.input_shapes = metadata['input_shapes'] self.vision_input_names = [k for k in self.input_shapes if 'img' in k] @@ -189,13 +187,11 @@ class ModelState: self.chestnut = chestnut self.frame_skip = ModelConstants.MODEL_RUN_FREQ // ModelConstants.MODEL_CONTEXT_FREQ - self.input_queues, self.npy = make_input_queues(self.input_shapes, self.frame_skip, device=self.QUEUE_DEV) - self.full_frames: dict[str, Tensor] = {} - self._blob_cache: dict[tuple[str, int], Tensor] = {} + self.frame_copy_size = nv12_copy_size(*get_nv12_info(cam_w, cam_h)[:3]) + self.input_queues, self.npy, self.frame_views = make_input_queues( + self.input_shapes, self.frame_skip, device=self.model_device, frame_copy_size=self.frame_copy_size) self.parser = Parser() - self.frame_buf_params = {k: get_nv12_info(cam_w, cam_h) for k in ('img', 'big_img')} - self.run_policy = jits['run_policy'] - self.warp = jits[(cam_w,cam_h)] + self.run_model = jits['run_model'][(cam_w,cam_h)] def slice_outputs(self, model_outputs: np.ndarray, output_slices: dict[str, slice]) -> dict[str, np.ndarray]: parsed_model_outputs = {k: model_outputs[np.newaxis, v] for k,v in output_slices.items()} @@ -203,14 +199,8 @@ class ModelState: def run(self, bufs: dict[str, VisionBuf], transforms: dict[str, np.ndarray], inputs: dict[str, np.ndarray], after_enqueue: Callable[[], None] | None = None) -> dict[str, np.ndarray]: - for key in bufs.keys(): - ptr = np.frombuffer(bufs[key].data, dtype=np.uint8).ctypes.data - yuv_size = self.frame_buf_params[key][3] - # There is a ringbuffer of imgs, just cache tensors pointing to all of them - cache_key = (key, ptr) - if cache_key not in self._blob_cache: - self._blob_cache[cache_key] = Tensor.from_blob(ptr, (yuv_size,), dtype='uint8', device=self.WARP_DEV) - self.full_frames[key] = self._blob_cache[cache_key] + for key, buf in bufs.items(): + np.copyto(self.frame_views[key], np.frombuffer(buf.data, dtype=np.uint8, count=self.frame_copy_size)) # Model decides when action is completed, so desire input is just a pulse triggered on rising edge inputs['desire_pulse'][0] = 0 @@ -221,11 +211,7 @@ class ModelState: self.npy['tfm'][:,:] = transforms['img'][:,:] self.npy['big_tfm'][:,:] = transforms['big_img'][:,:] - warped = self.warp(**{k: self.input_queues[k] for k in WARP_INPUTS}, frame=self.full_frames['img'], big_frame=self.full_frames['big_img']) - - outs, = self.run_policy( - **{k: self.input_queues[k] for k in POLICY_INPUTS if k in self.input_queues}, warped=warped - ) + outs, = self.run_model(**{k: self.input_queues[k] for k in MODELD_INPUTS}) if after_enqueue is not None: after_enqueue() model_output = outs.numpy()[0] @@ -239,14 +225,13 @@ class ModelState: return outputs_dict def warmup(self) -> None: - dummy_frames = {k: np.zeros(self.frame_buf_params[k][3], dtype=np.uint8) for k in self.vision_input_names} + dummy_frames = {k: np.zeros(self.frame_copy_size, dtype=np.uint8) for k in self.vision_input_names} eye = np.eye(3, dtype=np.float32) dims = {'desire_pulse': ModelConstants.DESIRE_LEN, 'traffic_convention': 2, 'action_t': 2} self.run(dummy_frames, dict.fromkeys(self.vision_input_names, eye), {k: np.zeros(v, dtype=np.float32) for k, v in dims.items()}) - self.input_queues, self.npy = make_input_queues(self.input_shapes, self.frame_skip, device=self.QUEUE_DEV) + self.input_queues, self.npy, self.frame_views = make_input_queues( + self.input_shapes, self.frame_skip, device=self.model_device, frame_copy_size=self.frame_copy_size) self.prev_desire[:] = 0 - self.full_frames.clear() - self._blob_cache.clear() def main(demo=False): diff --git a/openpilot/selfdrive/test/process_replay/model_replay.py b/openpilot/selfdrive/test/process_replay/model_replay.py index ba610c6a2b..927c9b38f1 100755 --- a/openpilot/selfdrive/test/process_replay/model_replay.py +++ b/openpilot/selfdrive/test/process_replay/model_replay.py @@ -33,9 +33,9 @@ MODEL_REPLAY_BUCKET="model_replay_master" GITHUB = GithubUtils(API_TOKEN, DATA_TOKEN) EXEC_TIMINGS = [ - # model, instant max, average max - ("modelV2", 0.05, 0.028), - ("driverStateV2", 0.05, 0.018), + # model, instant max, average max, chestnut average max + ("modelV2", 0.05, 0.03, 0.05), + ("driverStateV2", 0.05, 0.018, 0.018), ] def get_log_fn(test_route, ref="master"): @@ -169,11 +169,13 @@ def model_replay(lr, frs): dmonitoringmodeld_msgs = replay_process(dmonitoringmodeld, dmodeld_logs, frs) msgs = modeld_msgs + dmonitoringmodeld_msgs + chestnut = any(m.modelV2.big for m in modeld_msgs if m.which() == "modelV2") header = ['model', 'max instant', 'max instant allowed', 'average', 'max average allowed', 'test result'] rows = [] timings_ok = True - for (s, instant_max, avg_max) in EXEC_TIMINGS: + for (s, instant_max, avg_max, chestnut_avg_max) in EXEC_TIMINGS: + avg_max = chestnut_avg_max if chestnut else avg_max ts = [getattr(m, s).modelExecutionTime for m in msgs if m.which() == s] # TODO some init can happen in first iteration ts = ts[1:] From 36561258facd79875b58567f8b72ef751810ae3a Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Tue, 1 Sep 2026 15:45:57 -0700 Subject: [PATCH 60/67] ui: show usb connection (#38745) * ui: show USB status * ui: resize USB icon * ui: classify USB device once * ui: debounce USB disconnect --- openpilot/selfdrive/assets/icons_mici/usb.png | 3 +++ openpilot/selfdrive/ui/mici/layouts/home.py | 6 ++++- openpilot/selfdrive/ui/ui_state.py | 22 +++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 openpilot/selfdrive/assets/icons_mici/usb.png diff --git a/openpilot/selfdrive/assets/icons_mici/usb.png b/openpilot/selfdrive/assets/icons_mici/usb.png new file mode 100644 index 0000000000..2f3afb0de9 --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/usb.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:07bda2fe5d6be0b2854044053c384fe002e96406da119863a443b9344258b500 +size 1544 diff --git a/openpilot/selfdrive/ui/mici/layouts/home.py b/openpilot/selfdrive/ui/mici/layouts/home.py index b567b405a8..30d4a19451 100644 --- a/openpilot/selfdrive/ui/mici/layouts/home.py +++ b/openpilot/selfdrive/ui/mici/layouts/home.py @@ -140,6 +140,7 @@ class MiciHomeLayout(Widget): self._version_text = self._get_version_text() self._experimental_icon = IconWidget("icons_mici/experimental_mode.png", (48, 48)) + self._usb_icon = IconWidget("icons_mici/usb.png", (62, 40)) self._chestnut_icon = IconWidget("icons_mici/chestnut_green.png", (68, 40)) self._chestnut_loading_icon = IconWidget("icons_mici/chestnut.png", (68, 40)) self._chestnut_failed_icon = IconWidget("icons_mici/chestnut_orange.png", (68, 40)) @@ -152,6 +153,7 @@ class MiciHomeLayout(Widget): IconWidget("icons_mici/settings.png", (48, 48), opacity=0.9), NetworkIcon(), self._experimental_icon, + self._usb_icon, self._chestnut_icon, self._chestnut_loading_icon, self._chestnut_failed_icon, @@ -251,7 +253,9 @@ class MiciHomeLayout(Widget): # ***** Center-aligned bottom section icons ***** self._experimental_icon.set_visible(ui_state.experimental_mode) - self._chestnut_icon.set_visible(ui_state.chestnut_state in (ChestnutState.READY, ChestnutState.ACTIVE)) + self._usb_icon.set_visible(ui_state.usb_connected and ui_state.usb_unknown) + self._chestnut_icon.set_visible(not ui_state.usb_unknown and (ui_state.usb_connected or + ui_state.chestnut_state in (ChestnutState.READY, ChestnutState.ACTIVE))) self._chestnut_loading_icon.set_visible(ui_state.chestnut_state == ChestnutState.LOADING) self._chestnut_loading_icon.set_opacity(0.35 + 0.65 * (0.5 - 0.5 * math.cos(rl.get_time() * 6.0))) self._chestnut_failed_icon.set_visible(ui_state.chestnut_state in (ChestnutState.UNCOMPILED, ChestnutState.FAILED)) diff --git a/openpilot/selfdrive/ui/ui_state.py b/openpilot/selfdrive/ui/ui_state.py index c169cbeaff..ae1fad0b66 100644 --- a/openpilot/selfdrive/ui/ui_state.py +++ b/openpilot/selfdrive/ui/ui_state.py @@ -12,6 +12,7 @@ from openpilot.common.swaglog import cloudlog from openpilot.selfdrive.ui.lib.prime_state import PrimeState from openpilot.system.ui.lib.application import gui_app from openpilot.common.hardware import HARDWARE, PC +from openpilot.common.hardware.usb import TYPEC_CC_ORIENTATION_PATH, get_usb_state, is_chestnut_usb_id, read_int from openpilot.selfdrive.modeld.helpers import chestnut_compiled BACKLIGHT_OFFROAD = 65 if HARDWARE.get_device_type() == "mici" else 50 @@ -90,6 +91,10 @@ class UIState: self.chestnut_compiled: bool = chestnut_compiled() self.chestnut_active: bool | None = None self.chestnut_loading: bool = False + self.usb_connected: bool = False + self.usb_connected_ts: float | None = None + self.usb_disconnected_ts: float | None = None + self.usb_unknown: bool = False self.chestnut_state = ChestnutState.DISCONNECTED self.started: bool = False self.ignition: bool = False @@ -246,6 +251,23 @@ class UIState: self.chestnut_compiled = chestnut_compiled() self.chestnut_active = self.params.get("ChestnutActive") self.chestnut_loading = self.params.get_bool("ChestnutLoading") + now = time.monotonic() + if read_int(TYPEC_CC_ORIENTATION_PATH) != 0: + self.usb_disconnected_ts = None + if not self.usb_connected: + self.usb_connected = True + self.usb_connected_ts = now + self.usb_unknown = False + elif self.usb_connected_ts is not None and now - self.usb_connected_ts > 10.: + self.usb_unknown = not any(is_chestnut_usb_id(d["vendorId"], d["productId"], True) for d in get_usb_state()) + self.usb_connected_ts = None + elif self.usb_connected: + if self.usb_disconnected_ts is None: + self.usb_disconnected_ts = now + elif now - self.usb_disconnected_ts > PARAM_UPDATE_TIME: + self.usb_connected = False + self.usb_connected_ts = None + self.usb_unknown = False class Device: From 79658800ce142a84009e83a7f98e1c5d3415ceee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Tue, 1 Sep 2026 17:15:13 -0700 Subject: [PATCH 61/67] cereal: log big model in drivingModelData (#38747) --- openpilot/cereal/log.capnp | 1 + openpilot/selfdrive/modeld/fill_model_msg.py | 1 + openpilot/selfdrive/test/process_replay/migration.py | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/openpilot/cereal/log.capnp b/openpilot/cereal/log.capnp index fb4867b2a6..177a220451 100644 --- a/openpilot/cereal/log.capnp +++ b/openpilot/cereal/log.capnp @@ -1005,6 +1005,7 @@ struct DrivingModelData { frameIdExtra @1 :UInt32; frameDropPerc @6 :Float32; modelExecutionTime @7 :Float32; + big @8 :Bool; action @2 :ModelDataV2.Action; diff --git a/openpilot/selfdrive/modeld/fill_model_msg.py b/openpilot/selfdrive/modeld/fill_model_msg.py index 558f881b37..055c182f6c 100644 --- a/openpilot/selfdrive/modeld/fill_model_msg.py +++ b/openpilot/selfdrive/modeld/fill_model_msg.py @@ -63,6 +63,7 @@ def fill_driving_model_data(msg: capnp._DynamicStructBuilder, modelv2_send: capn driving_model_data.frameIdExtra = modelV2.frameIdExtra driving_model_data.frameDropPerc = modelV2.frameDropPerc driving_model_data.modelExecutionTime = modelV2.modelExecutionTime + driving_model_data.big = modelV2.big driving_model_data.action = modelV2.action driving_model_data.meta.laneChangeState = modelV2.meta.laneChangeState driving_model_data.meta.laneChangeDirection = modelV2.meta.laneChangeDirection diff --git a/openpilot/selfdrive/test/process_replay/migration.py b/openpilot/selfdrive/test/process_replay/migration.py index 2fb8932abc..ee1fb3b943 100644 --- a/openpilot/selfdrive/test/process_replay/migration.py +++ b/openpilot/selfdrive/test/process_replay/migration.py @@ -149,7 +149,7 @@ def migrate_drivingModelData(msgs): add_ops = [] for _, msg in msgs: dmd = messaging.new_message('drivingModelData', valid=msg.valid, logMonoTime=msg.logMonoTime) - for field in ["frameId", "frameIdExtra", "frameDropPerc", "modelExecutionTime", "action"]: + for field in ["frameId", "frameIdExtra", "frameDropPerc", "modelExecutionTime", "big", "action"]: setattr(dmd.drivingModelData, field, getattr(msg.modelV2, field)) for meta_field in ["laneChangeState", "laneChangeState"]: setattr(dmd.drivingModelData.meta, meta_field, getattr(msg.modelV2.meta, meta_field)) From 8b88f7dd6e0fe50985e27676370dc71145679d23 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Tue, 1 Sep 2026 18:32:39 -0700 Subject: [PATCH 62/67] ui: show one GPU status (#38748) ui: show one GPU status icon --- openpilot/selfdrive/ui/mici/layouts/home.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/openpilot/selfdrive/ui/mici/layouts/home.py b/openpilot/selfdrive/ui/mici/layouts/home.py index 30d4a19451..553fa37f8c 100644 --- a/openpilot/selfdrive/ui/mici/layouts/home.py +++ b/openpilot/selfdrive/ui/mici/layouts/home.py @@ -252,13 +252,17 @@ class MiciHomeLayout(Widget): self._version_commit_label.render() # ***** Center-aligned bottom section icons ***** + usb_connected = ui_state.usb_connected + usb_unknown = ui_state.usb_unknown + chestnut_state = ui_state.chestnut_state self._experimental_icon.set_visible(ui_state.experimental_mode) - self._usb_icon.set_visible(ui_state.usb_connected and ui_state.usb_unknown) - self._chestnut_icon.set_visible(not ui_state.usb_unknown and (ui_state.usb_connected or - ui_state.chestnut_state in (ChestnutState.READY, ChestnutState.ACTIVE))) - self._chestnut_loading_icon.set_visible(ui_state.chestnut_state == ChestnutState.LOADING) + self._usb_icon.set_visible(usb_connected and usb_unknown) + self._chestnut_icon.set_visible(not usb_unknown and chestnut_state not in + (ChestnutState.LOADING, ChestnutState.UNCOMPILED, ChestnutState.FAILED) and + (usb_connected or chestnut_state in (ChestnutState.READY, ChestnutState.ACTIVE))) + self._chestnut_loading_icon.set_visible(not usb_unknown and chestnut_state == ChestnutState.LOADING) self._chestnut_loading_icon.set_opacity(0.35 + 0.65 * (0.5 - 0.5 * math.cos(rl.get_time() * 6.0))) - self._chestnut_failed_icon.set_visible(ui_state.chestnut_state in (ChestnutState.UNCOMPILED, ChestnutState.FAILED)) + self._chestnut_failed_icon.set_visible(not usb_unknown and chestnut_state in (ChestnutState.UNCOMPILED, ChestnutState.FAILED)) self._mic_icon.set_visible(ui_state.recording_audio) self._body_icon.set_visible(bool(ui_state.is_body)) From 6249f4d5b0e63c05f08bce12ca3afebda9f764a3 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Tue, 1 Sep 2026 18:32:59 -0700 Subject: [PATCH 63/67] AGNOS 19.7 (#38750) --- launch_env.sh | 2 +- openpilot/common/hardware/comma/agnos.json | 22 +++++++++++----------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/launch_env.sh b/launch_env.sh index 094622005a..4fcb243f12 100755 --- a/launch_env.sh +++ b/launch_env.sh @@ -16,7 +16,7 @@ export VECLIB_MAXIMUM_THREADS=1 export QCOM_PRIORITY=12 if [ -z "$AGNOS_VERSION" ]; then - export AGNOS_VERSION="19.6" + export AGNOS_VERSION="19.7" fi export STAGING_ROOT="/data/safe_staging" diff --git a/openpilot/common/hardware/comma/agnos.json b/openpilot/common/hardware/comma/agnos.json index ae1cbbcd38..4a48016ed2 100644 --- a/openpilot/common/hardware/comma/agnos.json +++ b/openpilot/common/hardware/comma/agnos.json @@ -56,29 +56,29 @@ }, { "name": "boot", - "url": "https://commadist.azureedge.net/agnosupdate/boot-b30f5eef65ec3878f3aa3dcaf2cc95c09e2c1e661cd3a38e94da37dee76f68bd.img.xz", - "hash": "b30f5eef65ec3878f3aa3dcaf2cc95c09e2c1e661cd3a38e94da37dee76f68bd", - "hash_raw": "b30f5eef65ec3878f3aa3dcaf2cc95c09e2c1e661cd3a38e94da37dee76f68bd", + "url": "https://commadist.azureedge.net/agnosupdate/boot-6ecf6f987cd11968104abcccabbe268485d329cdb73012dfd3c381a6b8deb27d.img.xz", + "hash": "6ecf6f987cd11968104abcccabbe268485d329cdb73012dfd3c381a6b8deb27d", + "hash_raw": "6ecf6f987cd11968104abcccabbe268485d329cdb73012dfd3c381a6b8deb27d", "size": 46897152, "sparse": false, "full_check": true, "has_ab": true, - "ondevice_hash": "6650e4c46df99ae6dfd6ee895a34b8a2a3cc490a8ce18e16cc3c451c3f822b6e" + "ondevice_hash": "d12e1e5b9455b62a1464558716493b33e470d7a7e88da1c4105a3b21d0961808" }, { "name": "system", - "url": "https://commadist.azureedge.net/agnosupdate/system-5b6ce7965904a157fd3a134ccfcb854f9ca5c1cc2a26b7cb80a4fa4e1cc4aaa3.img.xz", - "hash": "b134fd04e9da27fa1d359ea0f2742c216fa21a08b5c47e9be22ab3b0563d9b9b", - "hash_raw": "5b6ce7965904a157fd3a134ccfcb854f9ca5c1cc2a26b7cb80a4fa4e1cc4aaa3", + "url": "https://commadist.azureedge.net/agnosupdate/system-3c271e2b3d20d2f0a8bf6555a1319f3efb12845490967d6151195174a01e912f.img.xz", + "hash": "74ffc9c551e1f29cda897ace8a69080fe644f8039977c6885f2b48362e39b744", + "hash_raw": "3c271e2b3d20d2f0a8bf6555a1319f3efb12845490967d6151195174a01e912f", "size": 4718592000, "sparse": true, "full_check": false, "has_ab": true, - "ondevice_hash": "91242772af771ae96fe2eebc105f2b80a7e1dbaaf6003c2574b62d51b806f468", + "ondevice_hash": "6a992680183685eea9db99d915219a37935f45989330d9b619e880450257f448", "alt": { - "hash": "5b6ce7965904a157fd3a134ccfcb854f9ca5c1cc2a26b7cb80a4fa4e1cc4aaa3", - "url": "https://commadist.azureedge.net/agnosupdate/system-5b6ce7965904a157fd3a134ccfcb854f9ca5c1cc2a26b7cb80a4fa4e1cc4aaa3.img", + "hash": "3c271e2b3d20d2f0a8bf6555a1319f3efb12845490967d6151195174a01e912f", + "url": "https://commadist.azureedge.net/agnosupdate/system-3c271e2b3d20d2f0a8bf6555a1319f3efb12845490967d6151195174a01e912f.img", "size": 4718592000 } } -] +] \ No newline at end of file From ab389498a8808e8ea82808adabe355af7475b749 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:12:54 -0400 Subject: [PATCH 64/67] [bot] Update Python packages (#1950) * Update Python packages * bump tg * bump * ci: route build_model runner by target_hardware instead of hardcoding chestnut * hack, remove before merge * Revert build-model runner hack and uv.lock update * why were they hard coded --------- Co-authored-by: github-actions[bot] Co-authored-by: Jason Wen --- openpilot/sunnypilot/models/fetcher.py | 4 ++-- openpilot/sunnypilot/models/helpers.py | 2 +- .../sunnypilot/models/tests/test_manager_download.py | 12 ++++++------ tinygrad_repo | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/openpilot/sunnypilot/models/fetcher.py b/openpilot/sunnypilot/models/fetcher.py index d0115be045..f997dbbc51 100644 --- a/openpilot/sunnypilot/models/fetcher.py +++ b/openpilot/sunnypilot/models/fetcher.py @@ -138,8 +138,8 @@ class ModelCache: class ModelFetcher: """Handles fetching and caching of model data from remote source""" - MODEL_URL = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_v21.json" - MODEL_URL_CHESTNUT = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_chestnut_v22.json" + MODEL_URL = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_v22.json" + MODEL_URL_CHESTNUT = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_chestnut_v23.json" MODEL_SOURCES = { "qcom": (MODEL_URL, ""), diff --git a/openpilot/sunnypilot/models/helpers.py b/openpilot/sunnypilot/models/helpers.py index 3c3cc1107b..ac69096597 100644 --- a/openpilot/sunnypilot/models/helpers.py +++ b/openpilot/sunnypilot/models/helpers.py @@ -19,7 +19,7 @@ from openpilot.common.hardware.hw import Paths from openpilot.selfdrive.modeld.helpers import chestnut_present # SET ME TO THE EXACT JSON VERSION WE SET IN SUNNYPILOT_MODELS REPO -REQUIRED_JSON_VERSION = 18 +REQUIRED_JSON_VERSION = 19 CUSTOM_MODEL_PATH = Paths.model_root() METADATA_PATH = Path(__file__).parent / '../models/supercombo_metadata.pkl' diff --git a/openpilot/sunnypilot/models/tests/test_manager_download.py b/openpilot/sunnypilot/models/tests/test_manager_download.py index 489e0bc096..b995d4fbd1 100644 --- a/openpilot/sunnypilot/models/tests/test_manager_download.py +++ b/openpilot/sunnypilot/models/tests/test_manager_download.py @@ -386,7 +386,7 @@ class TestManagerDownload(ManagerDownloadTestBase): def body(): artifact = self.make_artifact(chunked=True) self._bundle.ref = "test-ref" - self._bundle.minimumSelectorVersion = 18 + self._bundle.minimumSelectorVersion = helpers.REQUIRED_JSON_VERSION params, store = self._make_params_with_store() self.manager.params = params asyncio.run(self.manager._download_bundle(self._bundle, self.dest, "qcom")) @@ -406,7 +406,7 @@ class TestManagerDownload(ManagerDownloadTestBase): def body(): self.make_artifact(chunked=True) self._bundle.ref = "big-ref" - self._bundle.minimumSelectorVersion = 18 + self._bundle.minimumSelectorVersion = helpers.REQUIRED_JSON_VERSION params, store = self._make_params_with_store() self.manager.params = params asyncio.run(self.manager._download_bundle(self._bundle, self.dest, "chestnut")) @@ -469,7 +469,7 @@ def manifest_bundle(short_name: str, ref: str, index: int = 0, is_big: bool = Fa "environment": "release", "runner": "tinygrad", "is_big": is_big, - "minimum_selector_version": "18", + "minimum_selector_version": str(helpers.REQUIRED_JSON_VERSION), "ref": ref, "models": [{ "type": "supercombo", @@ -655,7 +655,7 @@ class TestActiveBundleValidation(OpenpilotTestCase): def _raw_bundle(ref: str, runner: int | None = None) -> dict: bundle = custom.ModelManagerSP.ModelBundle.new_message() bundle.ref = ref - bundle.minimumSelectorVersion = 18 + bundle.minimumSelectorVersion = helpers.REQUIRED_JSON_VERSION if runner is not None: bundle.runner = runner return bundle.to_dict() @@ -697,7 +697,7 @@ class TestActiveBundleSelection(OpenpilotTestCase): def _raw_bundle(ref: str) -> dict: bundle = custom.ModelManagerSP.ModelBundle.new_message() bundle.ref = ref - bundle.minimumSelectorVersion = 18 + bundle.minimumSelectorVersion = helpers.REQUIRED_JSON_VERSION return bundle.to_dict() def _params(self, qcom=None, chestnut=None): @@ -744,7 +744,7 @@ class TestEffectiveSource(OpenpilotTestCase): def _raw_bundle(ref: str) -> dict: bundle = custom.ModelManagerSP.ModelBundle.new_message() bundle.ref = ref - bundle.minimumSelectorVersion = 18 + bundle.minimumSelectorVersion = helpers.REQUIRED_JSON_VERSION return bundle.to_dict() def test_runtime_no_gpu(self): diff --git a/tinygrad_repo b/tinygrad_repo index 66ee3cfb4f..966a8f5112 160000 --- a/tinygrad_repo +++ b/tinygrad_repo @@ -1 +1 @@ -Subproject commit 66ee3cfb4f3a3908a6a20ddfbec7774ba7c09b4e +Subproject commit 966a8f5112dbc0e4f6d8120c1ec4d98e95fa2bcb From 68be7773955e2a0dd7901ea604ba3301bed13daf Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Tue, 1 Sep 2026 22:14:43 -0400 Subject: [PATCH 65/67] bump tg --- tinygrad_repo | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tinygrad_repo b/tinygrad_repo index 966a8f5112..e837e367aa 160000 --- a/tinygrad_repo +++ b/tinygrad_repo @@ -1 +1 @@ -Subproject commit 966a8f5112dbc0e4f6d8120c1ec4d98e95fa2bcb +Subproject commit e837e367aac9e1a66e689f4f32ce20ca9367df13 From 47db84ebfb47f82bfe3ebb3d78cb13d4bc9a91a3 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Wed, 2 Sep 2026 01:28:43 -0400 Subject: [PATCH 66/67] models: add big model ONNX hash tracking (#1982) --- openpilot/sunnypilot/models/default_model.py | 14 ++++++++++ .../sunnypilot/models/tests/big_model_hash | 1 + .../models/tests/test_default_model.py | 27 ++++++++++++++++++- 3 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 openpilot/sunnypilot/models/tests/big_model_hash diff --git a/openpilot/sunnypilot/models/default_model.py b/openpilot/sunnypilot/models/default_model.py index e3c9360835..84e962cdcd 100755 --- a/openpilot/sunnypilot/models/default_model.py +++ b/openpilot/sunnypilot/models/default_model.py @@ -20,7 +20,9 @@ def get_default_model() -> str: DEFAULT_MODEL_NAME_PATH = os.path.join(BASEDIR, "openpilot", "sunnypilot", "models", "model_name.py") MODEL_HASH_PATH = os.path.join(BASEDIR, "openpilot", "sunnypilot", "models", "tests", "model_hash") +BIG_MODEL_HASH_PATH = os.path.join(BASEDIR, "openpilot", "sunnypilot", "models", "tests", "big_model_hash") SUPERCOMBO_ONNX_PATH = os.path.join(BASEDIR, "openpilot", "selfdrive", "modeld", "models", "driving_supercombo.onnx") +BIG_SUPERCOMBO_ONNX_PATH = os.path.join(BASEDIR, "openpilot", "selfdrive", "modeld", "models", "big_driving_supercombo.onnx") def update_model_hash(): @@ -32,6 +34,18 @@ def update_model_hash(): print(f"Generated and updated new combined model hash to {MODEL_HASH_PATH}") + if os.path.exists(BIG_SUPERCOMBO_ONNX_PATH): + import subprocess + rel = os.path.relpath(BIG_SUPERCOMBO_ONNX_PATH, os.getcwd()) + pointer = subprocess.check_output(["git", "show", f"HEAD:{rel}"], text=True) + oid = next(l.split(":", 1)[1] for l in pointer.splitlines() if l.startswith("oid sha256:")) + big_combined_hash = hashlib.sha256(oid.encode()).hexdigest() + + with open(BIG_MODEL_HASH_PATH, "w") as f: + f.write(big_combined_hash) + + print(f"Generated and updated new big model hash to {BIG_MODEL_HASH_PATH}") + def get_ref_for_name(url: str, name: str) -> str: response = requests.get(url, timeout=10) diff --git a/openpilot/sunnypilot/models/tests/big_model_hash b/openpilot/sunnypilot/models/tests/big_model_hash new file mode 100644 index 0000000000..e957940fdd --- /dev/null +++ b/openpilot/sunnypilot/models/tests/big_model_hash @@ -0,0 +1 @@ +8bba37156aa17d49210cad028744c839ea9ed7b1f19428a8ecfafdc1e07a73b6 \ No newline at end of file diff --git a/openpilot/sunnypilot/models/tests/test_default_model.py b/openpilot/sunnypilot/models/tests/test_default_model.py index b72c2b4c89..c322d3b699 100644 --- a/openpilot/sunnypilot/models/tests/test_default_model.py +++ b/openpilot/sunnypilot/models/tests/test_default_model.py @@ -5,12 +5,25 @@ 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 subprocess + from openpilot.sunnypilot import get_file_hash -from openpilot.sunnypilot.models.default_model import MODEL_HASH_PATH, SUPERCOMBO_ONNX_PATH +from openpilot.sunnypilot.models.default_model import MODEL_HASH_PATH, SUPERCOMBO_ONNX_PATH, BIG_MODEL_HASH_PATH, \ + BIG_SUPERCOMBO_ONNX_PATH import hashlib from openpilot.common.test import OpenpilotTestCase +def _get_lfs_oid(path: str) -> str: + """Extract the LFS OID (SHA256 of actual content) from git, works whether the file is smudged or not.""" + pointer = subprocess.check_output(["git", "show", f"HEAD:{path}"], text=True) + for line in pointer.splitlines(): + if line.startswith("oid sha256:"): + return line.split(":", 1)[1] + raise ValueError(f"No LFS OID found for {path}") + + class TestDefaultModel(OpenpilotTestCase): def test_compare_onnx_hashes(self): supercombo_hash = get_file_hash(SUPERCOMBO_ONNX_PATH) @@ -21,3 +34,15 @@ class TestDefaultModel(OpenpilotTestCase): current_hash = f.read().strip() assert combined_hash == current_hash, "Run openpilot/sunnypilot/models/default_model.py to update the default model name and hash" + + def test_compare_big_onnx_hashes(self): + if not os.path.exists(BIG_SUPERCOMBO_ONNX_PATH): + self.skipTest("big_driving_supercombo.onnx not present") + + oid = _get_lfs_oid(os.path.relpath(BIG_SUPERCOMBO_ONNX_PATH, os.getcwd())) + combined_hash = hashlib.sha256(oid.encode()).hexdigest() + + with open(BIG_MODEL_HASH_PATH) as f: + current_hash = f.read().strip() + + assert combined_hash == current_hash, "Run openpilot/sunnypilot/models/default_model.py to update the default model name and hash" From e87dbbaba710bbfe7661d9ff064d46170cac9442 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Wed, 2 Sep 2026 14:53:31 -0400 Subject: [PATCH 67/67] models: sanitize default model name for HF (#1984) --- .github/workflows/build-default-models.yaml | 17 ++++++++++------- release/ci/upload_default_model.py | 3 ++- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build-default-models.yaml b/.github/workflows/build-default-models.yaml index 8030c7133d..f2cb007670 100644 --- a/.github/workflows/build-default-models.yaml +++ b/.github/workflows/build-default-models.yaml @@ -30,6 +30,7 @@ jobs: runs-on: ubuntu-24.04 outputs: model_name: ${{ steps.resolve.outputs.model_name }} + safe_model_name: ${{ steps.resolve.outputs.safe_model_name }} onnx_ref: ${{ steps.resolve.outputs.onnx_ref }} onnx_path: ${{ steps.resolve.outputs.onnx_path }} hf_defaults_path: ${{ steps.resolve.outputs.hf_defaults_path }} @@ -64,7 +65,9 @@ jobs: exit 1 fi + SAFE_NAME="${NAME// /-}" echo "model_name=${NAME}" >> $GITHUB_OUTPUT + echo "safe_model_name=${SAFE_NAME}" >> $GITHUB_OUTPUT echo "onnx_ref=${ONNX_REF}" >> $GITHUB_OUTPUT echo "onnx_path=${ONNX_PATH}" >> $GITHUB_OUTPUT echo "hf_defaults_path=${HF_DEFAULTS_PATH}" >> $GITHUB_OUTPUT @@ -135,7 +138,7 @@ jobs: - name: Prepare output env: - MODEL_NAME: ${{ needs.resolve.outputs.model_name }} + MODEL_NAME: ${{ needs.resolve.outputs.safe_model_name }} run: | source ${UV_PROJECT_ENVIRONMENT}/bin/activate export PYTHONPATH=${{ github.workspace }} @@ -158,13 +161,13 @@ jobs: - name: Upload small model artifact uses: actions/upload-artifact@v4 with: - name: model-${{ needs.resolve.outputs.model_name }}-${{ github.run_number }} + name: model-${{ needs.resolve.outputs.safe_model_name }}-${{ github.run_number }} path: ${{ github.workspace }}/small_output/ - name: Upload artifact name file uses: actions/upload-artifact@v4 with: - name: artifact-name-${{ needs.resolve.outputs.model_name }} + name: artifact-name-${{ needs.resolve.outputs.safe_model_name }} path: ${{ github.workspace }}/small_output/artifact_name.txt - name: Re-enable powersave @@ -254,7 +257,7 @@ jobs: - name: Prepare output env: - MODEL_NAME: ${{ needs.resolve.outputs.model_name }} + MODEL_NAME: ${{ needs.resolve.outputs.safe_model_name }} run: | source ${UV_PROJECT_ENVIRONMENT}/bin/activate export PYTHONPATH=${{ github.workspace }} @@ -277,13 +280,13 @@ jobs: - name: Upload big model artifact uses: actions/upload-artifact@v4 with: - name: model-${{ needs.resolve.outputs.model_name }}-${{ github.run_number }} + name: model-${{ needs.resolve.outputs.safe_model_name }}-${{ github.run_number }} path: ${{ github.workspace }}/big_output/ - name: Upload artifact name file uses: actions/upload-artifact@v4 with: - name: artifact-name-${{ needs.resolve.outputs.model_name }} + name: artifact-name-${{ needs.resolve.outputs.safe_model_name }} path: ${{ github.workspace }}/big_output/artifact_name.txt - name: Re-enable powersave @@ -318,7 +321,7 @@ jobs: if: ${{ inputs.target == 'small' || inputs.target == 'big' }} uses: actions/download-artifact@v4 with: - name: artifact-name-${{ needs.resolve.outputs.model_name }} + name: artifact-name-${{ needs.resolve.outputs.safe_model_name }} path: artifact_name - name: Read artifact name diff --git a/release/ci/upload_default_model.py b/release/ci/upload_default_model.py index eac48e3be4..97263ae6f5 100644 --- a/release/ci/upload_default_model.py +++ b/release/ci/upload_default_model.py @@ -38,7 +38,8 @@ def main(): api = HfApi() onnx_sha256 = hash_file(args.onnx_path) short_ref = args.onnx_ref[:8] - folder_name = f"model-{args.model_name}-{short_ref}-{args.run_number}" + safe_name = args.model_name.replace(" ", "-") + folder_name = f"model-{safe_name}-{short_ref}-{args.run_number}" print(f"ONNX hash: {onnx_sha256}") print(f"ONNX ref: {args.onnx_ref} (short: {short_ref})")