From 2bcfed5c7120763c76f6824d0ed0d5f6423c5ed9 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Mon, 24 Aug 2026 15:37:46 -0400 Subject: [PATCH 01/38] 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 02/38] 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 03/38] 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 04/38] 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 05/38] 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 06/38] 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 07/38] 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 08/38] 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 09/38] 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 10/38] 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 11/38] [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 12/38] 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 13/38] [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 14/38] 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 15/38] 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 5c3505b25fa05b6e297b2947c8b9e5c9f140bd3e Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Wed, 26 Aug 2026 02:48:03 -0400 Subject: [PATCH 16/38] ui: unify model source predicate and per-source bundle lookup in model_info --- .../ui/sunnypilot/layouts/settings/models.py | 19 ++++----------- .../ui/sunnypilot/mici/layouts/models.py | 24 ++++++------------- .../selfdrive/ui/sunnypilot/model_info.py | 17 ++++++++++--- 3 files changed, 26 insertions(+), 34 deletions(-) diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py index 44f3451ba3..bb788509e1 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.fetcher import ModelFetcher, get_cached_bundles 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 model_info +from openpilot.selfdrive.ui.sunnypilot.model_info import active_source, bundles_for_source, model_info 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 @@ -207,12 +206,7 @@ class ModelsLayout(Widget): ui_state.params.put("ModelManager_DownloadRef", selected_bundle.ref) def _resolve_selected_bundle(self, ref): - """Finds the bundle for a ref across both hardware manifests.""" - active = ModelFetcher.active_source(ui_state.sm["deviceState"].chestnutPresent) - source_bundles = { - source: self.model_manager.availableBundles if source == active else get_cached_bundles(ui_state.params, source) - for source in ("qcom", "usbgpu") - } + 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 @@ -236,11 +230,10 @@ class ModelsLayout(Widget): return folders_list def _handle_current_model_clicked(self): - self._open_source_dialog(ModelFetcher.active_source(ui_state.sm["deviceState"].chestnutPresent)) + self._open_source_dialog(active_source()) def _handle_other_model_clicked(self): - active = ModelFetcher.active_source(ui_state.sm["deviceState"].chestnutPresent) - self._open_source_dialog("qcom" if active == "usbgpu" else "usbgpu") + self._open_source_dialog("qcom" if active_source() == "usbgpu" else "usbgpu") def _open_source_dialog(self, source): """Opens the picker for one hardware: its model folders plus the Default reset entry.""" @@ -256,9 +249,7 @@ class ModelsLayout(Widget): gui_app.push_widget(self.model_dialog) def _source_folders(self, favorites, source): - """Default reset entry on top, then the hardware's model folders.""" - active = ModelFetcher.active_source(ui_state.sm["deviceState"].chestnutPresent) - bundles = self.model_manager.availableBundles if source == active else get_cached_bundles(ui_state.params, source) + bundles = bundles_for_source(source) if not bundles: return [] folders_list = [TreeFolder("", [TreeNode("Default", {'display_name': "Default"})])] diff --git a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py index 4d456bdb76..e6a9b17a09 100644 --- a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py @@ -8,11 +8,10 @@ import pyray as rl from openpilot.cereal import custom from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog -from openpilot.sunnypilot.models.fetcher import ModelFetcher, get_cached_bundles from openpilot.sunnypilot.models.helpers import ACTIVE_BUNDLE_KEYS from openpilot.selfdrive.ui.mici.widgets.button import BigButton from openpilot.selfdrive.ui.ui_state import ui_state, device -from openpilot.selfdrive.ui.sunnypilot.model_info import model_info +from openpilot.selfdrive.ui.sunnypilot.model_info import bundles_for_source, model_info 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 @@ -111,16 +110,12 @@ class ModelsLayoutMici(NavScroller): favs = ui_state.params.get("ModelManager_Favs") favorites = set(favs.split(';')) if favs else set() - active = ModelFetcher.active_source(ui_state.sm["deviceState"].chestnutPresent) - if source != active: - bundles = get_cached_bundles(ui_state.params, 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 - else: - bundles = self.model_manager.availableBundles + 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 = [] @@ -155,13 +150,8 @@ class ModelsLayoutMici(NavScroller): return favs = ui_state.params.get("ModelManager_Favs") favorites = set(favs.split(';')) if favs else set() - active = ModelFetcher.active_source(ui_state.sm["deviceState"].chestnutPresent) - if source != active: - bundles = get_cached_bundles(ui_state.params, source) - else: - bundles = self.model_manager.availableBundles - folders = self._get_grouped_bundles(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 = [] diff --git a/openpilot/selfdrive/ui/sunnypilot/model_info.py b/openpilot/selfdrive/ui/sunnypilot/model_info.py index 5222ef70c6..ce0d1cdf79 100644 --- a/openpilot/selfdrive/ui/sunnypilot/model_info.py +++ b/openpilot/selfdrive/ui/sunnypilot/model_info.py @@ -5,15 +5,26 @@ 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 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 model_info() -> tuple[str, str, str]: """returns (active source, active model name, other model name)""" - source = get_active_source(usbgpu=ui_state.usbgpu, - usbgpu_active=ui_state.usbgpu_active, usbgpu_loading=ui_state.usbgpu_loading, - offroad=ui_state.is_offroad()) + 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) From c2cbae9e00847b057fac7f9d7c775f03894d3b3a Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Wed, 26 Aug 2026 02:48:15 -0400 Subject: [PATCH 17/38] [TIZI/TICI] ui: disable the other-model row onroad like the active row --- openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py index bb788509e1..cbd655b7f2 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py @@ -286,9 +286,11 @@ class ModelsLayout(Widget): if not ui_state.is_offroad(): self.current_model_item.action_item.set_enabled(False) + self.other_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.other_model_item.action_item.set_enabled(True) self.current_model_item.set_description("") def _render(self, rect): From e717541ef4ffd02763e64fdbc29a58a83158580a Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Wed, 26 Aug 2026 02:48:33 -0400 Subject: [PATCH 18/38] [TIZI/TICI] ui: drop docstring that restates the function name --- openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py index cbd655b7f2..b01d51cce5 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py @@ -236,7 +236,6 @@ class ModelsLayout(Widget): self._open_source_dialog("qcom" if active_source() == "usbgpu" else "usbgpu") def _open_source_dialog(self, source): - """Opens the picker for one hardware: its model folders plus the Default reset entry.""" self._selection_source = source favs = ui_state.params.get("ModelManager_Favs") favorites = set(favs.split(';')) if favs else set() From 42486d4df526b9cc0a63f07c5887fc1d7974f00e Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Wed, 26 Aug 2026 02:49:36 -0400 Subject: [PATCH 19/38] [TIZI/TICI] ui: keep Favorites as the first model folder in the picker --- openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py index b01d51cce5..3ad984d815 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py @@ -226,7 +226,7 @@ class ModelsLayout(Widget): 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): From 40e0675fe6ad829c139399548a3c69a1ad95bb71 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Wed, 26 Aug 2026 02:49:37 -0400 Subject: [PATCH 20/38] ui: record why model names read the params slots and not modelManagerSP --- openpilot/selfdrive/ui/sunnypilot/model_info.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openpilot/selfdrive/ui/sunnypilot/model_info.py b/openpilot/selfdrive/ui/sunnypilot/model_info.py index ce0d1cdf79..2826a19f13 100644 --- a/openpilot/selfdrive/ui/sunnypilot/model_info.py +++ b/openpilot/selfdrive/ui/sunnypilot/model_info.py @@ -23,7 +23,11 @@ def bundles_for_source(source: str): def model_info() -> tuple[str, str, str]: - """returns (active source, active model name, other model name)""" + """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) From c71a093268642ffd61a8e25a2e33b1e4ca1d6b46 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Wed, 26 Aug 2026 03:06:33 -0400 Subject: [PATCH 21/38] ui: show the default model's name on the picker Default entries --- .../selfdrive/ui/sunnypilot/layouts/settings/models.py | 4 ++-- .../selfdrive/ui/sunnypilot/mici/layouts/models.py | 4 ++-- openpilot/selfdrive/ui/sunnypilot/model_info.py | 10 ++++++---- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py index 3ad984d815..159af50a22 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py @@ -13,7 +13,7 @@ from openpilot.cereal import custom from openpilot.sunnypilot.models.helpers import ACTIVE_BUNDLE_KEYS, get_selected_bundle, resolve_bundle_by_ref from openpilot.common.constants import CV from openpilot.selfdrive.ui.ui_state import device, ui_state -from openpilot.selfdrive.ui.sunnypilot.model_info import active_source, bundles_for_source, model_info +from openpilot.selfdrive.ui.sunnypilot.model_info import active_source, bundles_for_source, default_model_name, model_info 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 @@ -251,7 +251,7 @@ class ModelsLayout(Widget): bundles = bundles_for_source(source) if not bundles: return [] - folders_list = [TreeFolder("", [TreeNode("Default", {'display_name': "Default"})])] + folders_list = [TreeFolder("", [TreeNode("Default", {'display_name': default_model_name(source)})])] folders_list.extend(self._get_folders(favorites, bundles)) return folders_list diff --git a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py index e6a9b17a09..cd24740d4b 100644 --- a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py @@ -11,7 +11,7 @@ from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog from openpilot.sunnypilot.models.helpers import ACTIVE_BUNDLE_KEYS from openpilot.selfdrive.ui.mici.widgets.button import BigButton from openpilot.selfdrive.ui.ui_state import ui_state, device -from openpilot.selfdrive.ui.sunnypilot.model_info import bundles_for_source, model_info +from openpilot.selfdrive.ui.sunnypilot.model_info import bundles_for_source, default_model_name, model_info 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 @@ -119,7 +119,7 @@ class ModelsLayoutMici(NavScroller): folders = self._get_grouped_bundles(bundles, favorites) folder_buttons = [] - default_btn = BigButton(tr("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) diff --git a/openpilot/selfdrive/ui/sunnypilot/model_info.py b/openpilot/selfdrive/ui/sunnypilot/model_info.py index 2826a19f13..2c88ac2358 100644 --- a/openpilot/selfdrive/ui/sunnypilot/model_info.py +++ b/openpilot/selfdrive/ui/sunnypilot/model_info.py @@ -22,6 +22,10 @@ def bundles_for_source(source: str): return get_cached_bundles(ui_state.params, source) +def default_model_name(source: str) -> str: + return f"{DEFAULT_BIG_MODEL if source == 'usbgpu' else DEFAULT_MODEL} (Default)" + + def model_info() -> tuple[str, str, str]: """returns (active source, active model name, other model name) @@ -33,8 +37,6 @@ def model_info() -> tuple[str, str, str]: 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 f"{DEFAULT_BIG_MODEL if source == 'usbgpu' else DEFAULT_MODEL} (Default)" - other_name = other_bundle.displayName if other_bundle \ - else f"{DEFAULT_MODEL if source == 'usbgpu' else DEFAULT_BIG_MODEL} (Default)" + 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 From b0a63f198b4ba343e9d5cfcd779293653f2a43fc Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Wed, 26 Aug 2026 03:08:04 -0400 Subject: [PATCH 22/38] models: bind a download to its ref so cancel and reselect work everywhere --- openpilot/sunnypilot/models/manager.py | 23 +++++++- .../models/tests/test_manager_download.py | 56 +++++++++++++++++++ 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/openpilot/sunnypilot/models/manager.py b/openpilot/sunnypilot/models/manager.py index 2405566d55..930eddef6e 100644 --- a/openpilot/sunnypilot/models/manager.py +++ b/openpilot/sunnypilot/models/manager.py @@ -39,6 +39,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: + # DownloadRef removed means cancel; a different ref means the user picked + # another model mid-download. Either way this download must stop. + return self.params.get("ModelManager_DownloadRef") != self._download_ref + + def _release_download_ref(self) -> None: + if not self._download_interrupted(): + 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,7 +91,7 @@ class ModelManagerSP: f.write(chunk) bytes_downloaded += len(chunk) - if self.params.get("ModelManager_DownloadRef") is None: + if self._download_interrupted(): raise Exception("Download cancelled") if total_size > 0: @@ -118,7 +129,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_DownloadRef") is None: + if self._download_interrupted(): raise Exception("Download cancelled") intra = chunk_downloaded / max(chunk_size, 1) progress = min(99.0, ((i + intra) / num_chunks) * 100) @@ -137,6 +148,9 @@ class ModelManagerSP: async def _process_artifact(self, artifact, destination_path: str) -> None: if not artifact.downloadUri.uri: return None + if self._download_interrupted(): + # raised before the try so a cancel never deletes files already on disk + raise Exception("Download cancelled") url = artifact.downloadUri.uri expected_hash = artifact.downloadUri.sha256 @@ -242,6 +256,8 @@ class ModelManagerSP: seen_artifacts.add(artifact.fileName) await self._process_artifact(artifact, destination_path) + if self._download_interrupted(): + raise Exception("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) @@ -274,12 +290,13 @@ class ModelManagerSP: 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 + 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.params.remove("ModelManager_DownloadRef") + self._release_download_ref() self.selected_bundle = None if self.params.get("ModelManager_ClearCache"): diff --git a/openpilot/sunnypilot/models/tests/test_manager_download.py b/openpilot/sunnypilot/models/tests/test_manager_download.py index d74deb03e6..2d990afa52 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,66 @@ 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_cancels_transfer(self): + """Selecting another model mid-transfer cancels the running 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 b"other-ref" + 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_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): + """Cancelling 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() + self.manager.params = params + self.manager._download_ref = b"ref" # store has no DownloadRef -> cancelled + 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 _make_params_with_store(self): params = mock.MagicMock() store = {} From 1e1338f6cebc29254dfd27cb356b2148a37072ba Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Wed, 26 Aug 2026 03:09:08 -0400 Subject: [PATCH 23/38] models: resume partial chunked downloads and verify silently --- openpilot/sunnypilot/models/manager.py | 26 +++++++++---------- .../models/tests/test_manager_download.py | 21 ++++++++++++++- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/openpilot/sunnypilot/models/manager.py b/openpilot/sunnypilot/models/manager.py index 930eddef6e..787f65e3e6 100644 --- a/openpilot/sunnypilot/models/manager.py +++ b/openpilot/sunnypilot/models/manager.py @@ -105,7 +105,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) @@ -117,8 +117,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 @@ -132,12 +135,13 @@ class ModelManagerSP: if self._download_interrupted(): raise Exception("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)) @@ -158,21 +162,17 @@ class ModelManagerSP: full_path = os.path.join(destination_path, filename) try: + # verification is silent: publishing it as progress made the bar climb and + # then fall back to zero when the real download started 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 - self._sync_artifact_progress(artifact) - self._report_status() - if chunks_valid and num_chunks > 0: - is_cached = True + if await verify_file(get_chunk_name(full_path, i, num_chunks), chunk.sha256): + valid_chunks.add(i) + is_cached = len(valid_chunks) == num_chunks else: if await verify_file(full_path, expected_hash): is_cached = True @@ -186,7 +186,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)) diff --git a/openpilot/sunnypilot/models/tests/test_manager_download.py b/openpilot/sunnypilot/models/tests/test_manager_download.py index 2d990afa52..5a9ff631a5 100644 --- a/openpilot/sunnypilot/models/tests/test_manager_download.py +++ b/openpilot/sunnypilot/models/tests/test_manager_download.py @@ -323,7 +323,7 @@ class TestManagerDownload(ManagerDownloadTestBase): self.manager.params.remove.assert_called_once_with("ModelManager_DownloadRef") def test_cached_bundle_cancel_skips_slot_write(self): - """Cancelling must stop an already-on-disk bundle before it is applied to the slot.""" + """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) @@ -341,6 +341,25 @@ class TestManagerDownload(ManagerDownloadTestBase): 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 _make_params_with_store(self): params = mock.MagicMock() store = {} From 52222970ad3d6c7838f264051e67a779e1ab4d15 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Wed, 26 Aug 2026 03:20:19 -0400 Subject: [PATCH 24/38] models: publish a verifying status so cached checks read as verification, not a stuck download --- openpilot/cereal/custom.capnp | 1 + .../ui/sunnypilot/layouts/settings/models.py | 2 ++ .../ui/sunnypilot/mici/layouts/models.py | 7 +++++-- openpilot/sunnypilot/models/manager.py | 8 ++++++-- .../models/tests/test_manager_download.py | 16 ++++++++++++++++ .../ui/sunnypilot/widgets/download_status.py | 2 ++ 6 files changed, 32 insertions(+), 4 deletions(-) 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 159af50a22..f70f869dee 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py @@ -185,6 +185,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}: diff --git a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py index cd24740d4b..434cabe1ef 100644 --- a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py @@ -202,16 +202,19 @@ 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.current_model_info.current_model_header._shimmer = True self.current_model_info.current_model_text.set_text(f"{manager.selectedBundle.internalName.lower()}") self.current_model_info.info_header.set_text(tr("progress") + self._download_progress) diff --git a/openpilot/sunnypilot/models/manager.py b/openpilot/sunnypilot/models/manager.py index 787f65e3e6..54b583363f 100644 --- a/openpilot/sunnypilot/models/manager.py +++ b/openpilot/sunnypilot/models/manager.py @@ -162,8 +162,8 @@ class ModelManagerSP: full_path = os.path.join(destination_path, filename) try: - # verification is silent: publishing it as progress made the bar climb and - # then fall back to zero when the real download started + # 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: @@ -172,6 +172,10 @@ class ModelManagerSP: for i, chunk in enumerate(artifact.chunks): 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() is_cached = len(valid_chunks) == num_chunks else: if await verify_file(full_path, expected_hash): diff --git a/openpilot/sunnypilot/models/tests/test_manager_download.py b/openpilot/sunnypilot/models/tests/test_manager_download.py index 5a9ff631a5..44b3ef3246 100644 --- a/openpilot/sunnypilot/models/tests/test_manager_download.py +++ b/openpilot/sunnypilot/models/tests/test_manager_download.py @@ -360,6 +360,22 @@ class TestManagerDownload(ManagerDownloadTestBase): 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..43868ffaa3 100644 --- a/openpilot/system/ui/sunnypilot/widgets/download_status.py +++ b/openpilot/system/ui/sunnypilot/widgets/download_status.py @@ -134,6 +134,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 From 1e48f354c8a08ddc4f7fda918a01535b47016843 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Wed, 26 Aug 2026 03:21:46 -0400 Subject: [PATCH 25/38] [TIZI/TICI] ui: move download status onto each model's own row --- .../ui/sunnypilot/layouts/settings/models.py | 52 ++---- .../ui/sunnypilot/widgets/download_status.py | 168 ------------------ 2 files changed, 18 insertions(+), 202 deletions(-) delete mode 100644 openpilot/system/ui/sunnypilot/widgets/download_status.py diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py index f70f869dee..b6624b6f57 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py @@ -7,7 +7,6 @@ See the LICENSE.md file in the root directory for more details. import os import re import time -import pyray as rl from openpilot.cereal import custom from openpilot.sunnypilot.models.helpers import ACTIVE_BUNDLE_KEYS, get_selected_bundle, resolve_bundle_by_ref @@ -19,13 +18,11 @@ from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.widgets import DialogResult, Widget from openpilot.system.ui.widgets.confirm_dialog import alert_dialog, ConfirmDialog from openpilot.system.ui.widgets.scroller_tici import Scroller -from openpilot.system.ui.widgets.toggle import ON_COLOR from openpilot.sunnypilot.models.runners.constants import CUSTOM_MODEL_PATH from openpilot.system.ui.sunnypilot.lib.styles import style from openpilot.system.ui.sunnypilot.lib.utils import NoElideButtonAction, ScrollingButtonAction from openpilot.system.ui.sunnypilot.widgets.list_view import ListItemSP, toggle_item_sp, option_item_sp -from openpilot.system.ui.sunnypilot.widgets.download_status import download_status_item from openpilot.system.ui.sunnypilot.widgets.tree_dialog import TreeOptionDialog, TreeNode, TreeFolder if gui_app.sunnypilot_ui(): @@ -38,7 +35,7 @@ class ModelsLayout(Widget): self.model_manager = None self.model_dialog = None self._selection_source = None - self._downloading = False + self._download_status = None # (source, text) while a download or verify is in flight self.last_cache_calc_time = 0 self._initialize_items() @@ -63,8 +60,6 @@ class ModelsLayout(Widget): callback=self._handle_other_model_clicked ) - 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), @@ -103,7 +98,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.other_model_item, self.cancel_download_item, self.download_item, self.refresh_item, self.clear_cache_item, + self.items = [self.current_model_item, self.other_model_item, self.cancel_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): @@ -117,10 +112,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 @@ -143,20 +134,14 @@ 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 + self._download_status = None - 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 + bundle = self.model_manager.selectedBundle if self.model_manager else None 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.cancel_download_item.set_visible(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 @@ -165,34 +150,31 @@ class ModelsLayout(Widget): 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)) - self._downloading = self.download_item.action_item.downloading + resolved = resolve_bundle_by_ref(bundle.ref, {source: bundles_for_source(source) for source in ("qcom", "usbgpu")}) + if not resolved: + return + self._download_status = (resolved[1], self._download_status_text(progresses)) @staticmethod - def _download_row_state(progresses, name: str) -> dict: - """Maps a bundle's artifact progress to DownloadStatusAction.update kwargs.""" + def _download_status_text(progresses) -> str: # .raw: _DynamicEnum equals its int but does not hash like it statuses = {getattr(p.status, 'raw', p.status) for p in progresses} progress = sum(p.progress for p in progresses) / len(progresses) ds = custom.ModelManagerSP.DownloadStatus 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"} + return tr("download failed") if ds.verifying in statuses: - return {"name": name, "downloading": True, "progress": progress, "status_text": tr("verifying")} + return f"{tr('verifying')} {int(progress)}%" if ds.downloading in statuses: - return {"name": name, "downloading": True, "progress": progress} + return f"{tr('downloading')} {int(progress)}%" if statuses <= {ds.downloaded, ds.cached}: - return {"name": name, "text_color": ON_COLOR, "icon": "icons/checkmark.png"} - # circled_slash is authored grey; tinting it again only darkens it - return {"name": name, "text_color": rl.GRAY, "icon": "icons/circled_slash.png", "icon_color": rl.WHITE} + return tr("downloaded") + return "" def _on_model_selected(self, result): if result != DialogResult.CONFIRM: @@ -285,6 +267,8 @@ class ModelsLayout(Widget): self.other_model_item.set_title(tr("Big Model") if source == "qcom" else tr("Small Model")) self.other_model_item.action_item.set_value(other_name) + dl_source, dl_text = self._download_status or (None, "") + self.other_model_item.set_description(dl_text if dl_source is not None and dl_source != source else "") if not ui_state.is_offroad(): self.current_model_item.action_item.set_enabled(False) self.other_model_item.action_item.set_enabled(False) @@ -292,7 +276,7 @@ class ModelsLayout(Widget): else: self.current_model_item.action_item.set_enabled(True) self.other_model_item.action_item.set_enabled(True) - self.current_model_item.set_description("") + self.current_model_item.set_description(dl_text if dl_source == source else "") def _render(self, rect): self._scroller.render(rect) diff --git a/openpilot/system/ui/sunnypilot/widgets/download_status.py b/openpilot/system/ui/sunnypilot/widgets/download_status.py deleted file mode 100644 index 43868ffaa3..0000000000 --- a/openpilot/system/ui/sunnypilot/widgets/download_status.py +++ /dev/null @@ -1,168 +0,0 @@ -""" -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 numpy as np -import pyray as rl - -from openpilot.common.filter_simple import FirstOrderFilter -from openpilot.system.ui.lib.application import gui_app, FontWeight -from openpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient -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.widgets.list_view import ItemAction - -FONT_SIZE = style.ITEM_TEXT_FONT_SIZE -ICON_SIZE = 56 -ICON_PADDING = 12 - -BAR_WIDTH = 1100 -BAR_HEIGHT = 20 -BAR_GAP = 16 -BAR_RADIUS = BAR_HEIGHT / 2 -CAPSULE_POINTS = 24 - -RAIL_COLOR = rl.Color(60, 60, 60, 255) -FILL_COLOR = rl.Color(30, 121, 232, 255) -# rl.WHITE is a tuple; the shimmer path reads .a off the color -TEXT_COLOR = rl.Color(255, 255, 255, 255) - -SWEEP_SPEED = 550.0 # px/s -SWEEP_BAND = 240.0 # highlight half-width, px -SWEEP_DIM = 0.65 - - -class DownloadStatusAction(ItemAction): - """Model download row: a name + percent over a progress rail while downloading, a name + icon otherwise.""" - - def __init__(self): - super().__init__(width=BAR_WIDTH) - self.name = "" - self.status_text = "" - self.downloading = False - self.text_color = rl.GRAY - self.icon: str | None = None - self.icon_color: rl.Color | None = None - self._font = gui_app.font(FontWeight.NORMAL) - # raw progress arrives in steps, one per 128KB chunk the manager publishes - self._progress = FirstOrderFilter(0.0, 0.5, 1 / gui_app.target_fps) - # integrated per frame; (t * speed) % span jumps whenever the fill width changes - self._sweep = 0.0 - - self._name_label = UnifiedLabel("", font_size=FONT_SIZE, font_weight=FontWeight.NORMAL, text_color=TEXT_COLOR, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) - self._percent_label = UnifiedLabel("", font_size=FONT_SIZE, font_weight=FontWeight.NORMAL, text_color=TEXT_COLOR, - 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): - if downloading and not self.downloading: - self._name_label.reset_shimmer() - self._progress.x = progress - self._sweep = 0.0 - self.name = name - self.downloading = downloading - self.status_text = status_text - self.text_color = text_color - self.icon = icon - self.icon_color = icon_color - self._name_label._shimmer = downloading - if downloading: - self._progress.update(progress) - self._sweep += SWEEP_SPEED / gui_app.target_fps - - @property - def _idle_text(self) -> str: - return f"{self.name} - {self.status_text}" if self.status_text else self.name - - def get_width_hint(self) -> float: - if self.downloading: - return BAR_WIDTH - width = measure_text_cached(self._font, self._idle_text, FONT_SIZE).x - if self.icon: - width += ICON_SIZE + ICON_PADDING - return width - - def _render(self, rect: rl.Rectangle): - if self.downloading: - self._render_downloading(rect) - else: - self._render_idle(rect) - - def _sweep_gradient(self, width: float) -> Gradient: - # clearance at both ends keeps the wrap offscreen - center = (self._sweep % (width + 2 * SWEEP_BAND)) - SWEEP_BAND - - def band(x: float) -> float: - return max(0.0, 1.0 - abs(x - center) / SWEEP_BAND) - - # sampling the corners is exact for a piecewise linear band - xs = sorted({0.0, width} | {min(max(center + o, 0.0), width) for o in (-SWEEP_BAND, 0.0, SWEEP_BAND)}, reverse=True) - # the gradient axis runs right-to-left in screen space - stops = [1.0 - x / width for x in xs] - # alpha here is the lift over the SWEEP_DIM base, not the final opacity - colors = [rl.Color(FILL_COLOR.r, FILL_COLOR.g, FILL_COLOR.b, int(255 * band(x))) for x in xs] - return Gradient(start=(0.0, 0.0), end=(1.0, 0.0), colors=colors, stops=stops) - - @staticmethod - def _capsule(rect: rl.Rectangle) -> np.ndarray: - """Rounded-end ribbon so the gradient covers the caps.""" - r = rect.height / 2 - cy = rect.y + r - top, bottom = [], [] - for i in range(CAPSULE_POINTS): - x = rect.x + rect.width * i / (CAPSULE_POINTS - 1) - d = min(x - rect.x, rect.x + rect.width - x, r) - h = math.sqrt(max(r * r - (r - d) ** 2, 0.0)) - top.append((x, cy - h)) - bottom.append((x, cy + h)) - return np.array(top + bottom[::-1], dtype=np.float32) - - def _draw_fill(self, rail: rl.Rectangle, fill_width: float): - if fill_width <= 0: - return - fill = rl.Rectangle(rail.x, rail.y, fill_width, rail.height) - rl.draw_rectangle_rounded(fill, 1.0, 10, rl.Color(FILL_COLOR.r, FILL_COLOR.g, FILL_COLOR.b, int(255 * SWEEP_DIM))) - draw_polygon(fill, self._capsule(fill), gradient=self._sweep_gradient(fill_width)) - - 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 - - text_rect = rl.Rectangle(rect.x, top, rect.width, text_height) - self._name_label.set_text(self.name) - self._name_label.render(text_rect) - self._percent_label.set_text(percent) - self._percent_label.render(text_rect) - - rail = rl.Rectangle(rect.x, top + text_height + BAR_GAP, rect.width, BAR_HEIGHT) - rl.draw_rectangle_rounded(rail, 1.0, 10, RAIL_COLOR) - self._draw_fill(rail, max(0.0, min(rect.width, rect.width * (self._progress.x / 100.0)))) - - def _render_idle(self, rect: rl.Rectangle): - text = self._idle_text - text_size = measure_text_cached(self._font, text, FONT_SIZE) - right = rect.x + rect.width - - if self.icon: - texture = gui_app.texture(self.icon, ICON_SIZE, ICON_SIZE, keep_aspect_ratio=True) - rl.draw_texture_v(texture, rl.Vector2(right - texture.width, rect.y + (rect.height - texture.height) / 2), - self.icon_color or self.text_color) - right -= texture.width + ICON_PADDING - - 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 download_status_item(title): - return ListItemSP(title=title, action_item=DownloadStatusAction(), title_color=style.ITEM_TEXT_COLOR) From 6767c0713d3a8913cbc6eb4c9343b4f15d174d1f Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Wed, 26 Aug 2026 03:29:54 -0400 Subject: [PATCH 26/38] [TIZI/TICI] ui: show the row status description while it has text --- .../ui/sunnypilot/layouts/settings/models.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py index b6624b6f57..5d9a37e29b 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py @@ -268,15 +268,26 @@ class ModelsLayout(Widget): self.other_model_item.action_item.set_value(other_name) dl_source, dl_text = self._download_status or (None, "") - self.other_model_item.set_description(dl_text if dl_source is not None and dl_source != source else "") + self._set_row_status(self.other_model_item, dl_text if dl_source is not None and dl_source != source else "") if not ui_state.is_offroad(): self.current_model_item.action_item.set_enabled(False) self.other_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")) + self._set_row_status(self.current_model_item, tr("Only available when vehicle is off, or always offroad mode is on")) else: self.current_model_item.action_item.set_enabled(True) self.other_model_item.action_item.set_enabled(True) - self.current_model_item.set_description(dl_text if dl_source == source else "") + self._set_row_status(self.current_model_item, dl_text if dl_source == source else "") + + @staticmethod + def _set_row_status(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 _render(self, rect): self._scroller.render(rect) From b37e5355bb1648542ad98bcca6c621a326e67419 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Wed, 26 Aug 2026 03:39:55 -0400 Subject: [PATCH 27/38] [TIZI/TICI] ui: restore the Model Status bar row --- .../ui/sunnypilot/layouts/settings/models.py | 65 +++---- .../ui/sunnypilot/widgets/download_status.py | 168 ++++++++++++++++++ 2 files changed, 203 insertions(+), 30 deletions(-) create mode 100644 openpilot/system/ui/sunnypilot/widgets/download_status.py diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py index 5d9a37e29b..f70f869dee 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py @@ -7,6 +7,7 @@ See the LICENSE.md file in the root directory for more details. import os import re import time +import pyray as rl from openpilot.cereal import custom from openpilot.sunnypilot.models.helpers import ACTIVE_BUNDLE_KEYS, get_selected_bundle, resolve_bundle_by_ref @@ -18,11 +19,13 @@ from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.widgets import DialogResult, Widget from openpilot.system.ui.widgets.confirm_dialog import alert_dialog, ConfirmDialog from openpilot.system.ui.widgets.scroller_tici import Scroller +from openpilot.system.ui.widgets.toggle import ON_COLOR from openpilot.sunnypilot.models.runners.constants import CUSTOM_MODEL_PATH from openpilot.system.ui.sunnypilot.lib.styles import style from openpilot.system.ui.sunnypilot.lib.utils import NoElideButtonAction, ScrollingButtonAction from openpilot.system.ui.sunnypilot.widgets.list_view import ListItemSP, toggle_item_sp, option_item_sp +from openpilot.system.ui.sunnypilot.widgets.download_status import download_status_item from openpilot.system.ui.sunnypilot.widgets.tree_dialog import TreeOptionDialog, TreeNode, TreeFolder if gui_app.sunnypilot_ui(): @@ -35,7 +38,7 @@ class ModelsLayout(Widget): self.model_manager = None self.model_dialog = None self._selection_source = None - self._download_status = None # (source, text) while a download or verify is in flight + self._downloading = False self.last_cache_calc_time = 0 self._initialize_items() @@ -60,6 +63,8 @@ class ModelsLayout(Widget): callback=self._handle_other_model_clicked ) + 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), @@ -98,7 +103,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.other_model_item, self.cancel_download_item, self.refresh_item, self.clear_cache_item, + self.items = [self.current_model_item, self.other_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): @@ -112,6 +117,10 @@ 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 @@ -134,14 +143,20 @@ 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._download_status = None + self._downloading = False - bundle = self.model_manager.selectedBundle if self.model_manager else None + 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(ui_state.params.get("ModelManager_DownloadRef") 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 @@ -150,31 +165,34 @@ class ModelsLayout(Widget): 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 - resolved = resolve_bundle_by_ref(bundle.ref, {source: bundles_for_source(source) for source in ("qcom", "usbgpu")}) - if not resolved: - return - self._download_status = (resolved[1], self._download_status_text(progresses)) + self.download_item.set_visible(True) + self.download_item.action_item.update(**self._download_row_state(progresses, bundle.internalName)) + self._downloading = self.download_item.action_item.downloading @staticmethod - def _download_status_text(progresses) -> str: + def _download_row_state(progresses, name: str) -> dict: + """Maps a bundle's artifact progress to DownloadStatusAction.update kwargs.""" # .raw: _DynamicEnum equals its int but does not hash like it statuses = {getattr(p.status, 'raw', p.status) for p in progresses} progress = sum(p.progress for p in progresses) / len(progresses) ds = custom.ModelManagerSP.DownloadStatus if ds.failed in statuses: - return tr("download failed") + # 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 f"{tr('verifying')} {int(progress)}%" + return {"name": name, "downloading": True, "progress": progress, "status_text": tr("verifying")} if ds.downloading in statuses: - return f"{tr('downloading')} {int(progress)}%" + return {"name": name, "downloading": True, "progress": progress} if statuses <= {ds.downloaded, ds.cached}: - return tr("downloaded") - return "" + return {"name": name, "text_color": ON_COLOR, "icon": "icons/checkmark.png"} + # circled_slash is authored grey; tinting it again only darkens it + return {"name": name, "text_color": rl.GRAY, "icon": "icons/circled_slash.png", "icon_color": rl.WHITE} def _on_model_selected(self, result): if result != DialogResult.CONFIRM: @@ -267,27 +285,14 @@ class ModelsLayout(Widget): self.other_model_item.set_title(tr("Big Model") if source == "qcom" else tr("Small Model")) self.other_model_item.action_item.set_value(other_name) - dl_source, dl_text = self._download_status or (None, "") - self._set_row_status(self.other_model_item, dl_text if dl_source is not None and dl_source != source else "") if not ui_state.is_offroad(): self.current_model_item.action_item.set_enabled(False) self.other_model_item.action_item.set_enabled(False) - self._set_row_status(self.current_model_item, tr("Only available when vehicle is off, or always offroad mode is on")) + 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.other_model_item.action_item.set_enabled(True) - self._set_row_status(self.current_model_item, dl_text if dl_source == source else "") - - @staticmethod - def _set_row_status(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("") + self.current_model_item.set_description("") def _render(self, rect): self._scroller.render(rect) diff --git a/openpilot/system/ui/sunnypilot/widgets/download_status.py b/openpilot/system/ui/sunnypilot/widgets/download_status.py new file mode 100644 index 0000000000..43868ffaa3 --- /dev/null +++ b/openpilot/system/ui/sunnypilot/widgets/download_status.py @@ -0,0 +1,168 @@ +""" +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 numpy as np +import pyray as rl + +from openpilot.common.filter_simple import FirstOrderFilter +from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient +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.widgets.list_view import ItemAction + +FONT_SIZE = style.ITEM_TEXT_FONT_SIZE +ICON_SIZE = 56 +ICON_PADDING = 12 + +BAR_WIDTH = 1100 +BAR_HEIGHT = 20 +BAR_GAP = 16 +BAR_RADIUS = BAR_HEIGHT / 2 +CAPSULE_POINTS = 24 + +RAIL_COLOR = rl.Color(60, 60, 60, 255) +FILL_COLOR = rl.Color(30, 121, 232, 255) +# rl.WHITE is a tuple; the shimmer path reads .a off the color +TEXT_COLOR = rl.Color(255, 255, 255, 255) + +SWEEP_SPEED = 550.0 # px/s +SWEEP_BAND = 240.0 # highlight half-width, px +SWEEP_DIM = 0.65 + + +class DownloadStatusAction(ItemAction): + """Model download row: a name + percent over a progress rail while downloading, a name + icon otherwise.""" + + def __init__(self): + super().__init__(width=BAR_WIDTH) + self.name = "" + self.status_text = "" + self.downloading = False + self.text_color = rl.GRAY + self.icon: str | None = None + self.icon_color: rl.Color | None = None + self._font = gui_app.font(FontWeight.NORMAL) + # raw progress arrives in steps, one per 128KB chunk the manager publishes + self._progress = FirstOrderFilter(0.0, 0.5, 1 / gui_app.target_fps) + # integrated per frame; (t * speed) % span jumps whenever the fill width changes + self._sweep = 0.0 + + self._name_label = UnifiedLabel("", font_size=FONT_SIZE, font_weight=FontWeight.NORMAL, text_color=TEXT_COLOR, + alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + self._percent_label = UnifiedLabel("", font_size=FONT_SIZE, font_weight=FontWeight.NORMAL, text_color=TEXT_COLOR, + 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): + if downloading and not self.downloading: + self._name_label.reset_shimmer() + self._progress.x = progress + self._sweep = 0.0 + self.name = name + self.downloading = downloading + self.status_text = status_text + self.text_color = text_color + self.icon = icon + self.icon_color = icon_color + self._name_label._shimmer = downloading + if downloading: + self._progress.update(progress) + self._sweep += SWEEP_SPEED / gui_app.target_fps + + @property + def _idle_text(self) -> str: + return f"{self.name} - {self.status_text}" if self.status_text else self.name + + def get_width_hint(self) -> float: + if self.downloading: + return BAR_WIDTH + width = measure_text_cached(self._font, self._idle_text, FONT_SIZE).x + if self.icon: + width += ICON_SIZE + ICON_PADDING + return width + + def _render(self, rect: rl.Rectangle): + if self.downloading: + self._render_downloading(rect) + else: + self._render_idle(rect) + + def _sweep_gradient(self, width: float) -> Gradient: + # clearance at both ends keeps the wrap offscreen + center = (self._sweep % (width + 2 * SWEEP_BAND)) - SWEEP_BAND + + def band(x: float) -> float: + return max(0.0, 1.0 - abs(x - center) / SWEEP_BAND) + + # sampling the corners is exact for a piecewise linear band + xs = sorted({0.0, width} | {min(max(center + o, 0.0), width) for o in (-SWEEP_BAND, 0.0, SWEEP_BAND)}, reverse=True) + # the gradient axis runs right-to-left in screen space + stops = [1.0 - x / width for x in xs] + # alpha here is the lift over the SWEEP_DIM base, not the final opacity + colors = [rl.Color(FILL_COLOR.r, FILL_COLOR.g, FILL_COLOR.b, int(255 * band(x))) for x in xs] + return Gradient(start=(0.0, 0.0), end=(1.0, 0.0), colors=colors, stops=stops) + + @staticmethod + def _capsule(rect: rl.Rectangle) -> np.ndarray: + """Rounded-end ribbon so the gradient covers the caps.""" + r = rect.height / 2 + cy = rect.y + r + top, bottom = [], [] + for i in range(CAPSULE_POINTS): + x = rect.x + rect.width * i / (CAPSULE_POINTS - 1) + d = min(x - rect.x, rect.x + rect.width - x, r) + h = math.sqrt(max(r * r - (r - d) ** 2, 0.0)) + top.append((x, cy - h)) + bottom.append((x, cy + h)) + return np.array(top + bottom[::-1], dtype=np.float32) + + def _draw_fill(self, rail: rl.Rectangle, fill_width: float): + if fill_width <= 0: + return + fill = rl.Rectangle(rail.x, rail.y, fill_width, rail.height) + rl.draw_rectangle_rounded(fill, 1.0, 10, rl.Color(FILL_COLOR.r, FILL_COLOR.g, FILL_COLOR.b, int(255 * SWEEP_DIM))) + draw_polygon(fill, self._capsule(fill), gradient=self._sweep_gradient(fill_width)) + + 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 + + text_rect = rl.Rectangle(rect.x, top, rect.width, text_height) + self._name_label.set_text(self.name) + self._name_label.render(text_rect) + self._percent_label.set_text(percent) + self._percent_label.render(text_rect) + + rail = rl.Rectangle(rect.x, top + text_height + BAR_GAP, rect.width, BAR_HEIGHT) + rl.draw_rectangle_rounded(rail, 1.0, 10, RAIL_COLOR) + self._draw_fill(rail, max(0.0, min(rect.width, rect.width * (self._progress.x / 100.0)))) + + def _render_idle(self, rect: rl.Rectangle): + text = self._idle_text + text_size = measure_text_cached(self._font, text, FONT_SIZE) + right = rect.x + rect.width + + if self.icon: + texture = gui_app.texture(self.icon, ICON_SIZE, ICON_SIZE, keep_aspect_ratio=True) + rl.draw_texture_v(texture, rl.Vector2(right - texture.width, rect.y + (rect.height - texture.height) / 2), + self.icon_color or self.text_color) + right -= texture.width + ICON_PADDING + + 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 download_status_item(title): + return ListItemSP(title=title, action_item=DownloadStatusAction(), title_color=style.ITEM_TEXT_COLOR) From 809f0ec3ffc0445c34675a06e599e5c65aeb9572 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Wed, 26 Aug 2026 03:40:48 -0400 Subject: [PATCH 28/38] models: a cancel interrupts verification immediately and keeps on-disk chunks --- openpilot/sunnypilot/models/manager.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/openpilot/sunnypilot/models/manager.py b/openpilot/sunnypilot/models/manager.py index 54b583363f..f6e97e2812 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""" @@ -92,7 +96,7 @@ class ModelManagerSP: bytes_downloaded += len(chunk) if self._download_interrupted(): - raise Exception("Download cancelled") + raise DownloadCancelled("Download cancelled") if total_size > 0: progress = (bytes_downloaded / total_size) * 100 @@ -133,7 +137,7 @@ class ModelManagerSP: f.write(data) chunk_downloaded += len(data) if self._download_interrupted(): - raise Exception("Download cancelled") + raise DownloadCancelled("Download cancelled") intra = chunk_downloaded / max(chunk_size, 1) progress = min(99.0, ((completed + intra) / num_chunks) * 100) artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.downloading @@ -153,8 +157,7 @@ class ModelManagerSP: if not artifact.downloadUri.uri: return None if self._download_interrupted(): - # raised before the try so a cancel never deletes files already on disk - raise Exception("Download cancelled") + raise DownloadCancelled("Download cancelled") url = artifact.downloadUri.uri expected_hash = artifact.downloadUri.sha256 @@ -170,6 +173,8 @@ class ModelManagerSP: from openpilot.common.file_chunker import get_chunk_name num_chunks = len(artifact.chunks) for i, chunk in enumerate(artifact.chunks): + 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 @@ -207,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]: @@ -261,7 +277,7 @@ class ModelManagerSP: await self._process_artifact(artifact, destination_path) if self._download_interrupted(): - raise Exception("Download cancelled") + 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) From ce48330371eaf9a12b8f4f0c1ec5d15a6f08aa6e Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Wed, 26 Aug 2026 03:41:13 -0400 Subject: [PATCH 29/38] models: a selection made mid-download queues instead of cancelling the transfer --- openpilot/sunnypilot/models/manager.py | 8 +++---- .../models/tests/test_manager_download.py | 23 ++++++------------- 2 files changed, 11 insertions(+), 20 deletions(-) diff --git a/openpilot/sunnypilot/models/manager.py b/openpilot/sunnypilot/models/manager.py index f6e97e2812..9c950fc751 100644 --- a/openpilot/sunnypilot/models/manager.py +++ b/openpilot/sunnypilot/models/manager.py @@ -46,12 +46,12 @@ class ModelManagerSP: self._download_ref: bytes | str | None = None def _download_interrupted(self) -> bool: - # DownloadRef removed means cancel; a different ref means the user picked - # another model mid-download. Either way this download must stop. - return self.params.get("ModelManager_DownloadRef") != self._download_ref + # 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 not self._download_interrupted(): + if self.params.get("ModelManager_DownloadRef") == self._download_ref: self.params.remove("ModelManager_DownloadRef") self._download_ref = None diff --git a/openpilot/sunnypilot/models/tests/test_manager_download.py b/openpilot/sunnypilot/models/tests/test_manager_download.py index 44b3ef3246..4d3b7989fb 100644 --- a/openpilot/sunnypilot/models/tests/test_manager_download.py +++ b/openpilot/sunnypilot/models/tests/test_manager_download.py @@ -288,25 +288,15 @@ class TestManagerDownload(ManagerDownloadTestBase): assert not os.path.isfile(get_manifest_path(base_path)) self.run_with_server(body) - def test_replaced_download_ref_cancels_transfer(self): - """Selecting another model mid-transfer cancels the running download.""" + 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) - checks = {"n": 0} - - def get(key): - if key == "ModelManager_DownloadRef": - checks["n"] += 1 - return b"ref" if checks["n"] <= 2 else b"other-ref" - return b"0" - - self.manager.params.get.side_effect = get + self.manager.params.get.side_effect = lambda key: b"other-ref" if key == "ModelManager_DownloadRef" else None 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)) + 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): @@ -332,8 +322,9 @@ class TestManagerDownload(ManagerDownloadTestBase): 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" # store has no DownloadRef -> cancelled + 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() From 9e08f374d10e43b13986ef1d89356269f9cf862d Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Wed, 26 Aug 2026 03:44:20 -0400 Subject: [PATCH 30/38] [TIZI/TICI] ui: Model Status shows both slots idle and the queued pick while busy --- .../ui/sunnypilot/layouts/settings/models.py | 66 ++++++++++++------- .../ui/sunnypilot/widgets/download_status.py | 34 +++++++++- 2 files changed, 75 insertions(+), 25 deletions(-) diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py index f70f869dee..b9db390440 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py @@ -39,6 +39,7 @@ class ModelsLayout(Widget): self.model_dialog = None self._selection_source = None self._downloading = False + self._verifying = False self.last_cache_calc_time = 0 self._initialize_items() @@ -77,7 +78,8 @@ class ModelsLayout(Widget): callback=self._clear_cache ) - self.cancel_download_item = button_item(tr("Cancel Download"), tr("Cancel"), "", + 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, @@ -117,10 +119,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 @@ -143,36 +141,56 @@ 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 := self._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): + """One segment per slot: name, downloaded check or empty-slot mark, active in green.""" + active = active_source() + segments = [] + for source in ("qcom", "usbgpu"): + bundle = get_selected_bundle(ui_state.params, source) + name = bundle.internalName if bundle else tr("Default") + if source == active: + name += f" ({tr('active')})" + color = ON_COLOR if source == active else rl.GRAY + if bundle: + segments.append((name, color, "icons/checkmark.png", None)) + else: + segments.append((name, color, "icons/circled_slash.png", rl.WHITE)) + return segments + + def _queued_name(self, current_ref): + ref = ui_state.params.get("ModelManager_DownloadRef") + if ref and ref != current_ref: + if bundle := self._resolve_selected_bundle(ref): + return bundle.internalName + return None @staticmethod def _download_row_state(progresses, name: str) -> dict: diff --git a/openpilot/system/ui/sunnypilot/widgets/download_status.py b/openpilot/system/ui/sunnypilot/widgets/download_status.py index 43868ffaa3..cd71970c03 100644 --- a/openpilot/system/ui/sunnypilot/widgets/download_status.py +++ b/openpilot/system/ui/sunnypilot/widgets/download_status.py @@ -24,6 +24,7 @@ ICON_PADDING = 12 BAR_WIDTH = 1100 BAR_HEIGHT = 20 +SEGMENT_GAP = 24 BAR_GAP = 16 BAR_RADIUS = BAR_HEIGHT / 2 CAPSULE_POINTS = 24 @@ -45,6 +46,7 @@ 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.downloading = False self.text_color = rl.GRAY self.icon: str | None = None @@ -62,7 +64,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 +88,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 = measure_text_cached(self._font, seg[0], FONT_SIZE).x + 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) @@ -150,6 +164,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 @@ -163,6 +180,21 @@ 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() + 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 + text_height = measure_text_cached(self._font, text, FONT_SIZE).y + rl.draw_text_ex(self._font, text, rl.Vector2(x, rect.y + (rect.height - text_height) / 2), FONT_SIZE, 0, color) + 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 9348d4d7f60ec1c090576961098c2cff93ecb545 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Wed, 26 Aug 2026 04:00:27 -0400 Subject: [PATCH 31/38] [TIZI/TICI] ui: label the Model Status slots small and big and scroll long names --- .../ui/sunnypilot/layouts/settings/models.py | 21 ++++++++----------- .../selfdrive/ui/sunnypilot/model_info.py | 6 +++++- .../ui/sunnypilot/widgets/download_status.py | 14 +++++++++++-- 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py index b9db390440..5c4db49440 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py @@ -13,7 +13,7 @@ from openpilot.cereal import custom from openpilot.sunnypilot.models.helpers import ACTIVE_BUNDLE_KEYS, get_selected_bundle, resolve_bundle_by_ref from openpilot.common.constants import CV from openpilot.selfdrive.ui.ui_state import device, ui_state -from openpilot.selfdrive.ui.sunnypilot.model_info import active_source, bundles_for_source, default_model_name, model_info +from openpilot.selfdrive.ui.sunnypilot.model_info import active_source, bundles_for_source, default_model, default_model_name, model_info 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 @@ -163,26 +163,23 @@ class ModelsLayout(Widget): state = self._download_row_state(progresses, bundle.internalName) if queued := self._queued_name(bundle.ref): - state["name"] += f" · {queued} {tr('queued')}" + 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): - """One segment per slot: name, downloaded check or empty-slot mark, active in green.""" + """small and big slots side by side; the active side is green, an empty slot shows its default.""" active = active_source() segments = [] - for source in ("qcom", "usbgpu"): + 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 tr("Default") - if source == active: - name += f" ({tr('active')})" - color = ON_COLOR if source == active else rl.GRAY - if bundle: - segments.append((name, color, "icons/checkmark.png", None)) - else: - segments.append((name, color, "icons/circled_slash.png", rl.WHITE)) + name = bundle.internalName if bundle else default_model(source) + segments.append((label, rl.GRAY, None, None)) + segments.append((name, ON_COLOR if source == active else rl.LIGHTGRAY, "icons/checkmark.png", None)) return segments def _queued_name(self, current_ref): diff --git a/openpilot/selfdrive/ui/sunnypilot/model_info.py b/openpilot/selfdrive/ui/sunnypilot/model_info.py index 2c88ac2358..8348d68426 100644 --- a/openpilot/selfdrive/ui/sunnypilot/model_info.py +++ b/openpilot/selfdrive/ui/sunnypilot/model_info.py @@ -22,8 +22,12 @@ def bundles_for_source(source: str): 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_BIG_MODEL if source == 'usbgpu' else DEFAULT_MODEL} (Default)" + return f"{default_model(source)} (Default)" def model_info() -> tuple[str, str, str]: diff --git a/openpilot/system/ui/sunnypilot/widgets/download_status.py b/openpilot/system/ui/sunnypilot/widgets/download_status.py index cd71970c03..8af4d98f62 100644 --- a/openpilot/system/ui/sunnypilot/widgets/download_status.py +++ b/openpilot/system/ui/sunnypilot/widgets/download_status.py @@ -25,6 +25,7 @@ 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 @@ -47,6 +48,7 @@ class DownloadStatusAction(ItemAction): self.name = "" self.status_text = "" self.segments: list[tuple[str, rl.Color, str | None, rl.Color | None]] | None = None + self._segment_labels: list[UnifiedLabel] = [] self.downloading = False self.text_color = rl.GRAY self.icon: str | None = None @@ -99,7 +101,7 @@ class DownloadStatusAction(ItemAction): """[(segment, text width, total width incl. icon and gap)]""" out = [] for i, seg in enumerate(self.segments or []): - text_width = measure_text_cached(self._font, seg[0], FONT_SIZE).x + 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 @@ -182,12 +184,20 @@ class DownloadStatusAction(ItemAction): def _render_segments(self, rect: rl.Rectangle): measured = self._measured_segments() + while len(self._segment_labels) < len(measured): + self._segment_labels.append(UnifiedLabel("", 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 - rl.draw_text_ex(self._font, text, rl.Vector2(x, rect.y + (rect.height - text_height) / 2), FONT_SIZE, 0, color) + 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) From ac6e62078e39a0ab5886dacec2811c4ef0b69948 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Wed, 26 Aug 2026 04:26:19 -0400 Subject: [PATCH 32/38] models: start a queued download in the same tick and label empty slots (Default) --- .../ui/sunnypilot/layouts/settings/models.py | 4 +-- openpilot/sunnypilot/models/manager.py | 33 ++++++++++++------- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py index 5c4db49440..f44e8044aa 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py @@ -13,7 +13,7 @@ from openpilot.cereal import custom from openpilot.sunnypilot.models.helpers import ACTIVE_BUNDLE_KEYS, get_selected_bundle, resolve_bundle_by_ref from openpilot.common.constants import CV from openpilot.selfdrive.ui.ui_state import device, ui_state -from openpilot.selfdrive.ui.sunnypilot.model_info import active_source, bundles_for_source, default_model, default_model_name, model_info +from openpilot.selfdrive.ui.sunnypilot.model_info import active_source, bundles_for_source, default_model_name, model_info 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 @@ -177,7 +177,7 @@ class ModelsLayout(Widget): if segments: segments.append(("|", rl.GRAY, None, None)) bundle = get_selected_bundle(ui_state.params, source) - name = bundle.internalName if bundle else default_model(source) + name = bundle.internalName if bundle else default_model_name(source) segments.append((label, rl.GRAY, None, None)) segments.append((name, ON_COLOR if source == active else rl.LIGHTGRAY, "icons/checkmark.png", None)) return segments diff --git a/openpilot/sunnypilot/models/manager.py b/openpilot/sunnypilot/models/manager.py index 9c950fc751..178d6c04e5 100644 --- a/openpilot/sunnypilot/models/manager.py +++ b/openpilot/sunnypilot/models/manager.py @@ -294,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) @@ -307,17 +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 - 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 + self._process_download_requests() if self.params.get("ModelManager_ClearCache"): self.clear_model_cache() From e265854398d15c2645ed915d8d53a9c17277299d Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Wed, 26 Aug 2026 04:29:46 -0400 Subject: [PATCH 33/38] ui: scroll Model Status names at the corrected speed --- openpilot/system/ui/sunnypilot/widgets/download_status.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openpilot/system/ui/sunnypilot/widgets/download_status.py b/openpilot/system/ui/sunnypilot/widgets/download_status.py index 8af4d98f62..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 @@ -48,7 +49,7 @@ class DownloadStatusAction(ItemAction): self.name = "" self.status_text = "" self.segments: list[tuple[str, rl.Color, str | None, rl.Color | None]] | None = None - self._segment_labels: list[UnifiedLabel] = [] + self._segment_labels: list[UnifiedLabelSP] = [] self.downloading = False self.text_color = rl.GRAY self.icon: str | None = None @@ -185,8 +186,8 @@ class DownloadStatusAction(ItemAction): def _render_segments(self, rect: rl.Rectangle): measured = self._measured_segments() while len(self._segment_labels) < len(measured): - self._segment_labels.append(UnifiedLabel("", font_size=FONT_SIZE, max_width=SEGMENT_NAME_MAX_WIDTH, - scroll=True, wrap_text=False)) + 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: From 1cb58140f37a6d4a48c45bdab1a848e4f358178d Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Wed, 26 Aug 2026 04:37:11 -0400 Subject: [PATCH 34/38] [TIZI/TICI] ui: Model Status shows the big model failing over to small --- .../ui/sunnypilot/layouts/settings/models.py | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py index f44e8044aa..b63b77c2c3 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py @@ -155,8 +155,14 @@ class ModelsLayout(Widget): 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()) + note = "" + if self._big_model_state() == 'failed': + note = tr("Big model unavailable, the small model is driving") + self._set_item_note(self.download_item, note) return + self._set_item_note(self.download_item, "") + 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() @@ -169,19 +175,48 @@ class ModelsLayout(Widget): ds = custom.ModelManagerSP.DownloadStatus self._verifying = any(getattr(p.status, 'raw', p.status) == ds.verifying for p in progresses) + @staticmethod + def _big_model_state(): + """'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 _slot_segments(self): - """small and big slots side by side; the active side is green, an empty slot shows its default.""" + """small and big slots side by side; green marks the side actually driving, an empty slot shows its default.""" active = active_source() + big_state = self._big_model_state() 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 == active else rl.LIGHTGRAY + icon, icon_color = "icons/checkmark.png", None + if source == "usbgpu": + if big_state == 'failed': + color, icon, icon_color = rl.RED, "icons/close2.png", rl.RED + elif big_state == 'loading': + color = rl.GOLD segments.append((label, rl.GRAY, None, None)) - segments.append((name, ON_COLOR if source == active else rl.LIGHTGRAY, "icons/checkmark.png", None)) + segments.append((name, color, icon, icon_color)) 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 _queued_name(self, current_ref): ref = ui_state.params.get("ModelManager_DownloadRef") if ref and ref != current_ref: From 58ad198473d286c21bc47a06d7500722fafa9de9 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Wed, 26 Aug 2026 05:04:25 -0400 Subject: [PATCH 35/38] [TIZI/TICI] ui: stable model rows and a runner-matched failover note on Model Status --- .../ui/sunnypilot/layouts/settings/models.py | 78 +++++++++++-------- 1 file changed, 47 insertions(+), 31 deletions(-) diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py index b63b77c2c3..80904fa5b8 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py @@ -13,7 +13,7 @@ from openpilot.cereal import custom from openpilot.sunnypilot.models.helpers import ACTIVE_BUNDLE_KEYS, get_selected_bundle, resolve_bundle_by_ref from openpilot.common.constants import CV from openpilot.selfdrive.ui.ui_state import device, ui_state -from openpilot.selfdrive.ui.sunnypilot.model_info import active_source, bundles_for_source, default_model_name, model_info +from openpilot.selfdrive.ui.sunnypilot.model_info import active_source, bundles_for_source, default_model_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 @@ -40,6 +40,7 @@ class ModelsLayout(Widget): self._selection_source = None self._downloading = False self._verifying = False + self._last_note = None self.last_cache_calc_time = 0 self._initialize_items() @@ -51,17 +52,17 @@ class ModelsLayout(Widget): self._scroller = Scroller(self.items, line_separator=True, spacing=0) def _initialize_items(self): - self.current_model_item = ListItemSP( - title=tr("Active 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.other_model_item = ListItemSP( + self.big_model_item = ListItemSP( title=tr("Big Model"), action_item=ScrollingButtonAction(tr("SELECT")), - callback=self._handle_other_model_clicked + callback=lambda: self._open_source_dialog("usbgpu") ) self.download_item = download_status_item(lambda: tr("Download") if self._downloading else tr("Model Status")) @@ -105,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.other_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): @@ -155,14 +156,8 @@ class ModelsLayout(Widget): 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()) - note = "" - if self._big_model_state() == 'failed': - note = tr("Big model unavailable, the small model is driving") - self._set_item_note(self.download_item, note) return - self._set_item_note(self.download_item, "") - 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() @@ -217,6 +212,29 @@ class ModelsLayout(Widget): 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 = self._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) + def _queued_name(self, current_ref): ref = ui_state.params.get("ModelManager_DownloadRef") if ref and ref != current_ref: @@ -281,12 +299,6 @@ class ModelsLayout(Widget): 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): - self._open_source_dialog(active_source()) - - def _handle_other_model_clicked(self): - self._open_source_dialog("qcom" if active_source() == "usbgpu" else "usbgpu") - def _open_source_dialog(self, source): self._selection_source = source favs = ui_state.params.get("ModelManager_Favs") @@ -330,19 +342,23 @@ class ModelsLayout(Widget): self._update_lagd_description(live_delay) self.model_manager = ui_state.sm["modelManagerSP"] self._handle_bundle_download_progress() - source, active_name, other_name = model_info() - self.current_model_item.action_item.set_value(active_name) - self.other_model_item.set_title(tr("Big Model") if source == "qcom" else tr("Small Model")) - self.other_model_item.action_item.set_value(other_name) - if not ui_state.is_offroad(): - self.current_model_item.action_item.set_enabled(False) - self.other_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.other_model_item.action_item.set_enabled(True) - self.current_model_item.set_description("") + source = active_source() + 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.internalName if bundle else default_model_name(item_source) + color = ON_COLOR if item_source == source 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) From 82f4aa88c815dfaa740286b0da8a8f0a159770b4 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Wed, 26 Aug 2026 05:09:26 -0400 Subject: [PATCH 36/38] [TIZI/TICI] ui: model rows show full names and the failover note reopens with the page --- openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py index 80904fa5b8..8ada3e3f8d 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py @@ -346,7 +346,7 @@ class ModelsLayout(Widget): source = active_source() 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.internalName if bundle else default_model_name(item_source) + name = bundle.displayName if bundle else default_model_name(item_source) color = ON_COLOR if item_source == source else style.ITEM_TEXT_VALUE_COLOR item.action_item.set_value(name, color) @@ -365,3 +365,4 @@ class ModelsLayout(Widget): def show_event(self): self._scroller.show_event() + self._last_note = None # re-expand the failover note every time the page opens From 27cacee75a93f41230f8caa452754f5ad3a3c163 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Wed, 26 Aug 2026 05:20:01 -0400 Subject: [PATCH 37/38] ui: name the actually driving model runner-matched and bring mici to state parity --- .../ui/sunnypilot/layouts/settings/models.py | 37 +++++----------- .../ui/sunnypilot/mici/layouts/models.py | 34 +++++++++++--- .../selfdrive/ui/sunnypilot/model_info.py | 44 ++++++++++++++++++- 3 files changed, 82 insertions(+), 33 deletions(-) diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py index 8ada3e3f8d..0bbc05b96e 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py @@ -13,7 +13,7 @@ from openpilot.cereal import custom from openpilot.sunnypilot.models.helpers import ACTIVE_BUNDLE_KEYS, get_selected_bundle, resolve_bundle_by_ref from openpilot.common.constants import CV from openpilot.selfdrive.ui.ui_state import device, ui_state -from openpilot.selfdrive.ui.sunnypilot.model_info import active_source, bundles_for_source, default_model_name +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 @@ -163,34 +163,26 @@ class ModelsLayout(Widget): device._reset_interactive_timeout() state = self._download_row_state(progresses, bundle.internalName) - if queued := self._queued_name(bundle.ref): + 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) - @staticmethod - def _big_model_state(): - """'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 _slot_segments(self): - """small and big slots side by side; green marks the side actually driving, an empty slot shows its default.""" - active = active_source() - big_state = self._big_model_state() + """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 == active else rl.LIGHTGRAY + color = ON_COLOR if (source == carry_source and name == carry_internal) else rl.LIGHTGRAY icon, icon_color = "icons/checkmark.png", None if source == "usbgpu": if big_state == 'failed': @@ -222,7 +214,7 @@ class ModelsLayout(Widget): 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 = self._big_model_state() + 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) @@ -235,13 +227,6 @@ class ModelsLayout(Widget): 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) - def _queued_name(self, current_ref): - ref = ui_state.params.get("ModelManager_DownloadRef") - if ref and ref != current_ref: - if bundle := self._resolve_selected_bundle(ref): - return bundle.internalName - return None - @staticmethod def _download_row_state(progresses, name: str) -> dict: """Maps a bundle's artifact progress to DownloadStatusAction.update kwargs.""" @@ -343,11 +328,11 @@ class ModelsLayout(Widget): self.model_manager = ui_state.sm["modelManagerSP"] self._handle_bundle_download_progress() - source = active_source() + 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 == source else style.ITEM_TEXT_VALUE_COLOR + 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() diff --git a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py index 434cabe1ef..183b47fa58 100644 --- a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py @@ -8,10 +8,11 @@ import pyray as rl from openpilot.cereal import custom from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog -from openpilot.sunnypilot.models.helpers import ACTIVE_BUNDLE_KEYS +from openpilot.sunnypilot.models.helpers import ACTIVE_BUNDLE_KEYS, get_selected_bundle from openpilot.selfdrive.ui.mici.widgets.button import BigButton from openpilot.selfdrive.ui.ui_state import ui_state, device -from openpilot.selfdrive.ui.sunnypilot.model_info import bundles_for_source, default_model_name, model_info +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 @@ -19,10 +20,22 @@ 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, other-model header, other-model text) for the panel.""" + """(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_name.lower(), header, other_name.lower() + return active_text, header, other_name.lower() class CurrentModelInfo(Widget): @@ -99,8 +112,13 @@ class ModelsLayoutMici(NavScroller): self.focused_widget = self.select_model_btn hardware_btns = [] + active = active_source() for source, label in (("qcom", tr("small models")), ("usbgpu", tr("big models"))): - btn = BigButton(label.lower()) + 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) @@ -215,8 +233,12 @@ class ModelsLayoutMici(NavScroller): progress += 100.0 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}%") diff --git a/openpilot/selfdrive/ui/sunnypilot/model_info.py b/openpilot/selfdrive/ui/sunnypilot/model_info.py index 8348d68426..a93a06f187 100644 --- a/openpilot/selfdrive/ui/sunnypilot/model_info.py +++ b/openpilot/selfdrive/ui/sunnypilot/model_info.py @@ -6,7 +6,7 @@ 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 +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 @@ -30,6 +30,48 @@ 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) From ae4d48bd16bb8c98d177eeeafc3ad64eef3aa7a9 Mon Sep 17 00:00:00 2001 From: nayan Date: Wed, 26 Aug 2026 15:05:20 -0400 Subject: [PATCH 38/38] fix ugly --- .../selfdrive/ui/sunnypilot/layouts/settings/models.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py index 0bbc05b96e..3aa115139f 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py @@ -183,14 +183,14 @@ class ModelsLayout(Widget): 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 - icon, icon_color = "icons/checkmark.png", None + name = "● " + name if source == "usbgpu": if big_state == 'failed': - color, icon, icon_color = rl.RED, "icons/close2.png", rl.RED + color = rl.RED elif big_state == 'loading': color = rl.GOLD segments.append((label, rl.GRAY, None, None)) - segments.append((name, color, icon, icon_color)) + segments.append((name, color, None, None)) return segments @staticmethod