diff --git a/.github/workflows/build-all-tinygrad-models.yaml b/.github/workflows/build-all-tinygrad-models.yaml index 1ba407b3cf..7bddeab2bc 100644 --- a/.github/workflows/build-all-tinygrad-models.yaml +++ b/.github/workflows/build-all-tinygrad-models.yaml @@ -8,13 +8,13 @@ on: required: true type: string target_hardware: - description: 'Hardware target to compile for (qcom or usbgpu)' + description: 'Hardware target to compile for (qcom or chestnut)' required: true type: choice default: 'qcom' options: - qcom - - usbgpu + - chestnut hf_repo: description: 'Hugging Face dataset repository' required: false @@ -59,7 +59,7 @@ jobs: id: get-json run: | cd docs/docs - PREFIX="driving_models_${{ inputs.target_hardware == 'usbgpu' && 'usbgpu_' || '' }}v" + PREFIX="driving_models_${{ inputs.target_hardware == 'chestnut' && 'chestnut_' || '' }}v" latest=$(ls ${PREFIX}*.json | sed -E "s/${PREFIX}([0-9]+)\.json/\1/" | sort -n | tail -1) next=$((latest+1)) json_file="${PREFIX}${next}.json" diff --git a/.github/workflows/build-default-big-model.yaml b/.github/workflows/build-default-big-model.yaml deleted file mode 100644 index 4b05a7977d..0000000000 --- a/.github/workflows/build-default-big-model.yaml +++ /dev/null @@ -1,83 +0,0 @@ -name: Build default big model - -on: - workflow_dispatch: - -env: - HF_REPO: sunnypilot/sunnypilot_models_v1 - HF_DEFAULTS_PATH: models/defaults/big - -jobs: - resolve_name: - runs-on: ubuntu-24.04 - outputs: - model_name: ${{ steps.name.outputs.model_name }} - onnx_ref: ${{ steps.name.outputs.onnx_ref }} - steps: - - uses: actions/checkout@v4 - - id: name - run: | - NAME=$(PYTHONPATH=${{ github.workspace }} python3 -c "from openpilot.sunnypilot.models.model_name import DEFAULT_BIG_MODEL; print(DEFAULT_BIG_MODEL)") - ONNX_REF=$(git log -1 --format='%H' -- openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx) - echo "model_name=${NAME}" >> $GITHUB_OUTPUT - echo "onnx_ref=$ONNX_REF" >> $GITHUB_OUTPUT - - build_model: - needs: resolve_name - uses: ./.github/workflows/sunnypilot-build-model.yaml - with: - upstream_branch: ${{ needs.resolve_name.outputs.onnx_ref }} - custom_name: ${{ needs.resolve_name.outputs.model_name }} - target_hardware: usbgpu - secrets: inherit - - upload_defaults: - needs: [ resolve_name, build_model ] - runs-on: ubuntu-24.04 - permissions: - id-token: write - contents: write - steps: - - uses: actions/checkout@v4 - with: - submodules: recursive - - run: git lfs pull -I "openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx" - - - name: Install huggingface_hub - run: pip install --upgrade "huggingface_hub>=0.22.0" - - - name: Download artifact name - uses: actions/download-artifact@v4 - with: - name: artifact-name-${{ needs.resolve_name.outputs.model_name }} - path: artifact_name - - - name: Read artifact name - id: artifact - run: | - ARTIFACT_NAME=$(cat artifact_name/artifact_name.txt) - echo "artifact_name=$ARTIFACT_NAME" >> $GITHUB_OUTPUT - - - name: Download model artifact - uses: actions/download-artifact@v4 - with: - name: ${{ steps.artifact.outputs.artifact_name }} - path: output - - - name: Upload to HF and update default_models.json - env: - HF_OIDC_RESOURCE: datasets/${{ env.HF_REPO }} - ARTIFACT_NAME: ${{ steps.artifact.outputs.artifact_name }} - run: | - rm -f output/artifact_name.txt - export PYTHONPATH=$(pwd) - python3 release/ci/upload_default_model.py \ - --hf-repo "${{ env.HF_REPO }}" \ - --hf-defaults-path "${{ env.HF_DEFAULTS_PATH }}" \ - --artifact-name "$ARTIFACT_NAME" \ - --model-dir output \ - --onnx-path "openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx" \ - --onnx-ref "${{ needs.resolve_name.outputs.onnx_ref }}" \ - --model-name "${{ needs.resolve_name.outputs.model_name }}" \ - --tinygrad-ref "$(python3 openpilot/sunnypilot/models/tinygrad_ref.py)" \ - --run-number "${{ github.run_number }}" diff --git a/.github/workflows/build-default-models.yaml b/.github/workflows/build-default-models.yaml new file mode 100644 index 0000000000..f2cb007670 --- /dev/null +++ b/.github/workflows/build-default-models.yaml @@ -0,0 +1,504 @@ +name: Build default models + +on: + workflow_dispatch: + inputs: + target: + description: 'Model target to build' + required: true + type: choice + options: + - small + - big + - dm + workflow_call: + inputs: + target: + 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 + +jobs: + resolve: + runs-on: ubuntu-24.04 + outputs: + model_name: ${{ steps.resolve.outputs.model_name }} + safe_model_name: ${{ steps.resolve.outputs.safe_model_name }} + onnx_ref: ${{ steps.resolve.outputs.onnx_ref }} + onnx_path: ${{ steps.resolve.outputs.onnx_path }} + hf_defaults_path: ${{ steps.resolve.outputs.hf_defaults_path }} + tinygrad_ref: ${{ steps.resolve.outputs.tinygrad_ref }} + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - id: resolve + run: | + export PYTHONPATH=${{ github.workspace }} + + if [ "${{ inputs.target }}" = "big" ]; then + NAME=$(python3 -c "from openpilot.sunnypilot.models.model_name import DEFAULT_BIG_MODEL; print(DEFAULT_BIG_MODEL)") + ONNX_PATH="openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx" + HF_DEFAULTS_PATH="models/defaults/big" + 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" + fi + + ONNX_REF=$(git log -1 --format='%H' -- "$ONNX_PATH") + TINYGRAD_REF=$(python3 openpilot/sunnypilot/models/tinygrad_ref.py) + if [ -z "$TINYGRAD_REF" ]; then + echo "::error::Failed to resolve tinygrad ref" + exit 1 + fi + + SAFE_NAME="${NAME// /-}" + echo "model_name=${NAME}" >> $GITHUB_OUTPUT + echo "safe_model_name=${SAFE_NAME}" >> $GITHUB_OUTPUT + echo "onnx_ref=${ONNX_REF}" >> $GITHUB_OUTPUT + echo "onnx_path=${ONNX_PATH}" >> $GITHUB_OUTPUT + echo "hf_defaults_path=${HF_DEFAULTS_PATH}" >> $GITHUB_OUTPUT + echo "tinygrad_ref=${TINYGRAD_REF}" >> $GITHUB_OUTPUT + + build_small_model: + needs: resolve + 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.safe_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.safe_model_name }}-${{ github.run_number }} + path: ${{ github.workspace }}/small_output/ + + - name: Upload artifact name file + uses: actions/upload-artifact@v4 + with: + name: artifact-name-${{ needs.resolve.outputs.safe_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, chestnut] + 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.safe_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.safe_model_name }}-${{ github.run_number }} + path: ${{ github.workspace }}/big_output/ + + - name: Upload artifact name file + uses: actions/upload-artifact@v4 + with: + name: artifact-name-${{ needs.resolve.outputs.safe_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_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 == 'dm' && needs.build_dm_model.result == 'success') + }} + runs-on: ubuntu-24.04 + permissions: + id-token: write + contents: write + steps: + - uses: actions/checkout@v4 + + - name: Pull ONNX via LFS + 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 artifact name + if: ${{ inputs.target == 'small' || inputs.target == 'big' }} + uses: actions/download-artifact@v4 + with: + name: artifact-name-${{ needs.resolve.outputs.safe_model_name }} + path: 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 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 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 }} + run: | + rm -f output/artifact_name.txt + export PYTHONPATH=$(pwd) + python3 release/ci/upload_default_model.py \ + --hf-repo "${{ env.HF_REPO }}" \ + --hf-defaults-path "${{ needs.resolve.outputs.hf_defaults_path }}" \ + --artifact-name "$ARTIFACT_NAME" \ + --model-dir output \ + --onnx-path "${{ needs.resolve.outputs.onnx_path }}" \ + --onnx-ref "${{ needs.resolve.outputs.onnx_ref }}" \ + --model-name "${{ needs.resolve.outputs.model_name }}" \ + --tinygrad-ref "${{ needs.resolve.outputs.tinygrad_ref }}" \ + --run-number "${{ github.run_number }}" + + - name: Download DM artifact + if: ${{ inputs.target == '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 == 'dm' }} + env: + HF_OIDC_RESOURCE: datasets/${{ env.HF_REPO }} + run: | + export PYTHONPATH=$(pwd) + python3 -c " + import json, hashlib + from pathlib import Path + from datetime import datetime, UTC + + dm_dir = Path('dm_output') + manifest = list(dm_dir.glob('*.chunkmanifest')) + assert manifest, 'No chunkmanifest found' + pkl_name = manifest[0].name.removesuffix('.chunkmanifest') + num_chunks = int(manifest[0].read_text().strip()) + + chunks = [] + for i in range(num_chunks): + chunk = dm_dir / f'{pkl_name}.chunk{i+1:02d}of{num_chunks:02d}' + chunks.append({ + 'file_name': chunk.name, + 'sha256': hashlib.sha256(chunk.read_bytes()).hexdigest() + }) + + digest = hashlib.sha256() + for c in chunks: + with open(dm_dir / c['file_name'], 'rb') as f: + while block := f.read(1024*1024): + digest.update(block) + + metadata = { + 'bundles': [{ + 'short_name': 'DMMODEL', + 'display_name': '${{ 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': [{ + 'type': 'chunked', + 'artifact': { + 'file_name': pkl_name, + 'download_uri': {'url': '', 'sha256': digest.hexdigest()}, + 'chunks': chunks + } + }] + }] + } + with open(dm_dir / 'metadata.json', 'w') as f: + json.dump(metadata, f, indent=2) + print('Generated DM metadata.json') + " + + python3 release/ci/upload_default_model.py \ + --hf-repo "${{ env.HF_REPO }}" \ + --hf-defaults-path "${{ needs.resolve.outputs.hf_defaults_path }}" \ + --artifact-name "dm-model-${{ github.run_number }}" \ + --model-dir dm_output \ + --onnx-path "${{ 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 == 'dm' }} + runs-on: [self-hosted, tici] + env: + DM_ONNX: openpilot/selfdrive/modeld/models/dmonitoring_model.onnx + DM_PKL: openpilot/selfdrive/modeld/models/dmonitoring_model_tinygrad.pkl + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Pull DM ONNX via LFS + run: git lfs pull -I "${{ env.DM_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 DM model + run: | + source ${UV_PROJECT_ENVIRONMENT}/bin/activate + export PYTHONPATH="${PYTHONPATH}:${{ github.workspace }}/tinygrad_repo:${{ github.workspace }}" + + TG_FLAGS="DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1" + + taskset -c 7 env ${TG_FLAGS} python3 \ + ${{ github.workspace }}/tinygrad_repo/examples/openpilot/compile3.py \ + ${{ github.workspace }}/${{ env.DM_ONNX }} \ + ${{ github.workspace }}/${{ env.DM_PKL }} + + - name: Chunk DM pkl + run: | + source ${UV_PROJECT_ENVIRONMENT}/bin/activate + export PYTHONPATH=${{ github.workspace }} + python3 -c " + from openpilot.common.file_chunker import chunk_file, get_chunk_targets + import os + pkl = '${{ github.workspace }}/${{ env.DM_PKL }}' + size = os.path.getsize(pkl) + targets = get_chunk_targets(pkl, size) + chunk_file(pkl, targets) + print(f'Chunked {pkl} into {len(targets)} chunks') + " + + - name: Prepare DM output + run: | + mkdir -p dm_output + cp ${{ github.workspace }}/${{ env.DM_PKL }}.chunk* dm_output/ + cp ${{ github.workspace }}/${{ env.DM_PKL }}.chunkmanifest dm_output/ + + - name: Upload DM artifact + uses: actions/upload-artifact@v4 + with: + name: dm-model-${{ github.run_number }} + path: dm_output/ + + - name: Re-enable powersave + if: always() + run: | + source ${UV_PROJECT_ENVIRONMENT}/bin/activate + PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --enable + diff --git a/.github/workflows/build-single-tinygrad-model.yaml b/.github/workflows/build-single-tinygrad-model.yaml index 1ad06a54e4..c9d495c15f 100644 --- a/.github/workflows/build-single-tinygrad-model.yaml +++ b/.github/workflows/build-single-tinygrad-model.yaml @@ -30,7 +30,7 @@ on: type: boolean default: true target_hardware: - description: 'Hardware target to compile for (qcom or usbgpu)' + description: 'Hardware target to compile for (qcom or chestnut)' required: false type: string default: 'qcom' @@ -101,7 +101,7 @@ on: default: 'qcom' options: - qcom - - usbgpu + - chestnut hf_repo: description: 'Hugging Face dataset repository' required: false @@ -109,7 +109,7 @@ on: default: 'sunnypilot/sunnypilot_models_v1' env: RECOMPILED_DIR: recompiled${{ inputs.recompiled_dir }} - JSON_FILE: docs/docs/driving_models_${{ inputs.target_hardware == 'usbgpu' && 'usbgpu_v' || 'v' }}${{ inputs.json_version }}.json + JSON_FILE: docs/docs/driving_models_${{ inputs.target_hardware == 'chestnut' && 'chestnut_v' || 'v' }}${{ inputs.json_version }}.json jobs: build_model: 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-model.yaml b/.github/workflows/sunnypilot-build-model.yaml index bc132ae1bf..10be63ee22 100644 --- a/.github/workflows/sunnypilot-build-model.yaml +++ b/.github/workflows/sunnypilot-build-model.yaml @@ -31,7 +31,7 @@ on: type: string default: '' target_hardware: - description: 'Hardware target to compile for (qcom or usbgpu)' + description: 'Hardware target to compile for (qcom or chestnut)' required: false type: string default: 'qcom' @@ -57,7 +57,7 @@ on: type: choice options: - qcom - - usbgpu + - chestnut default: 'qcom' @@ -102,7 +102,7 @@ jobs: cat $GITHUB_OUTPUT - run: | cd ${{ github.workspace }}/openpilot/openpilot - if [ "${{ inputs.target_hardware }}" != "usbgpu" ]; then + if [ "${{ inputs.target_hardware }}" != "chestnut" ]; then git lfs pull -X "**/selfdrive/modeld/models/big_*.onnx,**/selfdrive/modeld/models/dmonitoring_*.onnx" rm -f selfdrive/modeld/models/big_*.onnx selfdrive/modeld/models/dmonitoring_*.onnx else @@ -121,7 +121,7 @@ jobs: if-no-files-found: error build_model: - runs-on: [self-hosted, usbgpu] + runs-on: [self-hosted, "${{ inputs.target_hardware == 'chestnut' && 'chestnut' || 'tici' }}"] needs: get_model env: MODEL_NAME: ${{ inputs.custom_name || inputs.upstream_branch }} (${{ needs.get_model.outputs.model_date }}) @@ -185,10 +185,10 @@ jobs: 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}')") TG_FLAGS_QCOM="DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1" - 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" + if [ "${{ inputs.target_hardware }}" == "chestnut" ]; then + echo "CHESTNUT build" + export CHESTNUT=1 + 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/.github/workflows/sunnypilot-build-prebuilt.yaml b/.github/workflows/sunnypilot-build-prebuilt.yaml index 84fe6b3cc1..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 }} @@ -165,7 +170,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} @@ -214,59 +219,174 @@ 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" - else - echo "No matching model on HF — triggering build" - gh workflow run build-default-big-model.yaml --ref "${{ github.head_ref || github.ref_name }}" - - echo "Waiting for build to start..." - sleep 120 - - RUN_ID=$(gh run list --workflow=build-default-big-model.yaml --branch="${{ github.head_ref || github.ref_name }}" --limit=1 --json databaseId --jq '.[0].databaseId') - if [ -z "$RUN_ID" ] || [ "$RUN_ID" = "null" ]; then - echo "::error::Failed to find build-default-big-model 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-big-model failed: $CONCLUSION" - exit 1 - fi - - if ! check_hash; then - echo "::error::HF defaults still don't match after build" - exit 1 - fi + 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 "$REF" -f target=big + + echo "Polling HF for big model availability..." + for i in $(seq 1 90); do + sleep 30 + if check_defaults; 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 }} + + - name: Cancel run on failure + if: failure() + run: gh run cancel ${{ github.run_id }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + prepare_small_model: + needs: [ prepare_strategy ] + runs-on: ubuntu-24.04 + 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: + - name: Resolve ONNX hash and tinygrad ref via API + id: resolve + run: | + 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" + + 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 + } + + 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 "$REF" -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: + GH_REPO: ${{ github.repository }} + HF_REPO: sunnypilot/sunnypilot_models_v1 + HF_DEFAULTS_PATH: models/defaults/dm + steps: + - name: Resolve ONNX hash and tinygrad ref via API + id: resolve + run: | + 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" + + 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 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 "$REF" -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 }} @@ -278,23 +398,24 @@ jobs: 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_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 ] + 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 + with: + fetch-depth: 1 - name: Download prebuilt artifact uses: actions/download-artifact@v4 @@ -306,43 +427,16 @@ jobs: mkdir -p ${{ env.OUTPUT_DIR }} tar xzf prebuilt.tar.gz -C ${{ env.OUTPUT_DIR }} - - 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 big model into chestnut - if: ${{ needs.prepare_chestnut.result == 'success' }} - run: | - cp big_model_chunks/* "${{ github.workspace }}/chestnut_output/openpilot/selfdrive/modeld/models/" + - 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: | @@ -364,22 +458,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: | @@ -387,12 +465,77 @@ 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 + environment: ${{ needs.prepare_strategy.outputs.environment }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - 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 runs-on: ubuntu-24.04 if: ${{ (always() && !cancelled() && !failure()) && needs.publish.result == 'success' @@ -400,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/launch_env.sh b/launch_env.sh index 094622005a..4fcb243f12 100755 --- a/launch_env.sh +++ b/launch_env.sh @@ -16,7 +16,7 @@ export VECLIB_MAXIMUM_THREADS=1 export QCOM_PRIORITY=12 if [ -z "$AGNOS_VERSION" ]; then - export AGNOS_VERSION="19.6" + export AGNOS_VERSION="19.7" fi export STAGING_ROOT="/data/safe_staging" diff --git a/openpilot/cereal/custom.capnp b/openpilot/cereal/custom.capnp index c20bf923be..cfbbbe472a 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 { @@ -352,6 +353,7 @@ struct OnroadEventSP @0xda96579883444c35 { speedLimitPending @22; e2eChime @23; laneChangeRoadEdge @24; + bigModelReady @25; } } diff --git a/openpilot/cereal/log.capnp b/openpilot/cereal/log.capnp index b87499806f..5cee3266e0 100644 --- a/openpilot/cereal/log.capnp +++ b/openpilot/cereal/log.capnp @@ -725,6 +725,7 @@ struct ChestnutState { pcieLtssm @7 :UInt8; supplyVoltage @8 :UInt16; # mV supplyCurrent @9 :Int16; # mA + supplyFault @10 :Bool; } struct RadarState @0x9a185389d6fdd05f { @@ -1004,6 +1005,7 @@ struct DrivingModelData { frameIdExtra @1 :UInt32; frameDropPerc @6 :Float32; modelExecutionTime @7 :Float32; + big @8 :Bool; action @2 :ModelDataV2.Action; diff --git a/openpilot/common/hardware/comma/agnos.json b/openpilot/common/hardware/comma/agnos.json index ae1cbbcd38..4a48016ed2 100644 --- a/openpilot/common/hardware/comma/agnos.json +++ b/openpilot/common/hardware/comma/agnos.json @@ -56,29 +56,29 @@ }, { "name": "boot", - "url": "https://commadist.azureedge.net/agnosupdate/boot-b30f5eef65ec3878f3aa3dcaf2cc95c09e2c1e661cd3a38e94da37dee76f68bd.img.xz", - "hash": "b30f5eef65ec3878f3aa3dcaf2cc95c09e2c1e661cd3a38e94da37dee76f68bd", - "hash_raw": "b30f5eef65ec3878f3aa3dcaf2cc95c09e2c1e661cd3a38e94da37dee76f68bd", + "url": "https://commadist.azureedge.net/agnosupdate/boot-6ecf6f987cd11968104abcccabbe268485d329cdb73012dfd3c381a6b8deb27d.img.xz", + "hash": "6ecf6f987cd11968104abcccabbe268485d329cdb73012dfd3c381a6b8deb27d", + "hash_raw": "6ecf6f987cd11968104abcccabbe268485d329cdb73012dfd3c381a6b8deb27d", "size": 46897152, "sparse": false, "full_check": true, "has_ab": true, - "ondevice_hash": "6650e4c46df99ae6dfd6ee895a34b8a2a3cc490a8ce18e16cc3c451c3f822b6e" + "ondevice_hash": "d12e1e5b9455b62a1464558716493b33e470d7a7e88da1c4105a3b21d0961808" }, { "name": "system", - "url": "https://commadist.azureedge.net/agnosupdate/system-5b6ce7965904a157fd3a134ccfcb854f9ca5c1cc2a26b7cb80a4fa4e1cc4aaa3.img.xz", - "hash": "b134fd04e9da27fa1d359ea0f2742c216fa21a08b5c47e9be22ab3b0563d9b9b", - "hash_raw": "5b6ce7965904a157fd3a134ccfcb854f9ca5c1cc2a26b7cb80a4fa4e1cc4aaa3", + "url": "https://commadist.azureedge.net/agnosupdate/system-3c271e2b3d20d2f0a8bf6555a1319f3efb12845490967d6151195174a01e912f.img.xz", + "hash": "74ffc9c551e1f29cda897ace8a69080fe644f8039977c6885f2b48362e39b744", + "hash_raw": "3c271e2b3d20d2f0a8bf6555a1319f3efb12845490967d6151195174a01e912f", "size": 4718592000, "sparse": true, "full_check": false, "has_ab": true, - "ondevice_hash": "91242772af771ae96fe2eebc105f2b80a7e1dbaaf6003c2574b62d51b806f468", + "ondevice_hash": "6a992680183685eea9db99d915219a37935f45989330d9b619e880450257f448", "alt": { - "hash": "5b6ce7965904a157fd3a134ccfcb854f9ca5c1cc2a26b7cb80a4fa4e1cc4aaa3", - "url": "https://commadist.azureedge.net/agnosupdate/system-5b6ce7965904a157fd3a134ccfcb854f9ca5c1cc2a26b7cb80a4fa4e1cc4aaa3.img", + "hash": "3c271e2b3d20d2f0a8bf6555a1319f3efb12845490967d6151195174a01e912f", + "url": "https://commadist.azureedge.net/agnosupdate/system-3c271e2b3d20d2f0a8bf6555a1319f3efb12845490967d6151195174a01e912f.img", "size": 4718592000 } } -] +] \ No newline at end of file diff --git a/openpilot/common/hardware/comma/modem.py b/openpilot/common/hardware/comma/modem.py index 171ed8c0e4..901efae2b1 100755 --- a/openpilot/common/hardware/comma/modem.py +++ b/openpilot/common/hardware/comma/modem.py @@ -5,6 +5,7 @@ import logging import os import select import signal +import string import struct import subprocess import tempfile @@ -354,7 +355,7 @@ class Modem: imei = "" iccid = (self._atv("AT+QCCID", "+QCCID:") or "").rstrip("F") - if not iccid.isdigit(): + if not all(c in string.hexdigits for c in iccid): iccid = "" imsi = first_line("AT+CIMI") diff --git a/openpilot/common/hardware/usb.py b/openpilot/common/hardware/usb.py index b9f6db2757..c3c2050b6b 100644 --- a/openpilot/common/hardware/usb.py +++ b/openpilot/common/hardware/usb.py @@ -4,11 +4,17 @@ from pathlib import Path CHESTNUT_FW_VERSION = "ed4e39b7" CHESTNUT_USB_IDS = ((0xADD1, 0x0001), (0x3801, 0x0001)) CHESTNUT_ROM_USB_IDS = ((0x174C, 0x2464), (0x174C, 0x2463)) +CHESTNUT_USB_PRODUCT = f"custom {CHESTNUT_FW_VERSION}-CLEAN" USB_DEVICES_PATH = Path("/sys/bus/usb/devices") TYPEC_CC_ORIENTATION_PATH = Path("/sys/class/power_supply/usb/typec_cc_orientation") PRIMARY_USB_CONTROLLER = "a600000.ssusb" +def is_chestnut_usb_id(vendor_id: int, product_id: int, include_bootloader: bool = False) -> bool: + ids = CHESTNUT_USB_IDS + CHESTNUT_ROM_USB_IDS if include_bootloader else CHESTNUT_USB_IDS + return (vendor_id, product_id) in ids + + def get_usb_topology() -> set[str]: try: return set(os.listdir(USB_DEVICES_PATH)) @@ -81,7 +87,7 @@ def set_usb_state(device_state, devices: list[dict]) -> None: entry.linkErrorCount = device["linkErrorCount"] entry.usb3Lane = device.get("usb3Lane", "unknown") - if (entry.vendorId, entry.productId) in CHESTNUT_USB_IDS: + if is_chestnut_usb_id(entry.vendorId, entry.productId): chestnut_present = True device_state.chestnutPresent = chestnut_present diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index 01c14fb539..daeb928a84 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -92,6 +92,12 @@ inline static std::unordered_map keys = { {"ObdMultiplexingEnabled", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}}, {"Offroad_CarUnrecognized", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}}, {"Offroad_ChestnutBranch", {CLEAR_ON_MANAGER_START, JSON}}, + {"Offroad_ChestnutNotDetected", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}}, + {"Offroad_ChestnutOverheated", {CLEAR_ON_MANAGER_START, JSON}}, + {"Offroad_ChestnutPcieUnavailable", {CLEAR_ON_MANAGER_START, JSON}}, + {"Offroad_ChestnutUncompiled", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}}, + {"Offroad_ChestnutUpdateFailed", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}}, + {"Offroad_ChestnutUsbSlow", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}}, {"Offroad_ConnectivityNeeded", {CLEAR_ON_MANAGER_START, JSON}}, {"Offroad_ConnectivityNeededPrompt", {CLEAR_ON_MANAGER_START, JSON}}, {"Offroad_ExcessiveActuation", {PERSISTENT, JSON}}, @@ -130,8 +136,9 @@ inline static std::unordered_map keys = { {"UpdaterLastFetchTime", {PERSISTENT, TIME}}, {"UptimeOffroad", {PERSISTENT, FLOAT, "0.0"}}, {"UptimeOnroad", {PERSISTENT, FLOAT, "0.0"}}, - {"UsbGpuActive", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}}, - {"UsbGpuLoading", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}}, + {"ChestnutActive", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}}, + {"ChestnutLoading", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}}, + {"ChestnutModelError", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}}, {"Version", {PERSISTENT, STRING}}, // --- sunnypilot params --- // @@ -195,14 +202,16 @@ inline static std::unordered_map keys = { // Model Manager params {"ModelManager_ActiveBundle", {PERSISTENT, JSON}}, - {"ModelManager_ActiveJson", {CLEAR_ON_MANAGER_START, STRING}}, + {"ModelManager_ActiveBundleUSBGPU", {PERSISTENT, JSON}}, //TODO-SP: kept for migration, remove on next sync? + {"ModelManager_ActiveBundleChestnut", {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"}}, + {"ModelManager_LastSyncTime_Chestnut", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, INT, "0"}}, {"ModelManager_ModelsCache", {PERSISTENT | BACKUP, JSON}}, - {"ModelManager_ModelsCache_USBGPU", {PERSISTENT | BACKUP, JSON}}, + {"ModelManager_ModelsCache_Chestnut", {PERSISTENT | BACKUP, JSON}}, // Neural Network Lateral Control {"NeuralNetworkLateralControl", {PERSISTENT | BACKUP, BOOL, "0"}}, @@ -245,6 +254,7 @@ inline static std::unordered_map keys = { // mapd {"MapAdvisorySpeedLimit", {CLEAR_ON_ONROAD_TRANSITION, FLOAT}}, + {"Mapd_ClearCache", {CLEAR_ON_MANAGER_START, BOOL}}, {"MapdVersion", {PERSISTENT, STRING}}, {"MapSpeedLimit", {CLEAR_ON_ONROAD_TRANSITION, FLOAT, "0.0"}}, {"NextMapSpeedLimit", {CLEAR_ON_ONROAD_TRANSITION, JSON}}, diff --git a/openpilot/common/prefix.h b/openpilot/common/prefix.h index 89f346b9e5..0f2c592527 100644 --- a/openpilot/common/prefix.h +++ b/openpilot/common/prefix.h @@ -27,14 +27,14 @@ public: auto param_path = Params().getParamPath(); if (util::file_exists(param_path)) { std::string real_path = util::readlink(param_path); - util::check_system(util::string_format("rm %s -rf", real_path.c_str())); + util::check_system(util::string_format("rm -rf %s", real_path.c_str())); unlink(param_path.c_str()); } if (getenv("COMMA_CACHE") == nullptr) { - util::check_system(util::string_format("rm %s -rf", Path::download_cache_root().c_str())); + util::check_system(util::string_format("rm -rf %s", Path::download_cache_root().c_str())); } - util::check_system(util::string_format("rm %s -rf", Path::comma_home().c_str())); - util::check_system(util::string_format("rm %s -rf", msgq_path.c_str())); + util::check_system(util::string_format("rm -rf %s", Path::comma_home().c_str())); + util::check_system(util::string_format("rm -rf %s", msgq_path.c_str())); unsetenv("OPENPILOT_PREFIX"); } diff --git a/openpilot/selfdrive/assets/icons_mici/egpu.png b/openpilot/selfdrive/assets/icons_mici/chestnut.png similarity index 100% rename from openpilot/selfdrive/assets/icons_mici/egpu.png rename to openpilot/selfdrive/assets/icons_mici/chestnut.png diff --git a/openpilot/selfdrive/assets/icons_mici/egpu_green.png b/openpilot/selfdrive/assets/icons_mici/chestnut_green.png similarity index 100% rename from openpilot/selfdrive/assets/icons_mici/egpu_green.png rename to openpilot/selfdrive/assets/icons_mici/chestnut_green.png diff --git a/openpilot/selfdrive/assets/icons_mici/chestnut_orange.png b/openpilot/selfdrive/assets/icons_mici/chestnut_orange.png new file mode 100644 index 0000000000..d3982abf87 --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/chestnut_orange.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:845c40ff0d37612e8f2f482a36845744b5ae91ce2fcfc8117990d7d278b59820 +size 13079 diff --git a/openpilot/selfdrive/assets/icons_mici/egpu_crossed.png b/openpilot/selfdrive/assets/icons_mici/egpu_crossed.png deleted file mode 100644 index 4fc5decb59..0000000000 --- a/openpilot/selfdrive/assets/icons_mici/egpu_crossed.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8a8c5fece2a1c7587feb41cbe04c6aee08e768ecd9b5d00da6af9832a4ccc842 -size 2034 diff --git a/openpilot/selfdrive/assets/icons_mici/egpu_gray.png b/openpilot/selfdrive/assets/icons_mici/egpu_gray.png deleted file mode 100644 index a6aeb84689..0000000000 --- a/openpilot/selfdrive/assets/icons_mici/egpu_gray.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7409c53d7c72681c24982fd83b56ce70f80797c9c0f936d9296a5c18557ac472 -size 7279 diff --git a/openpilot/selfdrive/assets/icons_mici/egpu_orange.png b/openpilot/selfdrive/assets/icons_mici/egpu_orange.png deleted file mode 100644 index c2fc06e34e..0000000000 --- a/openpilot/selfdrive/assets/icons_mici/egpu_orange.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:58bd6155433f623b1f75d134bd8ca4745d9aa71f6767eb807cdbcf7deb3089a1 -size 10876 diff --git a/openpilot/selfdrive/assets/icons_mici/usb.png b/openpilot/selfdrive/assets/icons_mici/usb.png new file mode 100644 index 0000000000..2f3afb0de9 --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/usb.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:07bda2fe5d6be0b2854044053c384fe002e96406da119863a443b9344258b500 +size 1544 diff --git a/openpilot/selfdrive/modeld/SConscript b/openpilot/selfdrive/modeld/SConscript index 30a31aae27..d19afd21dd 100644 --- a/openpilot/selfdrive/modeld/SConscript +++ b/openpilot/selfdrive/modeld/SConscript @@ -7,14 +7,9 @@ from openpilot.common.file_chunker import chunk_file, get_chunk_targets, get_exi from openpilot.common.transformations.camera import _ar_ox_fisheye, _os_fisheye from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE, DM_INPUT_SIZE from openpilot.selfdrive.modeld.constants import ModelConstants -from openpilot.selfdrive.modeld.helpers import TG_INPUT_DEVICES_PATH, usbgpu_present, modeld_pkl_path +from openpilot.selfdrive.modeld.helpers import TG_INPUT_DEVICES_PATH, chestnut_present, modeld_pkl_path -CAMERA_CONFIGS = [ - (_ar_ox_fisheye.width, _ar_ox_fisheye.height), # tici: 1928x1208 - (_os_fisheye.width, _os_fisheye.height), # mici: 1344x760 -] - Import('env', 'arch') chunker_file = File("#openpilot/common/file_chunker.py") lenv = env.Clone() @@ -24,30 +19,32 @@ tinygrad_files = ["#"+x for x in glob.glob(env.Dir("#tinygrad_repo").relpath + " if 'pycache' not in x and os.path.isfile(os.path.join(tinygrad_root, x))] def estimate_pickle_max_size(onnx_size): - return 1.2 * onnx_size + 10 * 1024 * 1024 # 20% + 10MB is plenty + # QCOM programs for models with spatial recurrent features can approach 2x + # the ONNX size. Overestimating only adds an empty trailing chunk. + return 2.0 * onnx_size + 10 * 1024 * 1024 if arch == 'comma_arm64': + from openpilot.common.hardware import HARDWARE + camera = _os_fisheye if HARDWARE.get_device_type() == "mici" else _ar_ox_fisheye + camera_configs = [(camera.width, camera.height)] tg_backend = 'QCOM' tg_flags = f'DEV={tg_backend} IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1' else: + camera_configs = [(c.width, c.height) for c in (_ar_ox_fisheye, _os_fisheye)] tg_backend = 'CPU' tg_flags = f'DEV=CPU' if arch == 'Darwin' else 'DEV=CPU:LLVM' tg_devices = { # which device to put jit inputs to at runtime - 'openpilot.selfdrive.modeld.modeld': { - 'default': {'WARP_DEV': tg_backend, 'QUEUE_DEV': tg_backend}, - 'usbgpu': {'WARP_DEV': tg_backend, 'QUEUE_DEV': 'AMD'} - }, 'openpilot.selfdrive.modeld.dmonitoringmodeld': { 'default': {'DEV': tg_backend} }, } -USBGPU = usbgpu_present() -if USBGPU: - usbgpu_tg_flags = f'DEBUG=2 DEV=USB+AMD:LLVM WARP_DEV={tg_backend} FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2' +CHESTNUT = chestnut_present() +if CHESTNUT: + chestnut_tg_flags = 'DEBUG=1 DEV=USB+AMD:LLVM FRAME_DEV=CPU FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2 TC_OCCUPANCY_OPT=1' # the USB+AMD GPU takes an exclusive flock; serialize all targets that touch it - usbgpu_lock = File("models/.usb_gpu.lock").abspath + chestnut_lock = File("models/.chestnut.lock").abspath def write_tg_devices(target, source, env): with open(str(target[0]), "w") as f: @@ -73,44 +70,44 @@ 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 chestnut in [False, True] if CHESTNUT else [False]: + target_pkl_path = File(modeld_pkl_path(chestnut)).abspath + file_prefix, cmd_flags = ('big_', chestnut_tg_flags) if chestnut else ('', tg_flags) + driving_onnx_deps = get_existing_chunks(File(f"models/{file_prefix}driving_supercombo.onnx").abspath) + camera_res_args = ' '.join(f'{cw}x{ch}' for cw, ch in camera_configs) + # 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, " [CHESTNUT] $TARGET") if chestnut else [cmd, Action(do_chunk, " [CHUNK] $TARGET")] + node = lenv.Command( + chunk_targets, + tinygrad_files + compile_modeld_script + driving_onnx_deps + [Value(camera_res_args), Value(chunk_targets), chunker_file], + actions, + ) + if chestnut: + lenv.SideEffect(chestnut_lock, node) # get model metadata fn = File(f"models/dmonitoring_model").abspath @@ -120,7 +117,7 @@ lenv.Command(fn + "_metadata.pkl", [fn + ".onnx"] + tinygrad_files + script_file dm_w, dm_h = DM_INPUT_SIZE compile_dm_warp_script = [File(f"{modeld_dir}/compile_dm_warp.py")] -for cam_w, cam_h in CAMERA_CONFIGS: +for cam_w, cam_h in camera_configs: dm_pkl_path = File(f"models/dm_warp_{cam_w}x{cam_h}_tinygrad.pkl").abspath cmd = (f'{tg_flags} {mac_brew_string} python3 {modeld_dir}/compile_dm_warp.py ' f'--camera-resolution {cam_w}x{cam_h} --warp-to {dm_w}x{dm_h} ' diff --git a/openpilot/selfdrive/modeld/compile_modeld.py b/openpilot/selfdrive/modeld/compile_modeld.py index 2d27a41496..d851c3cc86 100755 --- a/openpilot/selfdrive/modeld/compile_modeld.py +++ b/openpilot/selfdrive/modeld/compile_modeld.py @@ -37,17 +37,12 @@ from tinygrad.engine.jit import TinyJit NV12Frame = namedtuple("NV12Frame", ['width', 'height', 'stride', 'y_height', 'uv_height', 'size']) -WARP_INPUTS = ['tfm', 'big_tfm'] -POLICY_INPUTS = ['img_q', 'big_img_q', 'feat_q', 'desire_q', 'packed_npy_inputs'] - -UV_SCALE_MATRIX = np.array([[0.5, 0, 0], [0, 0.5, 0], [0, 0, 1]], dtype=np.float32) -UV_SCALE_MATRIX_INV = np.linalg.inv(UV_SCALE_MATRIX) - -WARP_DEV = os.getenv('WARP_DEV') +MODELD_INPUTS = ['img_q', 'big_img_q', 'feat_q', 'desire_q', 'packed_npy_inputs'] -def make_random_images(keys, shape, device=None): - return {k: Tensor.randint(shape, low=0, high=256, dtype='uint8', device=device).realize() for k in keys} +def nv12_copy_size(stride: int, y_height: int, uv_height: int) -> int: + # Retain the padded Y and UV plane storage, but skip the trailing kernel/guard allocation. + return stride * (y_height + uv_height) def warp_perspective_tinygrad(src_flat, M_inv, dst_shape, src_shape, stride_pad, border_fill_val=None): @@ -99,7 +94,7 @@ def make_frame_prepare(nv12: NV12Frame, model_w, model_h): def frame_prepare_tinygrad(input_frame, M_inv): # UV_SCALE @ M_inv @ UV_SCALE_INV simplifies to elementwise scaling - M_inv_uv = M_inv * Tensor([[1.0, 1.0, 0.5], [1.0, 1.0, 0.5], [2.0, 2.0, 1.0]], device=WARP_DEV) + M_inv_uv = M_inv * Tensor([[1.0, 1.0, 0.5], [1.0, 1.0, 0.5], [2.0, 2.0, 1.0]], device=Device.DEFAULT) # deinterleave NV12 UV plane (UVUV... -> separate U, V) uv = input_frame[uv_offset:uv_offset + uv_height * stride].reshape(uv_height, stride) with Context(SPLIT_REDUCEOP=0): @@ -118,49 +113,43 @@ def make_frame_prepare(nv12: NV12Frame, model_w, model_h): return frame_prepare_tinygrad -def make_warp_input_queues(vision_input_shapes, frame_skip, device): - img = vision_input_shapes['img'] # (1, 12, 128, 256) - n_frames = img[1] // 6 - img_buf_shape = (frame_skip * (n_frames - 1) + 1, 6, img[2], img[3]) - - npy = { - 'tfm': np.zeros((3, 3), dtype=np.float32), - 'big_tfm': np.zeros((3, 3), dtype=np.float32), - } - input_queues = { - 'img_q': Tensor(np.zeros(img_buf_shape, dtype=np.uint8), device=device).contiguous().realize(), - 'big_img_q': Tensor(np.zeros(img_buf_shape, dtype=np.uint8), device=device).contiguous().realize(), - **{k: Tensor(v, device='NPY').realize() for k, v in npy.items()}, - } - return input_queues, npy - - def get_policy_npy_shapes(input_shapes): dp = input_shapes['desire_pulse'] # (1, 25, 8) tc = input_shapes['traffic_convention'] # (1, 2) at = input_shapes['action_t'] # (1, 2) - fb = input_shapes['features_buffer'] # (1, 24, 512) + fb = input_shapes['features_buffer'] # (1, T-1, ...) e.g. (1, 24, 32, 512) with spatial features + feat_dim = math.prod(fb[2:]) # TODO prev_feat shouldn't exist and be handled inside the JIT, but corrupt on QCOM for now - shapes = {'desire': (dp[2],), 'traffic_convention': tuple(tc), 'action_t': tuple(at), 'prev_feat': (fb[0], fb[2])} + shapes = {'desire': (dp[2],), 'traffic_convention': tuple(tc), 'action_t': tuple(at), 'prev_feat': (fb[0], feat_dim)} return shapes, [math.prod(s) for s in shapes.values()] -def make_input_queues(input_shapes, frame_skip, device): - input_queues, npy = make_warp_input_queues(input_shapes, frame_skip, device) - - fb = input_shapes['features_buffer'] # (1, 24, 512), past features only; the model appends the current frame's feature +def make_input_queues(input_shapes, frame_skip, device, frame_copy_size): + img = input_shapes['img'] # (1, 12, 128, 256) + fb = input_shapes['features_buffer'] # (1, T-1, ...), past features only; the model appends the current frame's feature + feat_dim = math.prod(fb[2:]) dp = input_shapes['desire_pulse'] # (1, 25, 8) + n_frames = img[1] // 6 + img_buf_shape = (frame_skip * (n_frames - 1) + 1, 6, img[2], img[3]) - shapes, sizes = get_policy_npy_shapes(input_shapes) - packed_npy_inputs = np.zeros(sum(sizes), dtype=np.float32) + policy_shapes, _ = get_policy_npy_shapes(input_shapes) + shapes = {'tfm': (3, 3), 'big_tfm': (3, 3)} | policy_shapes + sizes = [math.prod(s) for s in shapes.values()] + packed_npy_size = sum(sizes) * np.dtype(np.float32).itemsize + packed_input = np.zeros(packed_npy_size + 2 * frame_copy_size, dtype=np.uint8) + packed_npy_inputs = packed_input[:packed_npy_size].view(np.float32) + frames = packed_input[packed_npy_size:] + frame_views = {'img': frames[:frame_copy_size], 'big_img': frames[frame_copy_size:]} # views into the packed inputs, to be refilled at runtime - npy.update({k: v.reshape(s) for (k, s), v in zip(shapes.items(), np.split(packed_npy_inputs, np.cumsum(sizes[:-1])), strict=True)}) - input_queues.update({ - 'feat_q': Tensor(np.zeros((frame_skip * fb[1], fb[0], fb[2]), dtype=np.float32), device=device).contiguous().realize(), + npy = {k: v.reshape(s) for (k, s), v in zip(shapes.items(), np.split(packed_npy_inputs, np.cumsum(sizes[:-1])), strict=True)} + input_queues = { + 'img_q': Tensor(np.zeros(img_buf_shape, dtype=np.uint8), device=device).contiguous().realize(), + 'big_img_q': Tensor(np.zeros(img_buf_shape, dtype=np.uint8), device=device).contiguous().realize(), + 'feat_q': Tensor(np.zeros((frame_skip * fb[1], fb[0], feat_dim), dtype=np.float32), device=device).contiguous().realize(), 'desire_q': Tensor(np.zeros((frame_skip * dp[1], dp[0], dp[2]), dtype=np.float32), device=device).contiguous().realize(), - 'packed_npy_inputs': Tensor(packed_npy_inputs, device='NPY').realize(), - }) - return input_queues, npy + 'packed_npy_inputs': Tensor(packed_input, device='NPY').realize(), + } + return input_queues, npy, frame_views def shift_and_sample(buf, new_val, sample_fn): @@ -176,13 +165,15 @@ def sample_desire(buf, frame_skip): return buf.reshape(-1, frame_skip, *buf.shape[1:]).max(1).flatten(0, 1).unsqueeze(0) -def make_warp(nv12, model_w, model_h, frame_skip): +def make_warp(nv12, model_w, model_h): frame_prepare = make_frame_prepare(nv12, model_w, model_h) def warp(tfm, big_tfm, frame, big_frame): - tfm = tfm.to(WARP_DEV) - big_tfm = big_tfm.to(WARP_DEV) - Tensor.realize(tfm, big_tfm) + tfm = tfm.to(Device.DEFAULT) + big_tfm = big_tfm.to(Device.DEFAULT) + frame = frame.to(Device.DEFAULT) + big_frame = big_frame.to(Device.DEFAULT) + Tensor.realize(tfm, big_tfm, frame, big_frame) warped_frame = frame_prepare(frame, tfm).unsqueeze(0) warped_big_frame = frame_prepare(big_frame, big_tfm).unsqueeze(0) @@ -195,10 +186,10 @@ def make_run_policy(model_runner, model_metadata, frame_skip): sample_desire_fn = partial(sample_desire, frame_skip=frame_skip) sample_skip_fn = partial(sample_skip, frame_skip=frame_skip) npy_shapes, npy_sizes = get_policy_npy_shapes(model_metadata['input_shapes']) + model_input_dtypes = {name: spec.dtype for name, spec in model_runner.graph_inputs.items()} def run_policy(warped, img_q, big_img_q, feat_q, desire_q, packed_npy_inputs): packed_npy_inputs = packed_npy_inputs.to(Device.DEFAULT) - warped = warped.to(Device.DEFAULT) Tensor.realize(packed_npy_inputs, warped) img = shift_and_sample(img_q, warped[0:1], sample_skip_fn) @@ -211,33 +202,50 @@ def make_run_policy(model_runner, model_metadata, frame_skip): inputs = { 'img': img, 'big_img': big_img, - 'features_buffer': feat_buf, + 'features_buffer': feat_buf.reshape(model_metadata['input_shapes']['features_buffer']), 'desire_pulse': desire_buf, 'traffic_convention': traffic_convention, 'action_t': action_t, } + inputs = {name: value.cast(model_input_dtypes[name]) for name, value in inputs.items()} out = next(iter(model_runner(inputs).values())).cast('float32') return out, return run_policy -def compile_jit(jit, make_random_inputs, input_keys, make_queues): - SEED = 42 - def random_inputs_run(fn, seed, test_val=None, test_buffers=None, expect_match=True): - input_queues, npy = make_queues(Device.DEFAULT) - rng = np.random.default_rng(seed) - Tensor.manual_seed(seed) +def make_run_model(warp, run_policy, model_metadata, frame_copy_size): + _, policy_sizes = get_policy_npy_shapes(model_metadata['input_shapes']) + packed_npy_size = (18 + sum(policy_sizes)) * np.dtype(np.float32).itemsize - testing = test_val is not None or test_buffers is not None - n_runs = 1 if testing else 3 + def run_model(img_q, big_img_q, feat_q, desire_q, packed_npy_inputs): + packed_input = packed_npy_inputs.to(Device.DEFAULT) + Tensor.realize(packed_input) + packed_npy_inputs = packed_input[:packed_npy_size].bitcast('float32') + frame = packed_input[packed_npy_size:packed_npy_size + frame_copy_size] + big_frame = packed_input[packed_npy_size + frame_copy_size:] + tfm, big_tfm, policy_inputs = packed_npy_inputs.split([9, 9, sum(policy_sizes)]) + warped = warp(tfm.reshape(3, 3), big_tfm.reshape(3, 3), frame, big_frame) + return run_policy(warped, img_q, big_img_q, feat_q, desire_q, policy_inputs) + return run_model + + +def compile_jit(jit, input_keys, make_queues, benchmark_runs): + if benchmark_runs < 1: + raise ValueError("benchmark_runs must be at least 1") + + SEED = 42 + def random_inputs_run(fn, seed, n_runs, test_val=None, test_buffers=None, expect_match=True): + input_queues, npy, frame_views = make_queues(Device.DEFAULT) + rng = np.random.default_rng(seed) for i in range(n_runs): for v in npy.values(): v[:] = rng.standard_normal(v.shape).astype(v.dtype) + for v in frame_views.values(): + v[:] = rng.integers(0, 256, size=v.shape, dtype=np.uint8) Device.default.synchronize() - random_inputs = make_random_inputs() st = time.perf_counter() - outs = fn(**{k: input_queues[k] for k in input_keys}, **random_inputs) + outs = fn(**{k: input_queues[k] for k in input_keys}) mt = time.perf_counter() Device.default.synchronize() et = time.perf_counter() @@ -256,14 +264,15 @@ def compile_jit(jit, make_random_inputs, input_keys, make_queues): return val, buffers print('capture + replay') - test_val, test_buffers = random_inputs_run(jit, SEED) - print('pickle round trip') + test_val, test_buffers = random_inputs_run(jit, SEED, 3) + print(f'pickle round trip ({benchmark_runs} runs per seed)') with tempfile.TemporaryFile(dir=".") as f: dump_oob(jit, f) f.seek(0) - jit = load_oob(f) - random_inputs_run(jit, SEED, test_val, test_buffers, expect_match=True) - random_inputs_run(jit, SEED+1, test_val, test_buffers, expect_match=False) + loaded_jit = load_oob(f) + random_inputs_run(loaded_jit, SEED, benchmark_runs, test_val, test_buffers, expect_match=True) + random_inputs_run(loaded_jit, SEED+1, benchmark_runs, test_val, test_buffers, expect_match=False) + # Keep the original so per-resolution JITs share model weight buffers in the final pickle. return jit @@ -292,27 +301,31 @@ if __name__ == "__main__": p.add_argument('--onnx', required=True) p.add_argument('--output', required=True) p.add_argument('--frame-skip', type=int, required=True) + p.add_argument('--benchmark-runs', type=int, default=1, + help='timed loaded-JIT runs for each correctness seed') args = p.parse_args() model_path = read_file_chunked_to_disk(args.onnx) model_w, model_h = args.model_size model_runner = OnnxRunner(model_path) - out = {'metadata': make_metadata_dict(model_path)} + out = { + 'metadata': make_metadata_dict(model_path), + 'input_devices': {'model': Device.DEFAULT}, + 'run_model': {}, + } - run_policy_jit = TinyJit(make_run_policy(model_runner, out['metadata'], args.frame_skip), prune=True) - - make_policy_queues = partial(make_input_queues, out['metadata']['input_shapes'], args.frame_skip) - make_random_model_inputs = partial(make_random_images, keys=['warped'], shape=(2, 6, *out['metadata']['input_shapes']['img'][2:]), device=WARP_DEV) - out['run_policy'] = compile_jit(run_policy_jit, make_random_model_inputs, POLICY_INPUTS, - make_policy_queues) + run_policy = make_run_policy(model_runner, out['metadata'], args.frame_skip) for cam_w, cam_h in args.camera_resolutions: nv12 = NV12Frame(cam_w, cam_h, *get_nv12_info(cam_w, cam_h)) - make_random_warp_inputs = partial(make_random_images, keys=['frame', 'big_frame'], shape=nv12.size, device=WARP_DEV) - warp = TinyJit(make_warp(nv12, model_w, model_h, args.frame_skip), prune=True) - make_warp_queues = partial(make_warp_input_queues, out['metadata']['input_shapes'], args.frame_skip) - out[(cam_w,cam_h)] = compile_jit(warp, make_random_warp_inputs, WARP_INPUTS, make_warp_queues) + frame_copy_size = nv12_copy_size(nv12.stride, nv12.y_height, nv12.uv_height) + make_model_queues = partial(make_input_queues, out['metadata']['input_shapes'], args.frame_skip, + frame_copy_size=frame_copy_size) + warp = make_warp(nv12, model_w, model_h) + run_model_jit = TinyJit(make_run_model(warp, run_policy, out['metadata'], frame_copy_size), prune=True) + out['run_model'][(cam_w,cam_h)] = compile_jit(run_model_jit, MODELD_INPUTS, make_model_queues, + args.benchmark_runs) with open(args.output, "wb") as f: dump_oob(out, f) diff --git a/openpilot/selfdrive/modeld/dmonitoringmodeld.py b/openpilot/selfdrive/modeld/dmonitoringmodeld.py index 67161c35bd..4010725b89 100755 --- a/openpilot/selfdrive/modeld/dmonitoringmodeld.py +++ b/openpilot/selfdrive/modeld/dmonitoringmodeld.py @@ -29,7 +29,7 @@ class ModelState: output: np.ndarray def __init__(self, cam_w: int, cam_h: int): - self.DEV = get_tg_input_devices(PROCESS_NAME, usbgpu=False)['DEV'] + self.DEV = get_tg_input_devices(PROCESS_NAME, chestnut=False)['DEV'] with open(METADATA_PATH, 'rb') as f: model_metadata = pickle.load(f) self.input_shapes = model_metadata['input_shapes'] diff --git a/openpilot/selfdrive/modeld/fill_model_msg.py b/openpilot/selfdrive/modeld/fill_model_msg.py index 4d0069c878..1dfbf3c2f6 100644 --- a/openpilot/selfdrive/modeld/fill_model_msg.py +++ b/openpilot/selfdrive/modeld/fill_model_msg.py @@ -64,6 +64,7 @@ def fill_driving_model_data(msg: capnp._DynamicStructBuilder, modelv2_send: capn driving_model_data.frameIdExtra = modelV2.frameIdExtra driving_model_data.frameDropPerc = modelV2.frameDropPerc driving_model_data.modelExecutionTime = modelV2.modelExecutionTime + driving_model_data.big = modelV2.big driving_model_data.action = modelV2.action driving_model_data.meta.laneChangeState = modelV2.meta.laneChangeState driving_model_data.meta.laneChangeDirection = modelV2.meta.laneChangeDirection diff --git a/openpilot/selfdrive/modeld/helpers.py b/openpilot/selfdrive/modeld/helpers.py index 37ab0b26d7..23eb6cbd21 100644 --- a/openpilot/selfdrive/modeld/helpers.py +++ b/openpilot/selfdrive/modeld/helpers.py @@ -7,18 +7,20 @@ import tempfile from pathlib import Path from openpilot.common.file_chunker import get_manifest_path -from openpilot.common.hardware.usb import CHESTNUT_FW_VERSION, CHESTNUT_USB_IDS, USB_DEVICES_PATH +from openpilot.common.hardware.usb import CHESTNUT_USB_PRODUCT, USB_DEVICES_PATH, is_chestnut_usb_id MODELS_DIR = Path(__file__).resolve().parent / 'models' TG_INPUT_DEVICES_PATH = MODELS_DIR / 'tg_input_devices.json' +CHESTNUT_POWERED_VOLTAGE = 5000 +CHESTNUT_PCIE_READY = 0x78 -def get_tg_input_devices(process_name: str, usbgpu: bool): +def get_tg_input_devices(process_name: str, chestnut: bool): with open(TG_INPUT_DEVICES_PATH) as f: - return json.load(f)[process_name]['default' if not usbgpu else 'usbgpu'] + return json.load(f)[process_name]['default' if not chestnut else 'chestnut'] -def modeld_pkl_path(usbgpu: bool): - prefix = 'big_' if usbgpu else '' +def modeld_pkl_path(chestnut: bool): + prefix = 'big_' if chestnut else '' return MODELS_DIR / f'{prefix}driving_tinygrad.pkl' def dump_oob(obj, f): @@ -45,16 +47,20 @@ def load_oob(f): yield pb return pickle.load(io.BytesIO(opcodes), buffers=buffers()) -def usbgpu_present() -> bool: +def chestnut_present() -> bool: for d in USB_DEVICES_PATH.glob("*"): try: usb_id = (int((d / "idVendor").read_text(), 16), int((d / "idProduct").read_text(), 16)) product = (d / "product").read_text().strip() - if usb_id in CHESTNUT_USB_IDS and product == f"custom {CHESTNUT_FW_VERSION}-CLEAN": + if is_chestnut_usb_id(*usb_id) and product == CHESTNUT_USB_PRODUCT: return True except Exception: pass return False -def usbgpu_compiled() -> bool: - return Path(get_manifest_path(modeld_pkl_path(usbgpu=True))).is_file() +def chestnut_compiled() -> bool: + return Path(get_manifest_path(modeld_pkl_path(chestnut=True))).is_file() + + +def chestnut_ready(state) -> bool: + return state.supplyVoltage >= CHESTNUT_POWERED_VOLTAGE and not state.supplyFault and state.pcieLtssm == CHESTNUT_PCIE_READY diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index 790ae7b167..7e7fa7f583 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -1,9 +1,11 @@ #!/usr/bin/env python3 +from collections.abc import Callable +import ctypes from functools import cached_property import os -os.environ['GMMU'] = '0' # for usbgpu fast loading, noop for qcom -from tinygrad.tensor import Tensor +os.environ['GMMU'] = '0' # for chestnut fast loading, noop for qcom from tinygrad.device import Device +import usb1 import struct import threading import time @@ -26,17 +28,17 @@ from openpilot.common.transformations.model import get_warp_matrix from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper from openpilot.selfdrive.controls.lib.drive_helpers import get_accel_from_plan, should_stop, smooth_value, get_curvature_from_plan from openpilot.selfdrive.modeld.parse_model_outputs import Parser -from openpilot.selfdrive.modeld.compile_modeld import make_input_queues, WARP_INPUTS, POLICY_INPUTS +from openpilot.selfdrive.modeld.compile_modeld import make_input_queues, nv12_copy_size, MODELD_INPUTS from openpilot.selfdrive.modeld.fill_model_msg import fill_model_msg, fill_driving_model_data, fill_pose_msg, PublishState from openpilot.common.file_chunker import open_file_chunked +from openpilot.common.hardware.usb import CHESTNUT_USB_IDS from openpilot.selfdrive.modeld.constants import ModelConstants, Plan -from openpilot.selfdrive.modeld.helpers import usbgpu_present, usbgpu_compiled, modeld_pkl_path, get_tg_input_devices, load_oob +from openpilot.selfdrive.modeld.helpers import chestnut_present, chestnut_compiled, chestnut_ready, modeld_pkl_path, load_oob from openpilot.sunnypilot.livedelay.helpers import get_lat_delay from openpilot.sunnypilot.modeld_v2.modeld_base import ModelStateBase from openpilot.sunnypilot.selfdrive.controls.lib.relc import RoadEdgeLaneChangeController -PROCESS_NAME = "openpilot.selfdrive.modeld.modeld" SEND_RAW_PRED = os.getenv('SEND_RAW_PRED') LAT_SMOOTH_SECONDS = 0.0 @@ -81,6 +83,37 @@ class ChestnutState: self.valid = True self.sends = 0 self.metrics = {} + self._asm_usb = None + + def _close_asm_usb(self) -> None: + if self._asm_usb is not None: + self._asm_usb.close() + self._asm_usb = None + + def _open_asm_usb(self): + context = usb1.USBContext() + for vendor_id, product_id in CHESTNUT_USB_IDS: + if (handle := context.openByVendorIDAndProductID(vendor_id, product_id, skip_on_error=True)) is not None: + return handle + context.close() + + def _read_ina(self) -> tuple[int, int, bool]: + if "AMD" in Device._opened_devices and self._asm_usb is None: + try: + raw = Device["AMD"].iface.pci_dev.usb.usb.control_read(0xC0, 5) + return struct.unpack(' int: @@ -94,8 +127,10 @@ class ChestnutState: if self.big and "AMD" in Device._opened_devices and self.sends % 100 == 1: try: smu = Device["AMD"].iface.dev_impl.smu + metrics_t = smu.smu_mod.SmuMetricsExternal_t smu._send_msg(smu.smu_mod.PPSMC_MSG_TransferTableSmu2Dram, smu.smu_mod.TABLE_SMU_METRICS, timeout=100) - metrics = smu.read_table(smu.smu_mod.SmuMetricsExternal_t, smu.smu_mod.TABLE_SMU_METRICS).SmuMetrics + metrics_buf = bytearray(smu.adev.vram.view(smu.driver_table_paddr, ctypes.sizeof(metrics_t))[:]) + metrics = metrics_t.from_buffer(metrics_buf).SmuMetrics self.metrics = {'tempC': metrics.AvgTemperature[smu.smu_mod.TEMP_HOTSPOT], 'memoryTempC': metrics.AvgTemperature[smu.smu_mod.TEMP_MEM], 'powerDrawW': metrics.AverageSocketPower, @@ -114,13 +149,15 @@ class ChestnutState: setattr(state, k, v) asm_valid = False + try: + # ASM runs on USB-C power, these still read without a gpu + state.supplyVoltage, state.supplyCurrent, state.supplyFault = self._read_ina() + asm_valid = True + except Exception: + pass if "AMD" in Device._opened_devices: try: - # ASM runs on USB-C power, these still read without a gpu - asm = Device["AMD"].iface.pci_dev.usb - state.pcieLtssm = asm.read(0xB450, 1)[0] - state.supplyVoltage, state.supplyCurrent = struct.unpack(' dict[str, np.ndarray]: parsed_model_outputs = {k: model_outputs[np.newaxis, v] for k,v in output_slices.items()} return parsed_model_outputs def run(self, bufs: dict[str, VisionBuf], transforms: dict[str, np.ndarray], - inputs: dict[str, np.ndarray]) -> dict[str, np.ndarray] | None: - for key in bufs.keys(): - ptr = np.frombuffer(bufs[key].data, dtype=np.uint8).ctypes.data - yuv_size = self.frame_buf_params[key][3] - # There is a ringbuffer of imgs, just cache tensors pointing to all of them - cache_key = (key, ptr) - if cache_key not in self._blob_cache: - self._blob_cache[cache_key] = Tensor.from_blob(ptr, (yuv_size,), dtype='uint8', device=self.WARP_DEV) - self.full_frames[key] = self._blob_cache[cache_key] + inputs: dict[str, np.ndarray], after_enqueue: Callable[[], None] | None = None) -> dict[str, np.ndarray]: + for key, buf in bufs.items(): + np.copyto(self.frame_views[key], np.frombuffer(buf.data, dtype=np.uint8, count=self.frame_copy_size)) # Model decides when action is completed, so desire input is just a pulse triggered on rising edge inputs['desire_pulse'][0] = 0 @@ -187,16 +216,12 @@ class ModelState(ModelStateBase): self.npy['tfm'][:,:] = transforms['img'][:,:] self.npy['big_tfm'][:,:] = transforms['big_img'][:,:] - warped = self.warp(**{k: self.input_queues[k] for k in WARP_INPUTS}, frame=self.full_frames['img'], big_frame=self.full_frames['big_img']) - - outs, = self.run_policy( - **{k: self.input_queues[k] for k in POLICY_INPUTS if k in self.input_queues}, warped=warped - ) + outs, = self.run_model(**{k: self.input_queues[k] for k in MODELD_INPUTS}) + if after_enqueue is not None: + after_enqueue() model_output = outs.numpy()[0] - if self.usbgpu and not np.all(np.isfinite(model_output)): - # TODO remove with prev_feat - cloudlog.error("model output not finite, dropping frame") - return None + if self.chestnut and not np.all(np.isfinite(model_output)): + raise RuntimeError("model output not finite") outputs_dict = self.parser.parse_outputs(self.slice_outputs(model_output, self.output_slices)) self.npy['prev_feat'][:] = model_output[self.output_slices['hidden_state']] @@ -205,25 +230,37 @@ class ModelState(ModelStateBase): return outputs_dict def warmup(self) -> None: - dummy_frames = {k: np.zeros(self.frame_buf_params[k][3], dtype=np.uint8) for k in self.vision_input_names} + dummy_frames = {k: np.zeros(self.frame_copy_size, dtype=np.uint8) for k in self.vision_input_names} eye = np.eye(3, dtype=np.float32) dims = {'desire_pulse': ModelConstants.DESIRE_LEN, 'traffic_convention': 2, 'action_t': 2} self.run(dummy_frames, dict.fromkeys(self.vision_input_names, eye), {k: np.zeros(v, dtype=np.float32) for k, v in dims.items()}) - self.input_queues, self.npy = make_input_queues(self.input_shapes, self.frame_skip, device=self.QUEUE_DEV) + self.input_queues, self.npy, self.frame_views = make_input_queues( + self.input_shapes, self.frame_skip, device=self.model_device, frame_copy_size=self.frame_copy_size) self.prev_desire[:] = 0 - self.full_frames.clear() - self._blob_cache.clear() def main(demo=False): cloudlog.warning("modeld init") - USBGPU = usbgpu_present() and usbgpu_compiled() - if USBGPU: + chestnut_available = chestnut_present() and chestnut_compiled() + CHESTNUT = False + if chestnut_available: + poller = messaging.Poller() + sock = messaging.sub_sock("chestnutState", poller=poller, conflate=True) + deadline = time.monotonic() + 4. / SERVICE_LIST['deviceState'].frequency + while not CHESTNUT and (remaining := deadline - time.monotonic()) > 0.: + if not poller.poll(round(remaining * 1000)): + break + msg = messaging.recv_one_or_none(sock) + CHESTNUT = msg is not None and msg.valid and chestnut_ready(msg.chestnutState) + if CHESTNUT: os.environ['HCQDEV_WAIT_TIMEOUT_MS'] = '3000' params = Params() - params.put_bool("UsbGpuLoading", USBGPU) - params.remove("UsbGpuActive") + params.put_bool("ChestnutLoading", CHESTNUT) + if chestnut_available and not CHESTNUT: + params.put_bool("ChestnutActive", False) + else: + params.remove("ChestnutActive") config_realtime_process(7, 54) @@ -253,7 +290,7 @@ def main(demo=False): st = time.monotonic() cloudlog.warning("loading model") model = None - if USBGPU: + if CHESTNUT: big_model = None def load_big(): nonlocal big_model @@ -267,23 +304,27 @@ def main(demo=False): loader.start() loader.join(BIG_MODEL_TIMEOUT) model = big_model - params.put_bool("UsbGpuActive", model is not None) + if model is None: + params.put_bool("ChestnutModelError", True) + params.put_bool("ChestnutActive", model is not None) + if model is not None: + params.remove("ChestnutModelError") - small_model = ModelState(vipc_client_main.width, vipc_client_main.height, False) if model is None or USBGPU else None + small_model = ModelState(vipc_client_main.width, vipc_client_main.height, False) if model is None or CHESTNUT else None if model is None: model = small_model - params.put_bool("UsbGpuLoading", False) + params.put_bool("ChestnutLoading", False) assert model is not None cloudlog.warning(f"models loaded in {time.monotonic() - st:.1f}s, modeld starting") # messaging - pub_socks = ["modelV2", "drivingModelData", "cameraOdometry", "modelDataV2SP"] + (["chestnutState"] if USBGPU else []) + pub_socks = ["modelV2", "drivingModelData", "cameraOdometry", "modelDataV2SP"] + (["chestnutState"] if CHESTNUT else []) pm = PubMaster(pub_socks) sm = SubMaster(["deviceState", "carState", "narrowRoadCameraState", "extrinsicsCalibration", "driverMonitoringState", "carControl", "lateralDelay"]) publish_state = PublishState() params = Params() - chestnut_state = ChestnutState(pm, model.usbgpu) if USBGPU else None + chestnut_state = ChestnutState(pm, model.chestnut) if CHESTNUT else None # setup filter to track dropped frames frame_dropped_filter = FirstOrderFilter(0., 10., 1. / ModelConstants.MODEL_RUN_FREQ) @@ -393,13 +434,16 @@ def main(demo=False): mt1 = time.perf_counter() try: - model_output = model.run(bufs, transforms, inputs) + send_chestnut = (chestnut_state is not None and + run_count % round(ModelConstants.MODEL_RUN_FREQ / SERVICE_LIST['chestnutState'].frequency) == 0) + model_output = model.run(bufs, transforms, inputs, chestnut_state.send if send_chestnut else None) except Exception: - if not params.get_bool("UsbGpuActive"): + if not params.get_bool("ChestnutActive"): raise # fallback to small model cloudlog.exception("big model failed, fall back to small") - params.put_bool("UsbGpuActive", False) + params.put_bool("ChestnutModelError", True) + params.put_bool("ChestnutActive", False) assert small_model is not None model = small_model if chestnut_state is not None: @@ -419,18 +463,17 @@ def main(demo=False): fill_model_msg(modelv2_send, model_output, action, publish_state, meta_main.frame_id, meta_extra.frame_id, frame_id, frame_drop_ratio, meta_main.timestamp_eof, model_execution_time, extrinsics_calibration_seen) - modelv2_send.modelV2.big = model.usbgpu + modelv2_send.modelV2.big = model.chestnut desire_state = modelv2_send.modelV2.meta.desireState l_lane_change_prob = desire_state[log.Desire.laneChangeLeft] r_lane_change_prob = desire_state[log.Desire.laneChangeRight] lane_change_prob = l_lane_change_prob + r_lane_change_prob - DH.update(sm['carState'], sm['carControl'].latActive, lane_change_prob) - modelv2_send.modelV2.meta.laneChangeState = DH.lane_change_state - modelv2_send.modelV2.meta.laneChangeDirection = DH.lane_change_direction - mdv2sp_send = messaging.new_message('modelDataV2SP') left_edge, right_edge = RELC.update_and_fill(modelv2_send.modelV2, mdv2sp_send.modelDataV2SP, v_ego) + DH.update(sm['carState'], sm['carControl'].latActive, lane_change_prob, left_edge, right_edge) + modelv2_send.modelV2.meta.laneChangeState = DH.lane_change_state + modelv2_send.modelV2.meta.laneChangeDirection = DH.lane_change_direction mdv2sp_send.modelDataV2SP.laneTurnDirection = DH.lane_turn_direction fill_driving_model_data(drivingdata_send, modelv2_send) @@ -441,10 +484,6 @@ def main(demo=False): pm.send('modelDataV2SP', mdv2sp_send) last_vipc_frame_id = meta_main.frame_id - if chestnut_state is not None and run_count % round(ModelConstants.MODEL_RUN_FREQ / SERVICE_LIST['chestnutState'].frequency) == 0: - chestnut_state.send() - - if __name__ == "__main__": try: import argparse diff --git a/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx b/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx index 4a04bd7833..7b51732275 100644 --- a/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx +++ b/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a501760a9d1d5fef0eab2b8c5d122d06124fc26dc8e0782e0aa94b82a208f0ff -size 1757355221 +oid sha256:1791d5940b2c048d0639813426dd2cf1d6f2a6727ed51e17c8bcea8bbe754123 +size 765950064 diff --git a/openpilot/selfdrive/pandad/pandad.cc b/openpilot/selfdrive/pandad/pandad.cc index ac87804bc8..23df5d6a83 100644 --- a/openpilot/selfdrive/pandad/pandad.cc +++ b/openpilot/selfdrive/pandad/pandad.cc @@ -123,22 +123,22 @@ void fill_panda_state(cereal::PandaState::Builder &ps, cereal::PandaState::Panda ps.setUptime(health.uptime_pkt); ps.setSafetyTxBlocked(health.safety_tx_blocked_pkt); ps.setSafetyRxInvalid(health.safety_rx_invalid_pkt); - ps.setIgnitionLine(health.ignition_line_pkt); - ps.setIgnitionCan(health.ignition_can_pkt); - ps.setControlsAllowed(health.controls_allowed_pkt); + ps.setIgnitionLine((health.flags_pkt & HEALTH_FLAG_IGNITION_LINE) != 0U); + ps.setIgnitionCan((health.flags_pkt & HEALTH_FLAG_IGNITION_CAN) != 0U); + ps.setControlsAllowed((health.flags_pkt & HEALTH_FLAG_CONTROLS_ALLOWED) != 0U); ps.setTxBufferOverflow(health.tx_buffer_overflow_pkt); ps.setRxBufferOverflow(health.rx_buffer_overflow_pkt); ps.setPandaType(hw_type); ps.setSafetyModel(cereal::CarParams::SafetyModel(health.safety_mode_pkt)); ps.setSafetyParam(health.safety_param_pkt); ps.setFaultStatus(cereal::PandaState::FaultStatus(health.fault_status_pkt)); - ps.setPowerSaveEnabled((bool)(health.power_save_enabled_pkt)); - ps.setHeartbeatLost((bool)(health.heartbeat_lost_pkt)); + ps.setPowerSaveEnabled((health.flags_pkt & HEALTH_FLAG_POWER_SAVE_ENABLED) != 0U); + ps.setHeartbeatLost((health.flags_pkt & HEALTH_FLAG_HEARTBEAT_LOST) != 0U); ps.setAlternativeExperience(health.alternative_experience_pkt); ps.setHarnessStatus(cereal::PandaState::HarnessStatus(health.car_harness_status_pkt)); - ps.setInterruptLoad(health.interrupt_load_pkt); + ps.setInterruptLoad(health.interrupt_load_pkt / 255.0f); ps.setFanPower(health.fan_power); - ps.setSafetyRxChecksInvalid((bool)(health.safety_rx_checks_invalid_pkt)); + ps.setSafetyRxChecksInvalid((health.flags_pkt & HEALTH_FLAG_SAFETY_RX_CHECKS_INVALID) != 0U); ps.setSpiErrorCount(health.spi_error_count_pkt); ps.setSbu1Voltage(health.sbu1_voltage_mV / 1000.0f); ps.setSbu2Voltage(health.sbu2_voltage_mV / 1000.0f); @@ -198,10 +198,10 @@ std::optional send_panda_states(PubMaster *pm, Panda *panda, bool is_onroa } if (spoofing_started) { - health.ignition_line_pkt = 1; + health.flags_pkt |= HEALTH_FLAG_IGNITION_LINE; } - bool ignition_local = ((health.ignition_line_pkt != 0) || (health.ignition_can_pkt != 0)) && !always_offroad; + bool ignition_local = ((health.flags_pkt & (HEALTH_FLAG_IGNITION_LINE | HEALTH_FLAG_IGNITION_CAN)) != 0U) && !always_offroad; // Make sure CAN buses are live: safety_setter_thread does not work if Panda CAN are silent and there is only one other CAN node if (health.safety_mode_pkt == (uint8_t)(cereal::CarParams::SafetyModel::SILENT)) { @@ -209,7 +209,7 @@ std::optional send_panda_states(PubMaster *pm, Panda *panda, bool is_onroa } bool power_save_desired = !ignition_local; - if (health.power_save_enabled_pkt != power_save_desired) { + if (((health.flags_pkt & HEALTH_FLAG_POWER_SAVE_ENABLED) != 0U) != power_save_desired) { panda->set_power_saving(power_save_desired); } diff --git a/openpilot/selfdrive/selfdrived/alerts_offroad.json b/openpilot/selfdrive/selfdrived/alerts_offroad.json index 91a7ec8ab3..b3b5077823 100644 --- a/openpilot/selfdrive/selfdrived/alerts_offroad.json +++ b/openpilot/selfdrive/selfdrived/alerts_offroad.json @@ -19,6 +19,30 @@ }, "Offroad_ChestnutBranch": { "text": "Chestnut detected! Switch to the %1 branch to use chestnut-class models.", + "severity": -1 + }, + "Offroad_ChestnutNotDetected": { + "text": "Chestnut not detected. Check USB and 12V connections.", + "severity": 0 + }, + "Offroad_ChestnutOverheated": { + "text": "Chestnut overheated. Ensure good airflow. Current GPU temperature is %1.", + "severity": 0 + }, + "Offroad_ChestnutPcieUnavailable": { + "text": "%1", + "severity": 0 + }, + "Offroad_ChestnutUncompiled": { + "text": "Chestnut model not compiled. Keep ignition on and reboot the comma.", + "severity": 0 + }, + "Offroad_ChestnutUpdateFailed": { + "text": "Chestnut update failed. Check the USB cable.", + "severity": 0 + }, + "Offroad_ChestnutUsbSlow": { + "text": "Chestnut USB link is slow. Check the USB cable. The current speed is %1.", "severity": 0 }, "Offroad_UnregisteredHardware": { diff --git a/openpilot/selfdrive/selfdrived/selfdrived.py b/openpilot/selfdrive/selfdrived/selfdrived.py index 92d410c8d6..603cbe35a5 100755 --- a/openpilot/selfdrive/selfdrived/selfdrived.py +++ b/openpilot/selfdrive/selfdrived/selfdrived.py @@ -195,17 +195,18 @@ class SelfdriveD(CruiseHelper): self.events.add(EventName.joystickDebug) self.startup_event = None - loading = self.params.get_bool("UsbGpuLoading") + loading = self.params.get_bool("ChestnutLoading") if self.big_model_loading and not loading: self.big_model_ready_t = time.monotonic() + self.events_sp.add(custom.OnroadEventSP.EventName.bigModelReady) self.big_model_loading = loading if self.big_model_loading: self.events.add(EventName.bigModelLoading) - big_active = self.params.get("UsbGpuActive") - usbgpu_present = self.sm['deviceState'].chestnutPresent + big_active = self.params.get("ChestnutActive") + chestnut_present = self.sm['deviceState'].chestnutPresent model_unavailable = big_active is True and self.sm.seen['modelV2'] and not self.sm.alive['modelV2'] - big_failed = big_active is False or model_unavailable or (self.big_model_active and not usbgpu_present) + big_failed = big_active is False or model_unavailable or (self.big_model_active and not chestnut_present) if big_failed and not self.big_model_failed: self.events.add(EventName.bigModelFailed) self.big_model_failed = big_failed diff --git a/openpilot/selfdrive/test/process_replay/migration.py b/openpilot/selfdrive/test/process_replay/migration.py index 646728e673..1809331c15 100644 --- a/openpilot/selfdrive/test/process_replay/migration.py +++ b/openpilot/selfdrive/test/process_replay/migration.py @@ -152,7 +152,7 @@ def migrate_drivingModelData(msgs): add_ops = [] for _, msg in msgs: dmd = messaging.new_message('drivingModelData', valid=msg.valid, logMonoTime=msg.logMonoTime) - for field in ["frameId", "frameIdExtra", "frameDropPerc", "modelExecutionTime", "action"]: + for field in ["frameId", "frameIdExtra", "frameDropPerc", "modelExecutionTime", "big", "action"]: setattr(dmd.drivingModelData, field, getattr(msg.modelV2, field)) for meta_field in ["laneChangeState", "laneChangeState"]: setattr(dmd.drivingModelData.meta, meta_field, getattr(msg.modelV2.meta, meta_field)) diff --git a/openpilot/selfdrive/test/process_replay/model_replay.py b/openpilot/selfdrive/test/process_replay/model_replay.py index ba610c6a2b..927c9b38f1 100755 --- a/openpilot/selfdrive/test/process_replay/model_replay.py +++ b/openpilot/selfdrive/test/process_replay/model_replay.py @@ -33,9 +33,9 @@ MODEL_REPLAY_BUCKET="model_replay_master" GITHUB = GithubUtils(API_TOKEN, DATA_TOKEN) EXEC_TIMINGS = [ - # model, instant max, average max - ("modelV2", 0.05, 0.028), - ("driverStateV2", 0.05, 0.018), + # model, instant max, average max, chestnut average max + ("modelV2", 0.05, 0.03, 0.05), + ("driverStateV2", 0.05, 0.018, 0.018), ] def get_log_fn(test_route, ref="master"): @@ -169,11 +169,13 @@ def model_replay(lr, frs): dmonitoringmodeld_msgs = replay_process(dmonitoringmodeld, dmodeld_logs, frs) msgs = modeld_msgs + dmonitoringmodeld_msgs + chestnut = any(m.modelV2.big for m in modeld_msgs if m.which() == "modelV2") header = ['model', 'max instant', 'max instant allowed', 'average', 'max average allowed', 'test result'] rows = [] timings_ok = True - for (s, instant_max, avg_max) in EXEC_TIMINGS: + for (s, instant_max, avg_max, chestnut_avg_max) in EXEC_TIMINGS: + avg_max = chestnut_avg_max if chestnut else avg_max ts = [getattr(m, s).modelExecutionTime for m in msgs if m.which() == s] # TODO some init can happen in first iteration ts = ts[1:] diff --git a/openpilot/selfdrive/ui/body/layouts/onroad.py b/openpilot/selfdrive/ui/body/layouts/onroad.py index a48e525628..94f1342abd 100644 --- a/openpilot/selfdrive/ui/body/layouts/onroad.py +++ b/openpilot/selfdrive/ui/body/layouts/onroad.py @@ -1,7 +1,7 @@ import time import pyray as rl -from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment, TextAlignmentVertical from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.label import UnifiedLabel from openpilot.selfdrive.ui.ui_state import ui_state @@ -26,8 +26,8 @@ class BodyLayout(Widget): self._last_input_time = time.monotonic() self._was_active = False self._offroad_label = UnifiedLabel("turn on ignition to use", 95 if gui_app.big_ui() else 45, FontWeight.DISPLAY, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + alignment=TextAlignment.CENTER, + alignment_vertical=TextAlignmentVertical.MIDDLE) def draw_dot_grid(self, rect: rl.Rectangle, dots: list[tuple[int, int]], color: rl.Color): spacing = min(rect.height / GRID_ROWS, rect.width / GRID_COLS) diff --git a/openpilot/selfdrive/ui/layouts/home.py b/openpilot/selfdrive/ui/layouts/home.py index be231dcd4b..1badf66116 100644 --- a/openpilot/selfdrive/ui/layouts/home.py +++ b/openpilot/selfdrive/ui/layouts/home.py @@ -8,7 +8,7 @@ from openpilot.selfdrive.ui.widgets.exp_mode_button import ExperimentalModeButto from openpilot.selfdrive.ui.widgets.prime import PrimeWidget from openpilot.selfdrive.ui.widgets.setup import SetupWidget from openpilot.system.ui.lib.text_measure import measure_text_cached -from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos +from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, TextAlignment from openpilot.system.ui.lib.multilang import tr, trn from openpilot.system.ui.widgets.label import gui_label from openpilot.system.ui.widgets import Widget @@ -178,7 +178,7 @@ class HomeLayout(Widget): version_rect = rl.Rectangle(self.header_rect.x + self.header_rect.width - version_text_width, self.header_rect.y, version_text_width, self.header_rect.height) - gui_label(version_rect, self._version_text, 48, rl.WHITE, alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT) + gui_label(version_rect, self._version_text, 48, rl.WHITE, alignment=TextAlignment.RIGHT) def _render_home_content(self): self._render_left_column() diff --git a/openpilot/selfdrive/ui/layouts/onboarding.py b/openpilot/selfdrive/ui/layouts/onboarding.py index df14415c60..0067160d03 100644 --- a/openpilot/selfdrive/ui/layouts/onboarding.py +++ b/openpilot/selfdrive/ui/layouts/onboarding.py @@ -5,7 +5,7 @@ from enum import IntEnum import pyray as rl from openpilot.common.basedir import BASEDIR -from openpilot.system.ui.lib.application import FontWeight, gui_app +from openpilot.system.ui.lib.application import FontWeight, TextAlignment, gui_app from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.button import Button, ButtonStyle @@ -115,9 +115,9 @@ class TermsPage(Widget): self._on_accept = on_accept self._on_decline = on_decline - self._title = Label(tr("Welcome to sunnypilot"), font_size=90, font_weight=FontWeight.BOLD, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT) + self._title = Label(tr("Welcome to sunnypilot"), font_size=90, font_weight=FontWeight.BOLD, text_alignment=TextAlignment.LEFT) self._desc = Label(tr("You must accept the Terms of Service to use sunnypilot. Read the latest terms at https://sunnypilot.ai/terms before continuing."), - font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT) + font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=TextAlignment.LEFT) self._decline_btn = Button(tr("Decline"), click_callback=on_decline) self._accept_btn = Button(tr("Agree"), button_style=ButtonStyle.PRIMARY, click_callback=on_accept) @@ -150,7 +150,7 @@ class DeclinePage(Widget): def __init__(self, back_callback=None): super().__init__() self._text = Label(tr("You must accept the Terms of Service in order to use sunnypilot."), - font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT) + font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=TextAlignment.LEFT) self._back_btn = Button(tr("Back"), click_callback=back_callback) self._uninstall_btn = Button(tr("Decline, uninstall sunnypilot"), button_style=ButtonStyle.DANGER, click_callback=self._on_uninstall_clicked) diff --git a/openpilot/selfdrive/ui/layouts/settings/software.py b/openpilot/selfdrive/ui/layouts/settings/software.py index d0016797fc..816d37b10c 100644 --- a/openpilot/selfdrive/ui/layouts/settings/software.py +++ b/openpilot/selfdrive/ui/layouts/settings/software.py @@ -199,6 +199,9 @@ class SoftwareLayout(Widget): selection = self._branch_dialog.selection ui_state.params.put("UpdaterTargetBranch", selection, block=True) self._branch_btn.action_item.set_value(selection) + self._download_btn.action_item.set_enabled(False) + self._waiting_for_updater = True + self._waiting_start_ts = time.monotonic() subprocess.run("pkill -SIGUSR1 -f openpilot.system.updated.updated", shell=True) self._branch_dialog = None diff --git a/openpilot/selfdrive/ui/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/mici/layouts/home.py b/openpilot/selfdrive/ui/mici/layouts/home.py index ff8d350e08..847cd42ed7 100644 --- a/openpilot/selfdrive/ui/mici/layouts/home.py +++ b/openpilot/selfdrive/ui/mici/layouts/home.py @@ -1,4 +1,5 @@ import datetime +import math import time from openpilot.cereal import log @@ -8,8 +9,8 @@ from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.layouts import HBoxLayout from openpilot.system.ui.widgets.icon_widget import IconWidget from openpilot.system.ui.widgets.label import UnifiedLabel, gui_label -from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos -from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, TextAlignment, TextAlignmentVertical +from openpilot.selfdrive.ui.ui_state import ui_state, ChestnutState from openpilot.common.version import RELEASE_BRANCHES HEAD_BUTTON_FONT_SIZE = 40 @@ -69,8 +70,8 @@ class AlertsPill(Widget): count_rect = rl.Rectangle(self.rect.x + self.COUNT_OFFSET, self.rect.y, pill_w - self.COUNT_OFFSET, pill_h) gui_label(count_rect, str(alert_count), font_size=36, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + alignment=TextAlignment.CENTER, + alignment_vertical=TextAlignmentVertical.MIDDLE) class NetworkIcon(Widget): @@ -139,8 +140,10 @@ class MiciHomeLayout(Widget): self._version_text = self._get_version_text() self._experimental_icon = IconWidget("icons_mici/experimental_mode.png", (48, 48)) - self._egpu_icon = IconWidget("icons_mici/egpu_green.png", (50, 37)) - self._egpu_icon_gray = IconWidget("icons_mici/egpu_gray.png", (50, 37)) + self._usb_icon = IconWidget("icons_mici/usb.png", (62, 40)) + self._chestnut_icon = IconWidget("icons_mici/chestnut_green.png", (68, 40)) + self._chestnut_loading_icon = IconWidget("icons_mici/chestnut.png", (68, 40)) + self._chestnut_failed_icon = IconWidget("icons_mici/chestnut_orange.png", (68, 40)) self._mic_icon = IconWidget("icons_mici/microphone.png", (32, 46)) self._body_icon = IconWidget("icons_mici/body.png", (54, 37)) @@ -150,13 +153,15 @@ class MiciHomeLayout(Widget): IconWidget("icons_mici/settings.png", (48, 48), opacity=0.9), NetworkIcon(), self._experimental_icon, - self._egpu_icon, - self._egpu_icon_gray, + self._usb_icon, + self._chestnut_icon, + self._chestnut_loading_icon, + self._chestnut_failed_icon, self._body_icon, self._mic_icon, ], spacing=18) - self._openpilot_label = UnifiedLabel("sunnypilot", font_size=96, font_weight=FontWeight.DISPLAY, max_width=480, wrap_text=False) + self._openpilot_label = UnifiedLabel("openpilot", font_size=96, font_weight=FontWeight.DISPLAY, max_width=480, wrap_text=False) self._version_label = UnifiedLabel("", font_size=36, font_weight=FontWeight.ROMAN, max_width=480, wrap_text=False) self._large_version_label = UnifiedLabel("", font_size=64, text_color=rl.GRAY, font_weight=FontWeight.ROMAN, max_width=480, wrap_text=False) self._date_label = UnifiedLabel("", font_size=36, text_color=rl.GRAY, font_weight=FontWeight.ROMAN, max_width=480, wrap_text=False) @@ -247,9 +252,20 @@ class MiciHomeLayout(Widget): self._version_commit_label.render() # ***** Center-aligned bottom section icons ***** + usb_connected = ui_state.usb_connected + usb_unknown = ui_state.usb_unknown + chestnut_state = ui_state.chestnut_state self._experimental_icon.set_visible(ui_state.experimental_mode) - self._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_chestnut_visibility() + else: + self._usb_icon.set_visible(usb_connected and usb_unknown) + self._chestnut_icon.set_visible(not usb_unknown and chestnut_state not in + (ChestnutState.LOADING, ChestnutState.UNCOMPILED, ChestnutState.FAILED) and + (usb_connected or chestnut_state in (ChestnutState.READY, ChestnutState.ACTIVE))) + self._chestnut_loading_icon.set_visible(not usb_unknown and chestnut_state == ChestnutState.LOADING) + self._chestnut_loading_icon.set_opacity(0.35 + 0.65 * (0.5 - 0.5 * math.cos(rl.get_time() * 6.0))) + self._chestnut_failed_icon.set_visible(not usb_unknown and chestnut_state in (ChestnutState.UNCOMPILED, ChestnutState.FAILED)) self._mic_icon.set_visible(ui_state.recording_audio) self._body_icon.set_visible(bool(ui_state.is_body)) diff --git a/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py b/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py index 879ac76df5..de8286cae5 100644 --- a/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py +++ b/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py @@ -11,7 +11,7 @@ from openpilot.common.hardware import HARDWARE from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.label import UnifiedLabel from openpilot.system.ui.widgets.scroller import Scroller -from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment, TextAlignmentVertical from openpilot.system.ui.lib.multilang import tr REFRESH_INTERVAL = 5.0 # seconds @@ -62,12 +62,12 @@ class AlertItem(Widget): self._icon_green = gui_app.texture("icons_mici/offroad_alerts/green_wheel.png", self.ICON_SIZE, self.ICON_SIZE) self._title_label = UnifiedLabel(text="", font_size=32, font_weight=FontWeight.SEMI_BOLD, text_color=self.TEXT_COLOR, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, line_height=0.95) + alignment=TextAlignment.LEFT, + alignment_vertical=TextAlignmentVertical.TOP, line_height=0.95) self._body_label = UnifiedLabel(text="", font_size=28, font_weight=FontWeight.ROMAN, text_color=self.TEXT_COLOR, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, line_height=0.95) + alignment=TextAlignment.LEFT, + alignment_vertical=TextAlignmentVertical.BOTTOM, line_height=0.95) self._title_text = "" self._body_text = "" @@ -200,8 +200,8 @@ class MiciOffroadAlerts(Scroller): # Create empty state label self._empty_label = UnifiedLabel(tr("no alerts"), 65, FontWeight.DISPLAY, rl.WHITE, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + alignment=TextAlignment.CENTER, + alignment_vertical=TextAlignmentVertical.MIDDLE) # Build initial alert list self._build_alerts() diff --git a/openpilot/selfdrive/ui/mici/layouts/onboarding.py b/openpilot/selfdrive/ui/mici/layouts/onboarding.py index 80483d1b01..d138172da6 100644 --- a/openpilot/selfdrive/ui/mici/layouts/onboarding.py +++ b/openpilot/selfdrive/ui/mici/layouts/onboarding.py @@ -4,7 +4,7 @@ import pyray as rl from collections.abc import Callable from openpilot.common.filter_simple import FirstOrderFilter from openpilot.common.qrcode import make_texture -from openpilot.system.ui.lib.application import FontWeight, gui_app +from openpilot.system.ui.lib.application import FontWeight, gui_app, TextAlignment from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.button import SmallCircleIconButton from openpilot.system.ui.widgets.scroller import NavScroller, Scroller @@ -35,7 +35,7 @@ class DriverCameraSetupDialog(BaseCabinCameraDialog): if not self._camera_view.frame: gui_label(rect, tr("camera starting"), font_size=64, font_weight=FontWeight.BOLD, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) + alignment=TextAlignment.CENTER) rl.end_scissor_mode() return diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/software.py b/openpilot/selfdrive/ui/mici/layouts/settings/software.py index 35fc94142b..2a4ae44595 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/software.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/software.py @@ -74,6 +74,10 @@ class SoftwareInfoLayoutMici(Widget): class CheckUpdateButton(BigButton): + UPDATER_PROC = "openpilot.system.updated.updated" + CHECK_FOR_UPDATE = "SIGUSR1" + DOWNLOAD_UPDATE = "SIGHUP" + def __init__(self): self._txt_update_icon = gui_app.texture("icons_mici/settings/device/update.png", 64, 75) self._txt_up_to_date_icon = gui_app.texture("icons_mici/settings/device/up_to_date.png", 64, 64) @@ -97,15 +101,20 @@ class CheckUpdateButton(BigButton): gui_app.push_widget(dlg) return + self._signal_updater(self.DOWNLOAD_UPDATE if self.get_value() == "download update" else self.CHECK_FOR_UPDATE) + + def check_for_update(self): + self._signal_updater(self.CHECK_FOR_UPDATE) + + def _signal_updater(self, sig: str): self.set_enabled(False) self._state = UpdaterState.WAITING_FOR_UPDATER + self._hide_value_t = None + self.set_value("") self.set_icon(self._txt_update_icon) def run(): - if self.get_value() == "download update": - subprocess.run("pkill -SIGHUP -f openpilot.system.updated.updated", shell=True) - else: - subprocess.run("pkill -SIGUSR1 -f openpilot.system.updated.updated", shell=True) + subprocess.run(f"pkill -{sig} -f {self.UPDATER_PROC}", shell=True) threading.Thread(target=run, daemon=True).start() @@ -184,7 +193,7 @@ class CheckUpdateButton(BigButton): class InstallUpdateButton(BigButton): def __init__(self): - super().__init__("install update", "", gui_app.texture("icons_mici/settings/device/reboot.png", 64, 70)) + super().__init__("install now", "", gui_app.texture("icons_mici/settings/device/reboot.png", 64, 70)) self.set_visible(lambda: ui_state.is_offroad() and ui_state.params.get_bool("UpdateAvailable")) def _update_state(self): @@ -232,8 +241,9 @@ class BranchSelectPage(NavScroller): class TargetBranchButton(BigButton): - def __init__(self): + def __init__(self, check_update_btn: CheckUpdateButton): super().__init__("target branch", ui_state.params.get("UpdaterTargetBranch") or "") + self._check_update_btn = check_update_btn self.set_click_callback(self._on_click) self.set_visible(not ui_state.params.get_bool("IsTestedBranch")) self.set_enabled(lambda: ui_state.is_offroad()) @@ -246,12 +256,15 @@ class TargetBranchButton(BigButton): self.set_value(target) def _on_click(self): + if not ui_state.params.get("UpdaterAvailableBranches"): + gui_app.push_widget(BigDialog("", tr("Failed to get available branches. Ensure you're connected to the internet and try again."))) + return gui_app.push_widget(BranchSelectPage(self._on_select)) def _on_select(self, branch: str): ui_state.params.put("UpdaterTargetBranch", branch, block=True) self.set_value(branch) - subprocess.run("pkill -SIGUSR1 -f openpilot.system.updated.updated", shell=True) + self._check_update_btn.check_for_update() class SoftwareLayoutMici(NavScroller): @@ -265,10 +278,11 @@ class SoftwareLayoutMici(NavScroller): gui_app.texture("icons_mici/settings/device/uninstall.png", 64, 64), uninstall_openpilot_callback, exit_on_confirm=False) + check_update_btn = CheckUpdateButton() self._scroller.add_widgets([ SoftwareInfoLayoutMici(), - CheckUpdateButton(), + check_update_btn, InstallUpdateButton(), - TargetBranchButton(), + TargetBranchButton(check_update_btn), uninstall_openpilot_btn, ]) diff --git a/openpilot/selfdrive/ui/mici/onroad/alert_renderer.py b/openpilot/selfdrive/ui/mici/onroad/alert_renderer.py index 125524b95d..cca0e9bec3 100644 --- a/openpilot/selfdrive/ui/mici/onroad/alert_renderer.py +++ b/openpilot/selfdrive/ui/mici/onroad/alert_renderer.py @@ -10,7 +10,7 @@ from opendbc.car.structs import car from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.common.filter_simple import BounceFilter, FirstOrderFilter from openpilot.common.hardware import COMMA_HARDWARE -from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.label import UnifiedLabel @@ -333,7 +333,7 @@ class AlertRenderer(Widget, SpeedLimitAlertRenderer): self._alert_text1_label.set_text(alert_text1) self._alert_text1_label.set_text_color(color) self._alert_text1_label.set_font_size(font_size) - self._alert_text1_label.set_alignment(rl.GuiTextAlignment.TEXT_ALIGN_LEFT if icon_side != 'left' else rl.GuiTextAlignment.TEXT_ALIGN_RIGHT) + self._alert_text1_label.set_alignment(TextAlignment.LEFT if icon_side != 'left' else TextAlignment.RIGHT) self._alert_text1_label.render(text_rect1) alert_text2 = alert.text2.lower() @@ -365,5 +365,5 @@ class AlertRenderer(Widget, SpeedLimitAlertRenderer): self._alert_text2_label.set_text(alert_text2) self._alert_text2_label.set_text_color(color) self._alert_text2_label.set_font_size(small_font_size) - self._alert_text2_label.set_alignment(rl.GuiTextAlignment.TEXT_ALIGN_LEFT if icon_side != 'left' else rl.GuiTextAlignment.TEXT_ALIGN_RIGHT) + self._alert_text2_label.set_alignment(TextAlignment.LEFT if icon_side != 'left' else TextAlignment.RIGHT) self._alert_text2_label.render(text_rect2) diff --git a/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py b/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py index e37e9b7ea9..71ea7e8f8f 100644 --- a/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py +++ b/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py @@ -11,7 +11,7 @@ from openpilot.selfdrive.ui.mici.onroad.hud_renderer import HudRenderer from openpilot.selfdrive.ui.mici.onroad.model_renderer import ModelRenderer from openpilot.selfdrive.ui.mici.onroad.confidence_ball import ConfidenceBall from openpilot.selfdrive.ui.mici.onroad.cameraview import CameraView -from openpilot.system.ui.lib.application import FontWeight, gui_app, MousePos, MouseEvent +from openpilot.system.ui.lib.application import FontWeight, gui_app, MousePos, MouseEvent, TextAlignment, TextAlignmentVertical from openpilot.system.ui.widgets.label import UnifiedLabel from openpilot.system.ui.widgets import Widget from openpilot.common.filter_simple import BounceFilter @@ -158,8 +158,8 @@ class AugmentedRoadView(CameraView): self._confidence_ball = ConfidenceBall() self._offroad_label = UnifiedLabel("start the car to\nuse sunnypilot", 54, FontWeight.DISPLAY, text_color=rl.Color(255, 255, 255, int(255 * 0.9)), - alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + alignment=TextAlignment.CENTER, + alignment_vertical=TextAlignmentVertical.MIDDLE) self._fade_texture = gui_app.texture("icons_mici/onroad/onroad_fade.png") diff --git a/openpilot/selfdrive/ui/mici/onroad/cabin_camera_dialog.py b/openpilot/selfdrive/ui/mici/onroad/cabin_camera_dialog.py index e86c0aa739..b0bdb9749c 100644 --- a/openpilot/selfdrive/ui/mici/onroad/cabin_camera_dialog.py +++ b/openpilot/selfdrive/ui/mici/onroad/cabin_camera_dialog.py @@ -4,7 +4,7 @@ from openpilot.cereal.visionipc import VisionStreamType from openpilot.selfdrive.ui.mici.onroad.cameraview import CameraView from openpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer from openpilot.selfdrive.ui.ui_state import ui_state, device -from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment, TextAlignmentVertical from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.nav_widget import NavWidget @@ -76,7 +76,7 @@ class BaseCabinCameraDialog(Widget): if not self._camera_view.frame: gui_label(rect, tr("camera starting"), font_size=54, font_weight=FontWeight.BOLD, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) + alignment=TextAlignment.CENTER) rl.end_scissor_mode() self._publish_alert_sound(None) return @@ -124,12 +124,12 @@ class BaseCabinCameraDialog(Widget): awareness_pct = dm_state.visionPolicyState.awarenessPercent if is_vision else dm_state.wheeltouchPolicyState.awarenessPercent gui_label(rl.Rectangle(rect.x + 2, rect.y + 2, rect.width, rect.height), f"Awareness: {awareness_pct:.0f}%", font_size=44, font_weight=FontWeight.MEDIUM, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, + alignment=TextAlignment.RIGHT, + alignment_vertical=TextAlignmentVertical.TOP, color=rl.Color(0, 0, 0, 180)) gui_label(rect, f"Awareness: {awareness_pct:.0f}%", font_size=44, font_weight=FontWeight.MEDIUM, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, + alignment=TextAlignment.RIGHT, + alignment_vertical=TextAlignmentVertical.TOP, color=rl.Color(255, 255, 255, int(255 * 0.9))) if dm_state.alertLevel == log.DriverMonitoringState.AlertLevel.none: @@ -137,16 +137,16 @@ class BaseCabinCameraDialog(Widget): # Show alert level alert_level_str = f"{'Pay Attention' if is_vision else 'Touch Wheel'} - level {dm_state.alertLevel}" - alignment = rl.GuiTextAlignment.TEXT_ALIGN_RIGHT if self.driver_state_renderer.is_rhd else rl.GuiTextAlignment.TEXT_ALIGN_LEFT + alignment = TextAlignment.RIGHT if self.driver_state_renderer.is_rhd else TextAlignment.LEFT shadow_rect = rl.Rectangle(rect.x + 2, rect.y + 2, rect.width, rect.height) gui_label(shadow_rect, alert_level_str, font_size=40, font_weight=FontWeight.BOLD, alignment=alignment, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, + alignment_vertical=TextAlignmentVertical.BOTTOM, color=rl.Color(0, 0, 0, 180)) gui_label(rect, alert_level_str, font_size=40, font_weight=FontWeight.BOLD, alignment=alignment, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, + alignment_vertical=TextAlignmentVertical.BOTTOM, color=rl.Color(255, 255, 255, int(255 * 0.9))) def _load_eye_textures(self): diff --git a/openpilot/selfdrive/ui/mici/onroad/hud_renderer.py b/openpilot/selfdrive/ui/mici/onroad/hud_renderer.py index 2d1f954122..05abae82eb 100644 --- a/openpilot/selfdrive/ui/mici/onroad/hud_renderer.py +++ b/openpilot/selfdrive/ui/mici/onroad/hud_renderer.py @@ -3,7 +3,7 @@ import pyray as rl from dataclasses import dataclass from openpilot.common.constants import CV from openpilot.selfdrive.ui.mici.onroad.torque_bar import TorqueBar -from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus +from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus, ChestnutState from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.lib.text_measure import measure_text_cached @@ -107,8 +107,7 @@ class HudRenderer(Widget): self.speed: float = 0.0 self.v_ego_cluster_seen: bool = False self._engaged: bool = False - self._small_model_engaged: bool = False - self._egpu_fade_time: float = 0 + self._chestnut_fade_time: float = 0 self._can_draw_top_icons = True self._show_wheel_critical = False @@ -124,17 +123,15 @@ class HudRenderer(Widget): self._txt_wheel: rl.Texture = gui_app.texture('icons_mici/wheel.png', 50, 50) self._txt_wheel_critical: rl.Texture = gui_app.texture('icons_mici/wheel_critical.png', 50, 50) self._txt_exclamation_point: rl.Texture = gui_app.texture('icons_mici/exclamation_point.png', 9, 44) - self._txt_egpu: rl.Texture = gui_app.texture('icons_mici/egpu.png', 60, 44) - self._txt_egpu_green: rl.Texture = gui_app.texture('icons_mici/egpu_green.png', 60, 44) - self._txt_egpu_orange: rl.Texture = gui_app.texture('icons_mici/egpu_orange.png', 60, 44) - self._txt_egpu_crossed: rl.Texture = gui_app.texture('icons_mici/egpu_crossed.png', 60, 52) - self._egpu_icon: rl.Texture | None = None - + self._txt_chestnut: rl.Texture = gui_app.texture('icons_mici/chestnut.png', 60, 44) + self._txt_chestnut_green: rl.Texture = gui_app.texture('icons_mici/chestnut_green.png', 60, 44) + self._txt_chestnut_orange: rl.Texture = gui_app.texture('icons_mici/chestnut_orange.png', 75, 44) + self._chestnut_icon: rl.Texture | None = None self._wheel_alpha_filter = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps) self._wheel_y_filter = FirstOrderFilter(0, 0.1, 1 / gui_app.target_fps) self._set_speed_alpha_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) - self._egpu_alpha_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) + self._chestnut_alpha_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) def set_wheel_critical_icon(self, critical: bool): """Set the wheel icon to critical or normal state.""" @@ -165,13 +162,10 @@ class HudRenderer(Widget): controls_state.deprecated.vCruise if v_cruise_cluster == 0.0 else v_cruise_cluster ) engaged = sm['selfdriveState'].enabled - if (engaged and not self._engaged and not ui_state.usbgpu_loading and ui_state.usbgpu_active is not True and - ui_state.sm.recv_frame['modelV2'] > ui_state.started_frame): - self._small_model_engaged = True - if engaged != self._engaged: - self._egpu_fade_time = rl.get_time() if engaged else 0 if (set_speed != self.set_speed and engaged) or (engaged and not self._engaged): self._set_speed_changed_time = rl.get_time() + if engaged != self._engaged: + self._chestnut_fade_time = rl.get_time() if engaged else 0 self._engaged = engaged self.set_speed = set_speed self.is_cruise_set = 0 < self.set_speed < SET_SPEED_NA @@ -191,8 +185,7 @@ class HudRenderer(Widget): if self.is_cruise_set: self._draw_set_speed(rect) - if ui_state.usbgpu and ui_state.usbgpu_compiled: - self._draw_model_source(rect) + self._draw_model_source(rect) self._draw_steering_wheel(rect) @@ -200,30 +193,24 @@ class HudRenderer(Widget): if ui_state.sm.recv_frame['selfdriveState'] < ui_state.started_frame: return - big_failed = (ui_state.usbgpu_active is False or not ui_state.sm['deviceState'].chestnutPresent or - (ui_state.usbgpu_active is True and ui_state.sm.recv_frame['modelV2'] > ui_state.started_frame and - not ui_state.sm.alive['modelV2']) or - (ui_state.usbgpu_active is None and ui_state.sm.recv_frame['modelV2'] > ui_state.started_frame)) - self._small_model_engaged &= big_failed - loading = ui_state.usbgpu_loading or (ui_state.usbgpu_active is None and not big_failed) + loading = ui_state.chestnut_state == ChestnutState.LOADING if loading: - pulse = 0.5 - 0.5 * math.cos(rl.get_time() * 6.0) - icon = self._txt_egpu - opacity = 0.35 + 0.65 * pulse - elif self._small_model_engaged: - icon = self._txt_egpu_crossed - opacity = 0.65 - elif big_failed: - icon = self._txt_egpu_orange + icon = self._txt_chestnut + opacity = 0.35 + 0.65 * (0.5 - 0.5 * math.cos(rl.get_time() * 6.0)) + elif ui_state.chestnut_state in (ChestnutState.UNCOMPILED, ChestnutState.FAILED): + icon = self._txt_chestnut_orange + opacity = 1.0 + elif ui_state.chestnut_state == ChestnutState.ACTIVE: + icon = self._txt_chestnut_green opacity = 1.0 else: - icon = self._txt_egpu_green - opacity = 1.0 + return - if icon is not self._egpu_icon: - self._egpu_fade_time = rl.get_time() - self._egpu_icon = icon - alpha = self._egpu_alpha_filter.update(loading or 0 < rl.get_time() - self._egpu_fade_time < SET_SPEED_PERSISTENCE) + if icon is not self._chestnut_icon: + self._chestnut_fade_time = rl.get_time() + self._chestnut_icon = icon + visible = loading or rl.get_time() - self._chestnut_fade_time < SET_SPEED_PERSISTENCE + alpha = self._chestnut_alpha_filter.update(visible) if alpha < 1e-2: return diff --git a/openpilot/selfdrive/ui/mici/widgets/button.py b/openpilot/selfdrive/ui/mici/widgets/button.py index e2912d00cf..59a3f95191 100644 --- a/openpilot/selfdrive/ui/mici/widgets/button.py +++ b/openpilot/selfdrive/ui/mici/widgets/button.py @@ -6,7 +6,7 @@ from collections.abc import Callable from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.label import UnifiedLabel from openpilot.system.ui.widgets.scroller import DO_ZOOM -from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos +from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, TextAlignmentVertical from openpilot.common.filter_simple import BounceFilter if TYPE_CHECKING: @@ -125,10 +125,10 @@ class BigButton(Widget): self._rotate_icon_t: float | None = None self._label = UnifiedLabel(text, font_size=self._get_label_font_size(), font_weight=FontWeight.BOLD, - text_color=LABEL_COLOR, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, scroll=scroll, + text_color=LABEL_COLOR, alignment_vertical=TextAlignmentVertical.BOTTOM, scroll=scroll, line_height=0.9) self._sub_label = UnifiedLabel(value, font_size=COMPLICATION_SIZE, font_weight=FontWeight.ROMAN, - text_color=COMPLICATION_GREY, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM) + text_color=COMPLICATION_GREY, alignment_vertical=TextAlignmentVertical.BOTTOM) self._update_label_layout() self._load_images() @@ -167,9 +167,9 @@ class BigButton(Widget): def _update_label_layout(self): self._label.set_font_size(self._get_label_font_size()) if self.value: - self._label.set_alignment_vertical(rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP) + self._label.set_alignment_vertical(TextAlignmentVertical.TOP) else: - self._label.set_alignment_vertical(rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM) + self._label.set_alignment_vertical(TextAlignmentVertical.BOTTOM) def set_text(self, text: str): self.text = text @@ -356,8 +356,8 @@ class GreyBigButton(BigButton): self._sub_label.set_font_size(36) self._sub_label.set_text_color(rl.Color(255, 255, 255, int(255 * 0.9))) self._sub_label.set_font_weight(FontWeight.DISPLAY_REGULAR) - self._sub_label.set_alignment_vertical(rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE if not self._label.text else - rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM) + self._sub_label.set_alignment_vertical(TextAlignmentVertical.MIDDLE if not self._label.text else + TextAlignmentVertical.BOTTOM) self._sub_label.set_line_height(0.95) @property diff --git a/openpilot/selfdrive/ui/onroad/alert_renderer.py b/openpilot/selfdrive/ui/onroad/alert_renderer.py index 7a69679db3..65925f82b4 100644 --- a/openpilot/selfdrive/ui/onroad/alert_renderer.py +++ b/openpilot/selfdrive/ui/onroad/alert_renderer.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from openpilot.cereal import messaging, log from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.common.hardware import COMMA_HARDWARE -from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment, TextAlignmentVertical from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.widgets import Widget @@ -76,10 +76,10 @@ class AlertRenderer(Widget): self.font_bold: rl.Font = gui_app.font(FontWeight.BOLD) # font size is set dynamically - self._full_text1_label = Label("", font_size=0, font_weight=FontWeight.BOLD, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, - text_alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP) - self._full_text2_label = Label("", font_size=ALERT_FONT_BIG, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, - text_alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP) + self._full_text1_label = Label("", font_size=0, font_weight=FontWeight.BOLD, text_alignment=TextAlignment.CENTER, + text_alignment_vertical=TextAlignmentVertical.TOP) + self._full_text2_label = Label("", font_size=ALERT_FONT_BIG, text_alignment=TextAlignment.CENTER, + text_alignment_vertical=TextAlignmentVertical.TOP) def get_alert(self, sm: messaging.SubMaster) -> Alert | None: """Generate the current alert based on selfdrive state.""" diff --git a/openpilot/selfdrive/ui/onroad/cabin_camera_dialog.py b/openpilot/selfdrive/ui/onroad/cabin_camera_dialog.py index 7bb1917c35..58a645381b 100644 --- a/openpilot/selfdrive/ui/onroad/cabin_camera_dialog.py +++ b/openpilot/selfdrive/ui/onroad/cabin_camera_dialog.py @@ -4,7 +4,7 @@ from openpilot.cereal.visionipc import VisionStreamType from openpilot.selfdrive.ui.onroad.cameraview import CameraView from openpilot.selfdrive.ui.onroad.driver_state import DriverStateRenderer from openpilot.selfdrive.ui.ui_state import ui_state, device -from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.widgets.label import gui_label @@ -38,7 +38,7 @@ class CabinCameraDialog(CameraView): tr("camera starting"), font_size=100, font_weight=FontWeight.BOLD, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, + alignment=TextAlignment.CENTER, ) return -1 diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/home.py b/openpilot/selfdrive/ui/sunnypilot/layouts/home.py index a8c0790d3f..70a69ca175 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/home.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/home.py @@ -6,7 +6,7 @@ See the LICENSE.md file in the root directory for more details. """ import pyray as rl from openpilot.selfdrive.ui.layouts.home import HomeLayout, HomeLayoutState, HEAD_BUTTON_FONT_SIZE, SPACING -from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.lib.multilang import tr, trn from openpilot.system.ui.widgets.label import gui_label @@ -59,7 +59,7 @@ class HomeLayoutSP(HomeLayout): desc_size = measure_text_cached(gui_app.font(FontWeight.NORMAL), description, BRAND_FONT_SIZE) desc_width = desc_size.x desc_rect = rl.Rectangle(version_right - desc_width, self.header_rect.y, desc_width, self.header_rect.height) - gui_label(desc_rect, description, BRAND_FONT_SIZE, rl.WHITE, alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT) + gui_label(desc_rect, description, BRAND_FONT_SIZE, rl.WHITE, alignment=TextAlignment.RIGHT) brand_size = measure_text_cached(gui_app.font(FontWeight.AUDIOWIDE), brand, BRAND_FONT_SIZE) spacing = BRAND_DESC_SPACING if description else 0 diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/onboarding.py b/openpilot/selfdrive/ui/sunnypilot/layouts/onboarding.py index ee4b479afe..a23140dc9b 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/onboarding.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/onboarding.py @@ -6,7 +6,7 @@ See the LICENSE.md file in the root directory for more details. """ import pyray as rl from openpilot.selfdrive.ui.ui_state import ui_state -from openpilot.system.ui.lib.application import FontWeight +from openpilot.system.ui.lib.application import FontWeight, TextAlignment from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.button import Button, ButtonStyle @@ -20,7 +20,7 @@ class SunnylinkConsentPage(Widget): self._done_callback = done_callback self._step = 0 - self._title = self._child(Label(tr("sunnylink"), font_size=90, font_weight=FontWeight.AUDIOWIDE, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)) + self._title = self._child(Label(tr("sunnylink"), font_size=90, font_weight=FontWeight.AUDIOWIDE, text_alignment=TextAlignment.LEFT)) self._content = [ { @@ -43,7 +43,7 @@ class SunnylinkConsentPage(Widget): self._primary_btn = self._child(Button("", button_style=ButtonStyle.PRIMARY, click_callback=lambda: self._handle_choice("enable"))) self._secondary_btn = self._child(Button("", button_style=ButtonStyle.NORMAL, click_callback=lambda: self._handle_choice("secondary"))) self._danger_btn = self._child(Button("", button_style=ButtonStyle.DANGER, click_callback=lambda: self._handle_choice("disable"))) - self._desc = self._child(Label("", font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)) + self._desc = self._child(Label("", font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=TextAlignment.LEFT)) def _handle_choice(self, choice): if choice == "enable": diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py index becdceaaf0..d1abfa22a0 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py @@ -10,9 +10,10 @@ import time import pyray as rl from openpilot.cereal import custom -from openpilot.sunnypilot.models.default_model import get_default_model +from openpilot.sunnypilot.models.helpers import ACTIVE_BUNDLE_KEYS, get_selected_bundle, resolve_bundle_by_ref from openpilot.common.constants import CV from openpilot.selfdrive.ui.ui_state import device, ui_state +from openpilot.selfdrive.ui.sunnypilot.model_info import big_model_state, bundles_for_source, carrying_model, default_model_name, queued_name from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.widgets import DialogResult, Widget @@ -36,7 +37,10 @@ class ModelsLayout(Widget): super().__init__() self.model_manager = None self.model_dialog = None + self._selection_source = None self._downloading = False + self._verifying = False + self._last_note = None self.last_cache_calc_time = 0 self._initialize_items() @@ -48,17 +52,24 @@ class ModelsLayout(Widget): self._scroller = Scroller(self.items, line_separator=True, spacing=0) def _initialize_items(self): - self.current_model_item = ListItemSP( - title=tr("Current Model"), + self.small_model_item = ListItemSP( + title=tr("Small Model"), description="", action_item=ScrollingButtonAction(tr("SELECT")), - callback=self._handle_current_model_clicked + callback=lambda: self._open_source_dialog("qcom") + ) + + self.big_model_item = ListItemSP( + title=tr("Big Model"), + action_item=ScrollingButtonAction(tr("SELECT")), + callback=lambda: self._open_source_dialog("chestnut") ) self.download_item = download_status_item(lambda: tr("Download") if self._downloading else tr("Model Status")) self.refresh_item = button_item(tr("Refresh Model List"), tr("REFRESH"), "", lambda: (ui_state.params.put("ModelManager_LastSyncTime", 0), + ui_state.params.put("ModelManager_LastSyncTime_Chestnut", 0), gui_app.push_widget(alert_dialog(tr("Fetching Latest Models"))))) self.clear_cache_item = ListItemSP( @@ -68,7 +79,9 @@ class ModelsLayout(Widget): callback=self._clear_cache ) - self.cancel_download_item = button_item(tr("Cancel Download"), tr("Cancel"), "", lambda: ui_state.params.remove("ModelManager_DownloadIndex")) + self.cancel_download_item = button_item(lambda: tr("Cancel Verification") if self._verifying else tr("Cancel Download"), + tr("Cancel"), "", + lambda: ui_state.params.remove("ModelManager_DownloadRef")) self.lane_turn_value_control = option_item_sp(tr("Adjust Lane Turn Speed"), "LaneTurnValue", 500, 2000, tr("Set the maximum speed for lane turn desires. Default is 19 mph."), @@ -93,7 +106,7 @@ class ModelsLayout(Widget): 1, None, True, "", style.BUTTON_ACTION_WIDTH, None, True, lambda v: f"{v / 100:.2f} m") - self.items = [self.current_model_item, self.cancel_download_item, self.download_item, self.refresh_item, self.clear_cache_item, + self.items = [self.small_model_item, self.big_model_item, self.cancel_download_item, self.download_item, self.refresh_item, self.clear_cache_item, self.lane_turn_desire_toggle, self.lane_turn_value_control, self.lagd_toggle, self.delay_control, self.camera_offset] def _update_lagd_description(self, lagd_toggle: bool): @@ -107,16 +120,16 @@ 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 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): @@ -129,36 +142,90 @@ class ModelsLayout(Widget): gui_app.push_widget(dialog) def _handle_bundle_download_progress(self): - self.download_item.set_visible(False) self.cancel_download_item.set_visible(False) self._downloading = False - - if not self.model_manager or (not self.model_manager.selectedBundle and not self.model_manager.activeBundle): - return - - bundle = self.model_manager.selectedBundle if self._is_downloading() or ( - self.model_manager.selectedBundle and self.model_manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.failed - ) else self.model_manager.activeBundle - if not bundle: - return - - self.cancel_download_item.set_visible(bool(self.model_manager.selectedBundle) and ui_state.params.get("ModelManager_DownloadIndex") is not None) + self._verifying = False + self.download_item.set_visible(True) if (current_time := time.monotonic()) - self.last_cache_calc_time > 0.5: self.last_cache_calc_time = current_time self.clear_cache_item.action_item.set_value(f"{self.calculate_cache_size():.2f} MB") + bundle = self.model_manager.selectedBundle if self.model_manager else None + progresses = [model.artifact.downloadProgress for model in bundle.models if model.artifact.fileName] if bundle else [] + if not progresses or bundle.status not in (custom.ModelManagerSP.DownloadStatus.downloading, + custom.ModelManagerSP.DownloadStatus.failed): + self.download_item.action_item.update(name="", segments=self._slot_segments()) + return + + self.cancel_download_item.set_visible(ui_state.params.get("ModelManager_DownloadRef") is not None) if bundle.status == custom.ModelManagerSP.DownloadStatus.downloading: device._reset_interactive_timeout() - # every bundle is a single chunked artifact now - progresses = [model.artifact.downloadProgress for model in bundle.models if model.artifact.fileName] - if not progresses: - return - - self.download_item.set_visible(True) - self.download_item.action_item.update(**self._download_row_state(progresses, bundle.internalName)) + state = self._download_row_state(progresses, bundle.internalName) + if queued := queued_name(bundle.ref): + state["name"] += f" | {queued} {tr('queued')}" + self.download_item.action_item.update(**state) self._downloading = self.download_item.action_item.downloading + ds = custom.ModelManagerSP.DownloadStatus + self._verifying = any(getattr(p.status, 'raw', p.status) == ds.verifying for p in progresses) + + def _slot_segments(self): + """small and big slots side by side; green marks the slot whose pick is actually + driving (runner-matched, so a failed Default big greens neither slot), an empty + slot shows its default.""" + big_state = big_model_state() + carry_source, carry_internal, _ = carrying_model() + segments = [] + for source, label in (("qcom", tr("small")), ("chestnut", tr("big"))): + if segments: + segments.append(("|", rl.GRAY, None, None)) + bundle = get_selected_bundle(ui_state.params, source) + name = bundle.internalName if bundle else default_model_name(source) + color = ON_COLOR if (source == carry_source and name == carry_internal) else rl.LIGHTGRAY + name = "● " + name + if source == "chestnut": + if big_state == 'failed': + color = rl.RED + elif big_state == 'loading': + color = rl.GOLD + segments.append((label, rl.GRAY, None, None)) + segments.append((name, color, None, None)) + return segments + + @staticmethod + def _set_item_note(item, text): + # a description renders only while shown; hide before clearing or the + # empty description keeps its visible state + if text: + item.set_description(text) + item.show_description(True) + else: + item.show_description(False) + item.set_description("") + + def _status_note(self) -> str: + """The failover story for the Model Status row. One-way big -> small, and the + fallback is runner-matched: a Default big can only fall back to the Default + small (stock modeld), a custom big has no automatic fallback yet.""" + if not ui_state.chestnut_present: + return "" + big_bundle = get_selected_bundle(ui_state.params, "chestnut") + big_name = big_bundle.internalName if big_bundle else default_model_name("chestnut") + big_is_default = big_bundle is None + fallback_name = default_model_name("qcom") + state = big_model_state() + if state == 'failed': + if big_is_default: + return tr("Big model unavailable, {} is driving until the next drive.").format(fallback_name) + return tr("Big model unavailable until the next drive.") + if state == 'loading': + if big_is_default: + return tr("{} drives until the big model is ready.").format(fallback_name) + return tr("Getting the big model ready.") + if big_is_default: + return tr("{} will drive. If it fails during a drive, {} takes over until the next drive.").format(big_name, fallback_name) + return tr("{} will drive when the chestnut is ready.").format(big_name) @staticmethod def _download_row_state(progresses, name: str) -> dict: @@ -171,6 +238,8 @@ class ModelsLayout(Widget): if ds.failed in statuses: # close.png is authored black and a tint cannot lift it, hence close2 return {"name": name, "status_text": tr("download failed"), "text_color": rl.RED, "icon": "icons/close2.png"} + if ds.verifying in statuses: + return {"name": name, "downloading": True, "progress": progress, "status_text": tr("verifying")} if ds.downloading in statuses: return {"name": name, "downloading": True, "progress": progress} if statuses <= {ds.downloaded, ds.cached}: @@ -180,50 +249,71 @@ class ModelsLayout(Widget): def _on_model_selected(self, result): if result != DialogResult.CONFIRM: + self.model_dialog = None return selected_ref = self.model_dialog.selection_ref - if selected_ref == "Default": - ui_state.params.remove("ModelManager_ActiveBundle") - 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) self.model_dialog = None + if selected_ref == "Default": + if self._selection_source in ACTIVE_BUNDLE_KEYS: + ui_state.params.remove(ACTIVE_BUNDLE_KEYS[self._selection_source]) + return + if selected_bundle := self._resolve_selected_bundle(selected_ref): + ui_state.params.put("ModelManager_DownloadRef", selected_bundle.ref) + + def _resolve_selected_bundle(self, ref): + source_bundles = {source: bundles_for_source(source) for source in ("qcom", "chestnut")} + resolved = resolve_bundle_by_ref(ref, source_bundles) + return resolved[0] if resolved else None @staticmethod def _bundle_to_node(bundle): return TreeNode(bundle.ref, {'display_name': bundle.displayName, 'short_name': bundle.internalName}) - def _get_folders(self, favorites): - bundles = self.model_manager.availableBundles + def _get_folders(self, favorites, bundles): folders = {} for bundle in bundles: folders.setdefault(next((ov_ride.value for ov_ride in bundle.overrides if ov_ride.key == "folder"), ""), []).append(bundle) - folders_list = [TreeFolder("", [TreeNode("Default", {'display_name': f"{get_default_model()} (Default)", - 'short_name': "Default"})])] + folders_list = [] for folder, folder_bundles in sorted(folders.items(), key=lambda x: max((bundle.index for bundle in x[1]), default=-1), reverse=True): folder_bundles.sort(key=lambda bundle: bundle.index, reverse=True) name = folder + (f" - (Updated: {m.group(1)})" if folder_bundles and (m := re.search(r'\(([^)]*)\)[^(]*$', folder_bundles[0].displayName)) else "") folders_list.append(TreeFolder(name, [self._bundle_to_node(bundle) for bundle in folder_bundles])) if favorites and (fav_bundles := [bundle for bundle in bundles if bundle.ref in favorites]): - folders_list.insert(1, TreeFolder("Favorites", [self._bundle_to_node(bundle) for bundle in fav_bundles])) + folders_list.insert(0, TreeFolder("Favorites", [self._bundle_to_node(bundle) for bundle in fav_bundles])) return folders_list - def _handle_current_model_clicked(self): + def _open_source_dialog(self, source): + self._selection_source = source favs = ui_state.params.get("ModelManager_Favs") favorites = set(favs.split(';')) if favs else set() - folders_list = self._get_folders(favorites) - - active_ref = self.model_manager.activeBundle.ref if self.model_manager.activeBundle else "Default" - self.model_dialog = TreeOptionDialog(tr("Select a Model"), folders_list, active_ref, "ModelManager_Favs", - get_folders_fn=self._get_folders, on_exit=self._on_model_selected) + folders_list = self._source_folders(favorites, source) + if not folders_list: + gui_app.push_widget(alert_dialog(tr("No models are available for this hardware yet. Connect to the internet and refresh the model list."))) + return + self.model_dialog = TreeOptionDialog(tr("Select a Model"), folders_list, self._slot_active_ref(source), "ModelManager_Favs", + get_folders_fn=lambda favs: self._source_folders(favs, source), on_exit=self._on_model_selected) gui_app.push_widget(self.model_dialog) + def _source_folders(self, favorites, source): + bundles = bundles_for_source(source) + if not bundles: + return [] + folders_list = [TreeFolder("", [TreeNode("Default", {'display_name': default_model_name(source)})])] + folders_list.extend(self._get_folders(favorites, bundles)) + return folders_list + + @staticmethod + def _slot_active_ref(source: str) -> str: + bundle = get_selected_bundle(ui_state.params, source) + return bundle.ref if bundle else "Default" + def _update_state(self): advanced_controls: bool = ui_state.params.get_bool("ShowAdvancedControls") turn_desire: bool = ui_state.params.get_bool("LaneTurnDesire") 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) @@ -237,19 +327,27 @@ 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 - self.current_model_item.action_item.set_value(active_name) - if not ui_state.is_offroad(): - self.current_model_item.action_item.set_enabled(False) - self.current_model_item.set_description(tr("Only available when vehicle is off, or always offroad mode is on")) - else: - self.current_model_item.action_item.set_enabled(True) - self.current_model_item.set_description("") + carry_source, _, carry_display = carrying_model() + for item, item_source in ((self.small_model_item, "qcom"), (self.big_model_item, "chestnut")): + bundle = get_selected_bundle(ui_state.params, item_source) + name = bundle.displayName if bundle else default_model_name(item_source) + color = ON_COLOR if (item_source == carry_source and name == carry_display) else style.ITEM_TEXT_VALUE_COLOR + item.action_item.set_value(name, color) + + note = self._status_note() + if note != self._last_note: + self._last_note = note + self._set_item_note(self.download_item, note) + + offroad = ui_state.is_offroad() + self.small_model_item.action_item.set_enabled(offroad) + self.big_model_item.action_item.set_enabled(offroad) + self.small_model_item.set_description("" if offroad else tr("Only available when vehicle is off, or always offroad mode is on")) def _render(self, rect): self._scroller.render(rect) def show_event(self): self._scroller.show_event() + self._last_note = None # re-expand the failover note every time the page opens diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/osm.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/osm.py index 7b30e880f9..8e1c4afe72 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/osm.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/osm.py @@ -8,7 +8,6 @@ import datetime import os import platform import requests -import shutil import threading from pathlib import Path from time import monotonic @@ -75,22 +74,12 @@ class OSMLayout(Widget): def _update_map_size(self): threading.Thread(target=self.calculate_size, daemon=True).start() - def _do_delete_maps(self): - if MAP_PATH.exists(): - shutil.rmtree(MAP_PATH) - - for param in ("OsmDownloadedDate", "OsmLocal", "OsmLocationName", "OsmLocationTitle", "OsmStateName", "OsmStateTitle"): - ui_state.params.remove(param) - + def _on_confirm_delete_maps(self): + ui_state.params.put_bool("Mapd_ClearCache", True) self._delete_maps_btn.action_item.set_enabled(True) self._delete_maps_btn.action_item.set_text(tr("DELETE")) self._update_map_size() - def _on_confirm_delete_maps(self): - self._delete_maps_btn.action_item.set_enabled(False) - self._delete_maps_btn.action_item.set_text("DELETING...") - threading.Thread(target=self._do_delete_maps).start() - def _delete_maps(self): self._show_confirm(tr("This will delete ALL downloaded maps\n\nAre you sure you want to delete all maps?"), tr("Yes, delete all maps"), self._on_confirm_delete_maps) diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py index 5e75e797cb..5664ab8286 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py @@ -9,7 +9,7 @@ from openpilot.cereal import custom from openpilot.selfdrive.ui.sunnypilot.layouts.onboarding import SunnylinkConsentPage 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, FontWeight +from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment, TextAlignmentVertical from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.sunnypilot.widgets.list_view import button_item_sp from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp @@ -32,8 +32,8 @@ class SunnylinkHeader(Widget): font_size=90, font_weight=FontWeight.AUDIOWIDE, text_color=rl.WHITE, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, + alignment=TextAlignment.CENTER, + alignment_vertical=TextAlignmentVertical.TOP, wrap_text=False, elide=False ) @@ -43,8 +43,8 @@ class SunnylinkHeader(Widget): font_size=40, font_weight=FontWeight.NORMAL, text_color=rl.Color(0, 255, 0, 255), # Green - alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, + alignment=TextAlignment.CENTER, + alignment_vertical=TextAlignmentVertical.TOP, wrap_text=True, elide=False ) @@ -55,8 +55,8 @@ class SunnylinkHeader(Widget): font_size=35, font_weight=FontWeight.NORMAL, text_color=rl.Color(255, 165, 0, 255), # Orange - alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, + alignment=TextAlignment.CENTER, + alignment_vertical=TextAlignmentVertical.TOP, wrap_text=True, elide=False ) @@ -109,8 +109,8 @@ class SunnylinkDescriptionItem(Widget): font_size=40, font_weight=FontWeight.NORMAL, text_color=rl.WHITE, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, + alignment=TextAlignment.LEFT, + alignment_vertical=TextAlignmentVertical.TOP, wrap_text=True, elide=False, ) diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/sidebar.py b/openpilot/selfdrive/ui/sunnypilot/layouts/sidebar.py index 79bb15dbb8..815e7018a4 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.selfdrive.ui.ui_state import ui_state, ChestnutState 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) +CHESTNUT_ICON_WIDTH = 180 +CHESTNUT_ICON_HEIGHT = 133 + # Color scheme class Colors: @@ -53,6 +59,9 @@ class MetricData: class SidebarSP: def __init__(self): self._sunnylink_status = MetricData(tr_noop("SUNNYLINK"), tr_noop("OFFLINE"), Colors.WARNING) + self._chestnut_green_img = gui_app.texture("icons_mici/chestnut_green.png", CHESTNUT_ICON_WIDTH, CHESTNUT_ICON_HEIGHT) + self._chestnut_default_img = gui_app.texture("icons_mici/chestnut.png", CHESTNUT_ICON_WIDTH, CHESTNUT_ICON_HEIGHT) + self._chestnut_orange_img = gui_app.texture("icons_mici/chestnut_orange.png", CHESTNUT_ICON_WIDTH, CHESTNUT_ICON_HEIGHT) def _update_sunnylink_status(self): if not ui_state.params.get_bool("SunnylinkEnabled"): @@ -78,6 +87,24 @@ 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) + state = ui_state.chestnut_state + if state == ChestnutState.DISCONNECTED: + return default_img, default_pos, 1.0 + + if state == ChestnutState.LOADING: + icon = self._chestnut_default_img + opacity = 0.35 + 0.65 * (0.5 - 0.5 * math.cos(rl.get_time() * 6.0)) + elif state in (ChestnutState.UNCOMPILED, ChestnutState.FAILED): + icon, opacity = self._chestnut_orange_img, 1.0 + else: + icon, opacity = self._chestnut_green_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 diff --git a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/home.py b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/home.py index d29e579c52..62dea4267f 100644 --- a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/home.py +++ b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/home.py @@ -4,7 +4,12 @@ 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, ChestnutState from openpilot.system.ui.lib.application import FontWeight from openpilot.system.ui.widgets.label import UnifiedLabel @@ -13,3 +18,16 @@ 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) + + def _set_chestnut_visibility(self): + usb_connected = ui_state.usb_connected + usb_unknown = ui_state.usb_unknown + chestnut_state = ui_state.chestnut_state + loading = chestnut_state == ChestnutState.LOADING + + self._usb_icon.set_visible(usb_connected and usb_unknown) + self._chestnut_loading_icon.set_opacity(0.35 + 0.65 * (0.5 - 0.5 * math.cos(rl.get_time() * 6.0))) + self._chestnut_loading_icon.set_visible(not usb_unknown and loading) + self._chestnut_icon.set_visible(not usb_unknown and not loading and + chestnut_state in (ChestnutState.READY, ChestnutState.ACTIVE)) + self._chestnut_failed_icon.set_visible(not usb_unknown and chestnut_state in (ChestnutState.UNCOMPILED, ChestnutState.FAILED)) diff --git a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py index 6eff456559..4c769498f5 100644 --- a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py @@ -7,16 +7,37 @@ See the LICENSE.md file in the root directory for more details. import pyray as rl from openpilot.cereal import custom -from openpilot.sunnypilot.models.default_model import get_default_model +from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog +from openpilot.sunnypilot.models.helpers import ACTIVE_BUNDLE_KEYS, get_selected_bundle from openpilot.selfdrive.ui.mici.widgets.button import BigButton -from openpilot.selfdrive.ui.sunnypilot.layouts.settings.models import ModelsLayout from openpilot.selfdrive.ui.ui_state import ui_state, device +from openpilot.selfdrive.ui.sunnypilot.model_info import (active_source, big_model_state, bundles_for_source, carrying_model, + default_model_name, model_info, queued_name) from openpilot.system.ui.lib.application import FontWeight, gui_app from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.label import UnifiedLabel from openpilot.system.ui.widgets.scroller import NavScroller +def _model_info() -> tuple[str, str, str]: + """(active model, info header, info text) for the panel. Runner-matched: the + active line names what actually drives, and a notable big-model state takes + the info pair.""" + source, active_name, other_name = model_info() + state = big_model_state() + _, _, carry_display = carrying_model() + if carry_display is None: + big = get_selected_bundle(ui_state.params, "chestnut") + carry_display = big.displayName if big else default_model_name("chestnut") + 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 == "chestnut" else tr("big model") + return active_text, header, other_name.lower() + + class CurrentModelInfo(Widget): def __init__(self): super().__init__() @@ -26,12 +47,12 @@ class CurrentModelInfo(Widget): header_color = rl.Color(255, 255, 255, int(255 * 0.9)) subheader_color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)) max_width = int(self._rect.width - 20) + active_text, info_header, info_text = _model_info() self.current_model_header = UnifiedLabel(tr("active model"), 48, max_width=max_width, text_color=header_color, font_weight=FontWeight.DISPLAY) - default_text = f"{get_default_model()} (Default)".lower() - self.current_model_text = UnifiedLabel(default_text, 32, max_width=max_width, text_color=subheader_color, font_weight=FontWeight.ROMAN, scroll=True) + self.current_model_text = UnifiedLabel(active_text, 32, max_width=max_width, text_color=subheader_color, font_weight=FontWeight.ROMAN, scroll=True) - self.info_header = UnifiedLabel("cache size", 48, max_width=max_width, text_color=header_color, font_weight=FontWeight.DISPLAY) - self.info_text = UnifiedLabel("0 mb", 32, max_width=max_width, text_color=subheader_color, font_weight=FontWeight.ROMAN) + self.info_header = UnifiedLabel(info_header, 48, max_width=max_width, text_color=header_color, font_weight=FontWeight.DISPLAY) + self.info_text = UnifiedLabel(info_text, 32, max_width=max_width, text_color=subheader_color, font_weight=FontWeight.ROMAN, scroll=True) def _render(self, _): self.current_model_header.set_position(self._rect.x + 20, self._rect.y - 10) @@ -55,12 +76,13 @@ class ModelsLayoutMici(NavScroller): self._download_progress = "." self._download_frame = 0 self._was_downloading = False + self._selection_source: str | None = None self.select_model_btn = BigButton(tr("select model")) self.select_model_btn.set_click_callback(self._show_folders) 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) @@ -69,8 +91,7 @@ class ModelsLayoutMici(NavScroller): def model_manager(self): return ui_state.sm["modelManagerSP"] - def _get_grouped_bundles(self, favorites = None): - bundles = self.model_manager.availableBundles + def _get_grouped_bundles(self, bundles, favorites = None): folders = {} for bundle in bundles: folder = next((override.value for override in bundle.overrides if override.key == "folder"), "") @@ -90,47 +111,70 @@ class ModelsLayoutMici(NavScroller): def _show_folders(self): self.focused_widget = self.select_model_btn + hardware_btns = [] + active = active_source() + for source, label in (("qcom", tr("small models")), ("chestnut", tr("big models"))): + bundle = get_selected_bundle(ui_state.params, source) + value = (bundle.internalName if bundle else default_model_name(source)).lower() + if source == active: + value += f" ({tr('active')})" + btn = BigButton(label.lower(), value=value) + btn.set_click_callback(lambda s=source: self._select_hardware(s)) + hardware_btns.append(btn) + self._push_selection_view(hardware_btns) + + def _select_hardware(self, source): + self._selection_source = source + favs = ui_state.params.get("ModelManager_Favs") favorites = set(favs.split(';')) if favs else set() - folders = self._get_grouped_bundles(favorites) + bundles = bundles_for_source(source) + if not bundles: + gui_app.push_widget(BigDialog(title=tr("No models available"), + description=tr("No models are available for this hardware yet. Connect to the internet and refresh the model list."))) + return + folders = self._get_grouped_bundles(bundles, favorites) + folder_buttons = [] - default_btn = BigButton(f"{get_default_model()} (Default)".lower()) - default_btn.set_click_callback(self._select_default) + default_btn = BigButton(default_model_name(source).lower()) + default_btn.set_click_callback(lambda s=source: self._select_default(s)) folder_buttons.append(default_btn) for folder in sorted(folders.keys(), key=lambda f: max((bundle.index for bundle in folders[f]), default=-1), reverse=True): - if folder.lower() in ["release models", "master models", "favorites"]: - btn = BigButton(folder.lower()) - btn.set_click_callback(lambda f=folder: self._select_folder(f)) - if folder.lower() == "favorites": - folder_buttons.insert(0, btn) - else: - folder_buttons.append(btn) + btn = BigButton(folder.lower()) + btn.set_click_callback(lambda f=folder: self._select_folder(f)) + if folder.lower() == "favorites": + folder_buttons.insert(0, btn) + else: + folder_buttons.append(btn) self._push_selection_view(folder_buttons) def _pop_to_main(self): gui_app.pop_widgets_to(self) + self._scroller.scroll_panel.set_offset(0.0) def _select_model(self, bundle): - ui_state.params.put("ModelManager_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") + def _select_default(self, source): + ui_state.params.remove(ACTIVE_BUNDLE_KEYS[source]) self._pop_to_main() def _select_folder(self, folder_name): + source = self._selection_source + if source is None: # folders are only reachable after picking a hardware + return favs = ui_state.params.get("ModelManager_Favs") favorites = set(favs.split(';')) if favs else set() - folders = self._get_grouped_bundles(favorites) + folders = self._get_grouped_bundles(bundles_for_source(source), favorites) bundles = sorted(folders.get(folder_name, []), key=lambda b: b.index, reverse=True) btns = [] for bundle in bundles: - txt = bundle.displayName.lower() - btn = BigButton(txt) + btn = BigButton(bundle.displayName.lower()) btn.set_click_callback(lambda b=bundle: self._select_model(b)) btns.append(btn) self._push_selection_view(btns) @@ -162,11 +206,10 @@ 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 - self.current_model_info.current_model_text.set_text(model_text) - self.current_model_info.info_header.set_text(tr("cache size")) - self.current_model_info.info_text.set_text(f"{ModelsLayout.calculate_cache_size():.2f} MB") + active_text, info_header, info_text = _model_info() + self.current_model_info.current_model_text.set_text(active_text) + self.current_model_info.info_header.set_text(info_header) + self.current_model_info.info_text.set_text(info_text) if manager.selectedBundle and manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.failed: self.current_model_info.info_header.set_text(tr("error") + self._download_progress) @@ -177,18 +220,29 @@ class ModelsLayoutMici(NavScroller): device.set_override_interactive_timeout(5) progress = 0.0 count = 0 + verifying = False for model in manager.selectedBundle.models: count += 1 p = model.artifact.downloadProgress - if p.status == custom.ModelManagerSP.DownloadStatus.downloading: + if p.status in (custom.ModelManagerSP.DownloadStatus.downloading, + custom.ModelManagerSP.DownloadStatus.verifying): progress += p.progress + verifying = verifying or p.status == custom.ModelManagerSP.DownloadStatus.verifying elif p.status in (custom.ModelManagerSP.DownloadStatus.downloaded, custom.ModelManagerSP.DownloadStatus.cached): progress += 100.0 - self.current_model_info.current_model_header.set_text(tr("downloading")) + self.current_model_info.current_model_header.set_text(tr("verifying") if verifying else tr("downloading")) + self.cancel_download_btn.set_text(tr("cancel verification") if verifying else tr("cancel download")) self.current_model_info.current_model_header._shimmer = True - self.current_model_info.current_model_text.set_text(f"{manager.selectedBundle.internalName.lower()}") + name_text = manager.selectedBundle.internalName.lower() + if queued := queued_name(manager.selectedBundle.ref): + name_text += f" | {queued.lower()} {tr('queued')}" + self.current_model_info.current_model_text.set_text(name_text) self.current_model_info.info_header.set_text(tr("progress") + self._download_progress) self.current_model_info.info_header._shimmer = True self.current_model_info.info_text.set_text(f"{progress/count:.2f}%") + + elif manager.selectedBundle and manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.downloaded: + self.current_model_info.info_header.set_text(tr("downloaded")) + self.current_model_info.info_text.set_text(tr("downloaded")) diff --git a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/settings.py b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/settings.py index f14efe51a3..4c0eba41ea 100644 --- a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/settings.py +++ b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/settings.py @@ -12,13 +12,23 @@ from openpilot.selfdrive.ui.mici.widgets.dialog import BigConfirmationDialog, Bi from openpilot.selfdrive.ui.sunnypilot.mici.layouts.sunnylink import SunnylinkLayoutMici from openpilot.selfdrive.ui.sunnypilot.mici.layouts.models import ModelsLayoutMici from openpilot.selfdrive.ui.ui_state import ui_state -from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.multilang import tr ICON_SIZE = 70 BIG_ICON_SIZE = 110 +class SunnylinkBigButton(SettingsBigButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._label.set_font_weight(FontWeight.AUDIOWIDE) + + def _get_label_font_size(self): + # Audiowide runs wider than Inter: "sunnylink" wraps to two lines at 64 + return 56 + + class SettingsLayoutSP(OP.SettingsLayout): def __init__(self): OP.SettingsLayout.__init__(self) @@ -33,7 +43,7 @@ class SettingsLayoutSP(OP.SettingsLayout): self.icon_offroad_slider = gui_app.texture("icons_mici/settings/device/lkas.png", BIG_ICON_SIZE, BIG_ICON_SIZE) sunnylink_panel = SunnylinkLayoutMici() - sunnylink_btn = SettingsBigButton(tr("sunnylink"), "", gui_app.texture("icons_mici/settings/developer/ssh.png", 55, 55)) + sunnylink_btn = SunnylinkBigButton(tr("sunnylink"), "", gui_app.texture("../../sunnypilot/selfdrive/assets/icons_mici/sunnylink.png", 76, 44)) sunnylink_btn.set_click_callback(lambda: gui_app.push_widget(sunnylink_panel)) models_panel = ModelsLayoutMici() @@ -56,8 +66,8 @@ class SettingsLayoutSP(OP.SettingsLayout): items = self._scroller._items.copy() - items.insert(1, sunnylink_btn) - items.insert(2, models_btn) + items.insert(1, models_btn) + items.insert(5, sunnylink_btn) # front slots (only one ever visible at a time): exit-always-offroad, then enable-onroad items.insert(0, self._enable_offroad_btn_onroad) diff --git a/openpilot/selfdrive/ui/sunnypilot/model_info.py b/openpilot/selfdrive/ui/sunnypilot/model_info.py new file mode 100644 index 0000000000..e338feb9ed --- /dev/null +++ b/openpilot/selfdrive/ui/sunnypilot/model_info.py @@ -0,0 +1,85 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +from openpilot.selfdrive.ui.ui_state import ui_state, ChestnutState +from openpilot.sunnypilot.models.fetcher import get_cached_bundles +from openpilot.sunnypilot.models.helpers import get_active_source, get_selected_bundle, resolve_bundle_by_ref +from openpilot.sunnypilot.models.model_name import DEFAULT_BIG_MODEL, DEFAULT_MODEL + + +def active_source() -> str: + return get_active_source(chestnut=ui_state.chestnut_present, + chestnut_active=ui_state.chestnut_active, chestnut_loading=ui_state.chestnut_loading, + offroad=ui_state.is_offroad()) + + +def bundles_for_source(source: str): + if source == active_source(): + return ui_state.sm["modelManagerSP"].availableBundles + return get_cached_bundles(ui_state.params, source) + + +def default_model(source: str) -> str: + return DEFAULT_BIG_MODEL if source == 'chestnut' else DEFAULT_MODEL + + +def default_model_name(source: str) -> str: + return f"{default_model(source)} (Default)" + + +def big_model_state() -> str | None: + """'failed' | 'loading' | None, from the same state the icons render.""" + return {ChestnutState.UNCOMPILED: 'failed', + ChestnutState.FAILED: 'failed', + ChestnutState.LOADING: 'loading'}.get(ui_state.chestnut_state) + + +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 == "chestnut": + bundle = get_selected_bundle(ui_state.params, "chestnut") + if bundle: + return "chestnut", bundle.internalName, bundle.displayName + name = default_model_name("chestnut") + return "chestnut", name, name + if ui_state.chestnut_present: + if get_selected_bundle(ui_state.params, "chestnut") 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", "chestnut")} + if resolved := resolve_bundle_by_ref(ref, source_bundles): + return resolved[0].internalName + return None + + +def model_info() -> tuple[str, str, str]: + """returns (active source, active model name, other model name) + + Names come from the params slots, never modelManagerSP.activeBundle — the + manager republishes a tick after a chestnut change, so the stale bundle + would flash the wrong model.""" + source = active_source() + other = "qcom" if source == "chestnut" else "chestnut" + active_bundle = get_selected_bundle(ui_state.params, source) + other_bundle = get_selected_bundle(ui_state.params, other) + + active_name = active_bundle.displayName if active_bundle else default_model_name(source) + other_name = other_bundle.displayName if other_bundle else default_model_name(other) + return source, active_name, other_name diff --git a/openpilot/selfdrive/ui/sunnypilot/ui_state.py b/openpilot/selfdrive/ui/sunnypilot/ui_state.py index 602830a4db..d130c3862a 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 @@ -43,6 +44,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 @@ -150,7 +152,13 @@ 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(chestnut=self.chestnut_present, chestnut_active=self.chestnut_active, + chestnut_loading=self.chestnut_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" + # stock only counts the default big model's compiled pkl. a downloaded big bundle runs on the + # chestnut just the same, so ChestnutState has to see it as available too. + self.chestnut_compiled = self.chestnut_compiled or self.model_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) diff --git a/openpilot/selfdrive/ui/ui_state.py b/openpilot/selfdrive/ui/ui_state.py index e0aca74ff2..a101f93d57 100644 --- a/openpilot/selfdrive/ui/ui_state.py +++ b/openpilot/selfdrive/ui/ui_state.py @@ -12,7 +12,8 @@ from openpilot.common.swaglog import cloudlog from openpilot.selfdrive.ui.lib.prime_state import PrimeState from openpilot.system.ui.lib.application import gui_app from openpilot.common.hardware import HARDWARE, PC -from openpilot.selfdrive.modeld.helpers import usbgpu_compiled +from openpilot.common.hardware.usb import TYPEC_CC_ORIENTATION_PATH, get_usb_state, is_chestnut_usb_id, read_int +from openpilot.selfdrive.modeld.helpers import chestnut_compiled from openpilot.selfdrive.ui.sunnypilot.ui_state import UIStateSP, DeviceSP @@ -28,6 +29,15 @@ class UIStatus(Enum): LONG_ONLY = "long_only" +class ChestnutState(Enum): + DISCONNECTED = "disconnected" + UNCOMPILED = "uncompiled" + READY = "ready" + LOADING = "loading" + ACTIVE = "active" + FAILED = "failed" + + class UIState(UIStateSP): _instance: 'UIState | None' = None @@ -82,10 +92,15 @@ class UIState(UIStateSP): self.always_on_dm: bool = self.params.get_bool("AlwaysOnDM") self.experimental_mode: bool = self.params.get_bool("ExperimentalMode") self.experimental_mode_confirmed: bool = self.params.get_bool("ExperimentalModeConfirmed") - self.usbgpu: bool = False - self.usbgpu_compiled: bool = usbgpu_compiled() - self.usbgpu_active: bool | None = self.params.get("UsbGpuActive") - self.usbgpu_loading: bool = self.params.get_bool("UsbGpuLoading") + self.chestnut_present: bool = False + self.chestnut_compiled: bool = chestnut_compiled() + self.chestnut_active: bool | None = None + self.chestnut_loading: bool = False + self.usb_connected: bool = False + self.usb_connected_ts: float | None = None + self.usb_disconnected_ts: float | None = None + self.usb_unknown: bool = False + self.chestnut_state = ChestnutState.DISCONNECTED self.started: bool = False self.ignition: bool = False self.recording_audio: bool = False @@ -131,6 +146,7 @@ class UIState(UIStateSP): self.sm.update(0) self._update_state() self._update_status() + self._update_chestnut_state() device.update() UIStateSP.update(self) @@ -194,12 +210,35 @@ class UIState(UIStateSP): self.status = UIStatus.DISENGAGED self.started_frame = self.sm.frame self.started_time = time.monotonic() + self.chestnut_present = self.sm["deviceState"].chestnutPresent for callback in self._offroad_transition_callbacks: callback() self._started_prev = self.started + def _update_chestnut_state(self) -> None: + detected = self.sm["deviceState"].chestnutPresent + if not self.started: + self.chestnut_present = detected + self.chestnut_state = (ChestnutState.READY if detected and self.chestnut_compiled else + ChestnutState.UNCOMPILED if detected else ChestnutState.DISCONNECTED) + return + + model_seen = self.sm.recv_frame["modelV2"] > self.started_frame + if not self.chestnut_present: + self.chestnut_state = ChestnutState.DISCONNECTED + elif not self.chestnut_compiled: + self.chestnut_state = ChestnutState.UNCOMPILED + elif self.chestnut_state == ChestnutState.FAILED or not detected or (model_seen and (not self.sm.alive["modelV2"] or not self.sm["modelV2"].big)): + self.chestnut_state = ChestnutState.FAILED + elif self.chestnut_loading or not model_seen: + self.chestnut_state = ChestnutState.LOADING + elif self.chestnut_active is False: + self.chestnut_state = ChestnutState.FAILED + else: + self.chestnut_state = ChestnutState.ACTIVE + def update_params(self) -> None: # For slower operations # Update longitudinal control state @@ -216,12 +255,27 @@ class UIState(UIStateSP): self.always_on_dm = self.params.get_bool("AlwaysOnDM") self.experimental_mode = self.params.get_bool("ExperimentalMode") self.experimental_mode_confirmed = self.params.get_bool("ExperimentalModeConfirmed") - # keep usbgpu UI active until offroad transition when gpu disappears - self.usbgpu = self.sm["deviceState"].chestnutPresent or (self.usbgpu and self.started) - if not self.usbgpu_compiled: - self.usbgpu_compiled = usbgpu_compiled() - self.usbgpu_active = self.params.get("UsbGpuActive") - self.usbgpu_loading = self.params.get_bool("UsbGpuLoading") + if not self.chestnut_compiled: + self.chestnut_compiled = chestnut_compiled() + self.chestnut_active = self.params.get("ChestnutActive") + self.chestnut_loading = self.params.get_bool("ChestnutLoading") + now = time.monotonic() + if read_int(TYPEC_CC_ORIENTATION_PATH) != 0: + self.usb_disconnected_ts = None + if not self.usb_connected: + self.usb_connected = True + self.usb_connected_ts = now + self.usb_unknown = False + elif self.usb_connected_ts is not None and now - self.usb_connected_ts > 10.: + self.usb_unknown = not any(is_chestnut_usb_id(d["vendorId"], d["productId"], True) for d in get_usb_state()) + self.usb_connected_ts = None + elif self.usb_connected: + if self.usb_disconnected_ts is None: + self.usb_disconnected_ts = now + elif now - self.usb_disconnected_ts > PARAM_UPDATE_TIME: + self.usb_connected = False + self.usb_connected_ts = None + self.usb_unknown = False UIStateSP.update_params(self) diff --git a/openpilot/sunnypilot/mapd/mapd_manager.py b/openpilot/sunnypilot/mapd/mapd_manager.py index 2251289bbe..899b0c2bd9 100755 --- a/openpilot/sunnypilot/mapd/mapd_manager.py +++ b/openpilot/sunnypilot/mapd/mapd_manager.py @@ -55,6 +55,19 @@ def cleanup_old_osm_data(files_to_remove: list[str]) -> None: shutil.rmtree(file, ignore_errors=False) +def clear_downloaded_maps() -> None: + """Deletes downloaded OSM map data and resets params.""" + path = f"{Paths.mapd_root()}/offline" + if os.path.exists(path): + shutil.rmtree(path, ignore_errors=True) + + for param in ("OsmDownloadedDate", "OsmLocal", "OsmLocationName", "OsmLocationTitle", + "OsmStateName", "OsmStateTitle"): + params.remove(param) + + cloudlog.info("mapd: downloaded maps cleared") + + def request_refresh_osm_location_data(nations: list[str], states: list[str] | None = None) -> None: params.put("OsmDownloadedDate", str(datetime.now().timestamp()), block=True) params.put_bool("OsmDbUpdatesCheck", False, block=True) @@ -131,6 +144,10 @@ def main_thread(): show_alert = bool(get_files_for_cleanup() and params.get_bool("OsmLocal")) set_offroad_alert("Offroad_OSMUpdateRequired", show_alert, "This alert will be cleared when new maps are downloaded.") + if params.get("Mapd_ClearCache"): + clear_downloaded_maps() + params.remove("Mapd_ClearCache") + update_osm_db() live_map_sp.tick() rk.keep_time() diff --git a/openpilot/sunnypilot/modeld_v2/compile_modeld.py b/openpilot/sunnypilot/modeld_v2/compile_modeld.py index 85ae57c078..7a58352c36 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: @@ -295,7 +298,7 @@ def _load_policy_runners(args: argparse.Namespace) -> tuple[list, list]: if __name__ == "__main__": - if 'USB' in os.getenv('DEV', '') or os.getenv('USBGPU'): + if 'USB' in os.getenv('DEV', '') or os.getenv('CHESTNUT'): from openpilot.system.hardware.chestnut.flash import link_up for _ in range(10): if link_up(): diff --git a/openpilot/sunnypilot/modeld_v2/modeld.py b/openpilot/sunnypilot/modeld_v2/modeld.py index 9f3d709537..cd421d70d5 100755 --- a/openpilot/sunnypilot/modeld_v2/modeld.py +++ b/openpilot/sunnypilot/modeld_v2/modeld.py @@ -8,22 +8,22 @@ See the LICENSE.md file in the root directory for more details. import os os.environ['GMMU'] = '0' -from openpilot.common.hardware import COMMA_HARDWARE -from openpilot.selfdrive.modeld.helpers import usbgpu_present, load_oob -import time import numpy as np +import threading +import time +from setproctitle import setproctitle +from tinygrad.tensor import Tensor + import openpilot.cereal.messaging as messaging +from openpilot.common.hardware import COMMA_HARDWARE +from openpilot.selfdrive.modeld.helpers import chestnut_present, load_oob from openpilot.cereal import log from opendbc.car.structs import car from openpilot.cereal.services import SERVICE_LIST -from setproctitle import setproctitle from openpilot.cereal.messaging import PubMaster, SubMaster from openpilot.cereal.visionipc import VisionStreamType from msgq.visionipc import VisionIpcClient, VisionBuf from opendbc.car.car_helpers import get_demo_car_params - -from tinygrad.tensor import Tensor - from openpilot.common.file_chunker import open_file_chunked from openpilot.common.swaglog import cloudlog from openpilot.common.params import Params @@ -42,13 +42,13 @@ from openpilot.sunnypilot.modeld_v2.constants import Plan from openpilot.sunnypilot.modeld_v2.meta_helper import load_meta_constants from openpilot.sunnypilot.modeld_v2.camera_offset_helper import CameraOffsetHelper from openpilot.sunnypilot.modeld_v2.compile_modeld import derive_frame_skip, make_split_input_queues, make_supercombo_input_queues, WARP_INPUTS, POLICY_INPUTS - from openpilot.sunnypilot.livedelay.helpers import get_lat_delay from openpilot.sunnypilot.modeld_v2.modeld_base import ModelStateBase from openpilot.sunnypilot.models.helpers import get_active_bundle from openpilot.sunnypilot.selfdrive.controls.lib.relc import RoadEdgeLaneChangeController PROCESS_NAME = "openpilot.selfdrive.modeld.modeld_tinygrad" +BIG_MODEL_TIMEOUT = 60 def _pkl_exists(path): @@ -68,6 +68,7 @@ def _find_driving_pkl(bundle): pkl_path = os.path.join(model_root, pkl_name) if _pkl_exists(pkl_path): return pkl_path + return None class FrameMeta: @@ -84,14 +85,14 @@ class ModelState(ModelStateBase): inputs: dict[str, np.ndarray] prev_desire: np.ndarray - def __init__(self, cam_w: int, cam_h: int, usbgpu: bool = False): + def __init__(self, cam_w: int, cam_h: int, chestnut: bool = False): ModelStateBase.__init__(self) env_pkl = os.environ.get('COMBINED_MODEL_PKL') if env_pkl and os.path.exists(env_pkl): model_bundle = None else: - model_bundle = get_active_bundle() + model_bundle = get_active_bundle(chestnut=chestnut) 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 {} @@ -99,10 +100,10 @@ class ModelState(ModelStateBase): self.LONG_SMOOTH_SECONDS = float(overrides.get('long', ".0")) self.MIN_LAT_CONTROL_SPEED = 0.3 self.PLANPLUS_CONTROL: float = 1.0 - self.usbgpu = usbgpu + self.chestnut = chestnut pkl_path = _find_driving_pkl(model_bundle) - assert pkl_path is not None, "No driving pkl found — all models must be compiled with compile_modeld.py" + assert pkl_path is not None, f"No driving pkl found for {'chestnut' if chestnut else 'small model'} — all models must be compiled with compile_modeld.py" self._init_combined(pkl_path, cam_w, cam_h, model_bundle) def _init_combined(self, pkl_path, cam_w, cam_h, bundle): @@ -110,7 +111,7 @@ class ModelState(ModelStateBase): jits = load_oob(open_file_chunked(pkl_path)) self.WARP_DEV = 'QCOM' if COMMA_HARDWARE else 'CPU' - self.DEV = 'AMD' if self.usbgpu else self.WARP_DEV + self.DEV = 'AMD' if self.chestnut else self.WARP_DEV self.QUEUE_DEV = self.DEV metadata = jits['metadata'] @@ -185,9 +186,6 @@ class ModelState(ModelStateBase): else: self.warp(**{k: self.input_queues[k] for k in WARP_INPUTS}, frame=frame_tensor, big_frame=big_frame_tensor) - if self.usbgpu: - self.warmup() - def warmup(self) -> None: dummy_frames = {k: np.zeros(self.frame_buf_params[k][3], dtype=np.uint8) for k in self._vision_input_names} transforms = {k: np.eye(3, dtype=np.float32) for k in [self._road_key, self._wide_key] if k} @@ -287,9 +285,8 @@ class ModelState(ModelStateBase): buf[0, :-1] = buf[0, 1:] buf[0, -1, :] = outputs['desired_curvature'][0, :] if not self.mlsim else 0 - if self.usbgpu and not np.all(np.isfinite(outputs.get('plan', np.array([0.])))): - cloudlog.error("model output not finite, dropping frame") - return None + if self.chestnut and not np.all(np.isfinite(outputs.get('plan', np.array([0.])))): + raise RuntimeError("model output not finite") return outputs @@ -327,13 +324,13 @@ def main(demo=False): setproctitle(PROCESS_NAME) config_realtime_process(7, 54) - USBGPU = usbgpu_present() - if USBGPU: + CHESTNUT = chestnut_present() + if CHESTNUT: os.environ['HCQDEV_WAIT_TIMEOUT_MS'] = '3000' params = Params() - params.put_bool("UsbGpuLoading", USBGPU) - params.remove("UsbGpuActive") + params.put_bool("ChestnutLoading", CHESTNUT) + params.remove("ChestnutActive") # visionipc clients while True: @@ -362,31 +359,36 @@ def main(demo=False): st = time.monotonic() model = None - if USBGPU: - import threading - def load(): - nonlocal model - model = ModelState(cam_w=vipc_client_main.width, cam_h=vipc_client_main.height, usbgpu=True) - t = threading.Thread(target=load, daemon=True) - t.start() - t.join(60) - if model is None: - params.put_bool("UsbGpuActive", False) - raise RuntimeError("eGPU model load failed or timed out (60s)") - params.put_bool("UsbGpuActive", True) - else: - model = ModelState(cam_w=vipc_client_main.width, cam_h=vipc_client_main.height, usbgpu=False) + if CHESTNUT: + big_model = None + def load_big(): + nonlocal big_model + try: + m = ModelState(cam_w=vipc_client_main.width, cam_h=vipc_client_main.height, chestnut=True) + m.warmup() + big_model = m + except Exception: + cloudlog.exception("chestnut load failed") + loader = threading.Thread(target=load_big, daemon=True) + loader.start() + loader.join(BIG_MODEL_TIMEOUT) + model = big_model + params.put_bool("ChestnutActive", model is not None) - params.put_bool("UsbGpuLoading", False) + small_model = ModelState(cam_w=vipc_client_main.width, cam_h=vipc_client_main.height, chestnut=False) if model is None or CHESTNUT else None + if model is None: + model = small_model + params.put_bool("ChestnutLoading", False) + assert model is not None cloudlog.warning(f"models loaded in {time.monotonic() - st:.1f}s, modeld starting") # messaging - pub_socks = ["modelV2", "drivingModelData", "cameraOdometry", "modelDataV2SP"] + (["chestnutState"] if USBGPU else []) + pub_socks = ["modelV2", "drivingModelData", "cameraOdometry", "modelDataV2SP"] + (["chestnutState"] if CHESTNUT else []) pm = PubMaster(pub_socks) sm = SubMaster(["deviceState", "carState", "narrowRoadCameraState", "extrinsicsCalibration", "driverMonitoringState", "carControl", "lateralDelay"]) publish_state = PublishState() - chestnut_state = ChestnutState(pm, USBGPU) if USBGPU else None + chestnut_state = ChestnutState(pm, model.chestnut) if CHESTNUT else None # setup filter to track dropped frames frame_dropped_filter = FirstOrderFilter(0., 10., 1. / model.constants.MODEL_FREQ) @@ -509,7 +511,19 @@ def main(demo=False): inputs['action_t'] = np.array([lat_action_t, long_action_t], dtype=np.float32) mt1 = time.perf_counter() - model_output = model.run(bufs, transforms, inputs, prepare_only) + try: + model_output = model.run(bufs, transforms, inputs, prepare_only) + except Exception: + if not params.get_bool("ChestnutActive"): + raise + cloudlog.exception("chestnut failed, falling back to small") + params.put_bool("ChestnutActive", False) + assert small_model is not None + model = small_model + if chestnut_state is not None: + chestnut_state.big = False + run_count = 0 + model_output = None mt2 = time.perf_counter() model_execution_time = mt2 - mt1 @@ -524,7 +538,7 @@ def main(demo=False): fill_model_msg(drivingdata_send, modelv2_send, model_output, action, publish_state, meta_main.frame_id, meta_extra.frame_id, frame_id, frame_drop_ratio, meta_main.timestamp_eof, model_execution_time, live_calib_seen, meta_constants) - modelv2_send.modelV2.big = model.usbgpu + modelv2_send.modelV2.big = model.chestnut desire_state = modelv2_send.modelV2.meta.desireState l_lane_change_prob = desire_state[log.Desire.laneChangeLeft] diff --git a/openpilot/sunnypilot/modeld_v2/tests/helpers.py b/openpilot/sunnypilot/modeld_v2/tests/helpers.py index ee59e82785..82e159a305 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, *, chestnut=None: bundle) + monkeypatch.setattr(modeld_module, 'get_active_bundle', lambda params=None, *, chestnut=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..e3a686a73f 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, *, chestnut=None: bundle) + monkeypatch.setattr(modeld_module, 'get_active_bundle', lambda params=None, *, chestnut=None: bundle) with self.assertRaisesRegex(AssertionError, "No driving pkl found"): ModelState(cam_w=CAM_W, cam_h=CAM_H) @@ -75,11 +75,11 @@ class TestStockEquivalence(OpenpilotTestCase): frame_skip = derive_frame_skip(SPLIT_VISION_INPUT_SHAPES, SPLIT_POLICY_INPUT_SHAPES) stock_shapes = {**SPLIT_VISION_INPUT_SHAPES, **SPLIT_POLICY_INPUT_SHAPES, 'action_t': (1, 2)} - stock_queues, stock_npy = make_input_queues(stock_shapes, frame_skip, device='NPY') + stock_queues, stock_npy, _frame_views = make_input_queues(stock_shapes, frame_skip, device='NPY', frame_copy_size=49152) - assert set(state.input_queues.keys()) == set(stock_queues.keys()) + # sunnypilot split pipeline has tfm/big_tfm as queues (stock has them in npy only) + assert set(stock_queues.keys()) <= set(state.input_queues.keys()) assert {'desire', 'traffic_convention'} <= set(state.numpy_inputs.keys()) - assert set(state.numpy_inputs.keys()) == set(stock_npy.keys()) - {'action_t', 'prev_feat'} def test_split_queue_keys_work_with_desire_key(self, model_state_factory): from openpilot.sunnypilot.modeld_v2.compile_modeld import derive_frame_skip, make_split_input_queues diff --git a/openpilot/sunnypilot/modeld_v2/tests/test_compile_modeld.py b/openpilot/sunnypilot/modeld_v2/tests/test_compile_modeld.py index 96bfb42638..7f6636fb95 100644 --- a/openpilot/sunnypilot/modeld_v2/tests/test_compile_modeld.py +++ b/openpilot/sunnypilot/modeld_v2/tests/test_compile_modeld.py @@ -7,6 +7,7 @@ See the LICENSE.md file in the root directory for more details. import os import tempfile +import unittest from pathlib import Path import numpy as np @@ -195,3 +196,89 @@ 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, 32, 512), + 'traffic_convention': (1, 2), + 'action_t': (1, 2), + } + frame_skip = 4 + + stock_queues, stock_npy, _frame_views = stock_make_input_queues(input_shapes, frame_skip, device='NPY', frame_copy_size=49152) + sunny_queues, sunny_npy = sunny_make_supercombo_input_queues(input_shapes, frame_skip, device='NPY') + # sunnypilot split pipeline has tfm/big_tfm as queues; packed_npy_inputs size differs (different frame packing) + assert set(stock_queues.keys()) <= set(sunny_queues.keys()) + for key in stock_queues: + if key == 'packed_npy_inputs': + continue + 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(stock_npy.keys()) <= set(sunny_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}" + + @unittest.skip("upstream removed make_warp_input_queues — warp merged into run_model") + 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/modeld_v2/tests/test_fallback.py b/openpilot/sunnypilot/modeld_v2/tests/test_fallback.py new file mode 100644 index 0000000000..1cc8410f06 --- /dev/null +++ b/openpilot/sunnypilot/modeld_v2/tests/test_fallback.py @@ -0,0 +1,62 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" + +import io +import requests + +from openpilot.common.file_chunker import get_chunk_name +from openpilot.common.hardware import hw +from openpilot.common.test import OpenpilotTestCase +from openpilot.selfdrive.modeld.helpers import dump_oob +import openpilot.sunnypilot.modeld_v2.modeld as modeld_module +from openpilot.sunnypilot.modeld_v2.tests import helpers as tests_helpers +from openpilot.sunnypilot.modeld_v2.tests.helpers import DummyModel, DummyBundle, CAM_W, CAM_H +from openpilot.sunnypilot.models.fetcher import ModelParser, ModelFetcher + +tmp_path = tests_helpers.tmp_path + + +class TestFallback(OpenpilotTestCase): + def test_find_dual_model_in_bundle(self, tmp_path, monkeypatch): + lebowski_file = 'driving_lebowski.pkl' + tsfdo_file = 'driving_tsfdo.pkl' + (tmp_path / lebowski_file).write_bytes(b'fkasdjfkljf') + (tmp_path / tsfdo_file).write_bytes(b'dskfajklsdjlsfka') + + monkeypatch.setattr(hw.Paths, 'model_root', staticmethod(lambda: str(tmp_path))) + big_bundle = DummyBundle(models=[DummyModel('supercombo', lebowski_file)]) + small_bundle = DummyBundle(models=[DummyModel('supercombo', tsfdo_file)]) + big_pkl = modeld_module._find_driving_pkl(big_bundle) + small_pkl = modeld_module._find_driving_pkl(small_bundle) + + assert big_pkl is not None and lebowski_file in big_pkl + assert small_pkl is not None and tsfdo_file in small_pkl + + def test_download_models_and_init_modelstate_fallback(self, tmp_path, monkeypatch): + monkeypatch.setattr(hw.Paths, 'model_root', staticmethod(lambda: str(tmp_path))) + big_json = requests.get(ModelFetcher.MODEL_URL_CHESTNUT).json() + big_bundle = ModelParser.parse_models(big_json)[-1] + small_json = requests.get(ModelFetcher.MODEL_URL).json() + small_bundle = ModelParser.parse_models(small_json)[-1] + + buf = io.BytesIO() + dump_oob(tests_helpers.make_pkl_data(tests_helpers.ARCHETYPES['supercombo_non20hz']), buf) + oob_bytes = buf.getvalue() + + for bundle in (big_bundle, small_bundle): + artifact = bundle.models[0].artifact + for i in range(len(artifact.chunks)): + (tmp_path / get_chunk_name(artifact.fileName, i, len(artifact.chunks))).write_bytes(oob_bytes if i == 0 else b"") + + monkeypatch.setattr(modeld_module, 'get_active_bundle', lambda params=None, *, chestnut=None: small_bundle) + assert modeld_module.ModelState(CAM_W, CAM_H, chestnut=False).chestnut is False + + monkeypatch.setattr(modeld_module, 'get_active_bundle', lambda params=None, *, chestnut=None: big_bundle) + try: + assert modeld_module.ModelState(CAM_W, CAM_H, chestnut=True).chestnut is True + except Exception as e: + assert "AMD" in str(e) or "device" in str(e).lower() diff --git a/openpilot/sunnypilot/models/default_model.py b/openpilot/sunnypilot/models/default_model.py index a8e55f8e69..5aacea12b2 100755 --- a/openpilot/sunnypilot/models/default_model.py +++ b/openpilot/sunnypilot/models/default_model.py @@ -1,43 +1,89 @@ import argparse import os import hashlib +import requests +import re from openpilot.common.basedir import BASEDIR from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.sunnypilot import get_file_hash from openpilot.sunnypilot.models.model_name import DEFAULT_MODEL, DEFAULT_BIG_MODEL +from openpilot.sunnypilot.models.fetcher import ModelFetcher def get_default_model() -> str: - show_big_model = (ui_state.usbgpu - and (ui_state.usbgpu_active or ui_state.usbgpu_loading or ui_state.is_offroad())) + show_big_model = (ui_state.chestnut_present + and (ui_state.chestnut_active or ui_state.chestnut_loading or ui_state.is_offroad())) return DEFAULT_BIG_MODEL if show_big_model else DEFAULT_MODEL DEFAULT_MODEL_NAME_PATH = os.path.join(BASEDIR, "openpilot", "sunnypilot", "models", "model_name.py") MODEL_HASH_PATH = os.path.join(BASEDIR, "openpilot", "sunnypilot", "models", "tests", "model_hash") +BIG_MODEL_HASH_PATH = os.path.join(BASEDIR, "openpilot", "sunnypilot", "models", "tests", "big_model_hash") SUPERCOMBO_ONNX_PATH = os.path.join(BASEDIR, "openpilot", "selfdrive", "modeld", "models", "driving_supercombo.onnx") +BIG_SUPERCOMBO_ONNX_PATH = os.path.join(BASEDIR, "openpilot", "selfdrive", "modeld", "models", "big_driving_supercombo.onnx") + + +def _read_model_name_fields(): + with open(DEFAULT_MODEL_NAME_PATH) as f: + content = f.read() + fields = {} + for line in content.splitlines(): + if "=" in line: + key, val = line.split("=", 1) + fields[key.strip()] = val.strip().strip('"') + return fields def update_model_hash(): + fields = _read_model_name_fields() supercombo_hash = get_file_hash(SUPERCOMBO_ONNX_PATH) - combined_hash = hashlib.sha256(supercombo_hash.encode()).hexdigest() + fingerprint = f"{supercombo_hash}:{fields.get('DEFAULT_MODEL', '')}:{fields.get('DEFAULT_MODEL_REF', '')}" + combined_hash = hashlib.sha256(fingerprint.encode()).hexdigest() with open(MODEL_HASH_PATH, "w") as f: f.write(combined_hash) print(f"Generated and updated new combined model hash to {MODEL_HASH_PATH}") + if os.path.exists(BIG_SUPERCOMBO_ONNX_PATH): + import subprocess + rel = os.path.relpath(BIG_SUPERCOMBO_ONNX_PATH, os.getcwd()) + pointer = subprocess.check_output(["git", "show", f"HEAD:{rel}"], text=True) + oid = next(l.split(":", 1)[1] for l in pointer.splitlines() if l.startswith("oid sha256:")) + big_fingerprint = f"{oid}:{fields.get('DEFAULT_BIG_MODEL', '')}:{fields.get('DEFAULT_BIG_MODEL_REF', '')}" + big_combined_hash = hashlib.sha256(big_fingerprint.encode()).hexdigest() + + with open(BIG_MODEL_HASH_PATH, "w") as f: + f.write(big_combined_hash) + + print(f"Generated and updated new big model hash to {BIG_MODEL_HASH_PATH}") + + +def get_ref_for_name(url: str, name: str) -> str: + response = requests.get(url, timeout=10) + if response.status_code == 200: + bundles = response.json()["bundles"] + matching = [b for b in bundles if re.search(name, f"{b['short_name']} {b['display_name']}", re.IGNORECASE)] + if matching: + return max(matching, key=lambda b: int(b["index"]))["ref"] + return "" + def update_default_model_names(default_model_name: str, default_big_model_name: str): print("[CHANGE DEFAULT MODEL NAMES]") + small_ref = get_ref_for_name(ModelFetcher.MODEL_URL, default_model_name) + big_ref = get_ref_for_name(ModelFetcher.MODEL_URL_CHESTNUT, default_big_model_name) + with open(DEFAULT_MODEL_NAME_PATH, "w") as f: f.write(f'DEFAULT_MODEL = "{default_model_name}"\n') + f.write(f'DEFAULT_MODEL_REF = "{small_ref}"\n') f.write(f'DEFAULT_BIG_MODEL = "{default_big_model_name}"\n') + f.write(f'DEFAULT_BIG_MODEL_REF = "{big_ref}"\n') - print(f'New default small model name: "{default_model_name}"') - print(f'New default big model name: "{default_big_model_name}"') + print(f'New default small model name: "{default_model_name}" (ref: {small_ref})') + print(f'New default big model name: "{default_big_model_name}" (ref: {big_ref})') print("[DONE]") diff --git a/openpilot/sunnypilot/models/fetcher.py b/openpilot/sunnypilot/models/fetcher.py index b64e27b1ad..f997dbbc51 100644 --- a/openpilot/sunnypilot/models/fetcher.py +++ b/openpilot/sunnypilot/models/fetcher.py @@ -13,8 +13,6 @@ from openpilot.common.params import Params from openpilot.common.swaglog import cloudlog from openpilot.common.hardware.hw import Paths from openpilot.sunnypilot.models.helpers import is_bundle_version_compatible -from openpilot.selfdrive.modeld.helpers import usbgpu_present - from openpilot.cereal import custom @@ -140,45 +138,53 @@ 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_v22.json" + MODEL_URL_CHESTNUT = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_chestnut_v23.json" + + MODEL_SOURCES = { + "qcom": (MODEL_URL, ""), + "chestnut": (MODEL_URL_CHESTNUT, "_Chestnut"), + } 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._update_model_source() + 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, + "chestnut": self.MODEL_URL_CHESTNUT, + }, block=True) - def _update_model_source(self) -> None: - """Updates what json to use based on usbgpu availability""" - is_usbgpu = usbgpu_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 "chestnut" 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}") @@ -191,16 +197,40 @@ class ModelFetcher: return None - def get_available_bundles(self) -> list[custom.ModelManagerSP.ModelBundle]: - """Gets the list of available models, with smart cache handling""" - self._update_model_source() - 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 == "chestnut": + 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 @@ -208,12 +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 chestnut_present params = Params() model_fetcher = ModelFetcher(params) - bundles = model_fetcher.get_available_bundles() + bundles = model_fetcher.get_bundles_for_source(ModelFetcher.active_source(chestnut_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 b5c97467d3..ac69096597 100644 --- a/openpilot/sunnypilot/models/helpers.py +++ b/openpilot/sunnypilot/models/helpers.py @@ -16,14 +16,20 @@ 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 chestnut_present # SET ME TO THE EXACT JSON VERSION WE SET IN SUNNYPILOT_MODELS REPO -REQUIRED_JSON_VERSION = 17 +REQUIRED_JSON_VERSION = 19 CUSTOM_MODEL_PATH = Paths.model_root() METADATA_PATH = Path(__file__).parent / '../models/supercombo_metadata.pkl' ModelManager = custom.ModelManagerSP -_LAST_VALIDATED_RAW = None + +ACTIVE_BUNDLE_KEYS = { + "qcom": "ModelManager_ActiveBundle", + "chestnut": "ModelManager_ActiveBundleChestnut", +} +_LAST_VALIDATED_RAW: dict[str, dict | None] = {} def _compute_hash(file_path: str) -> str | None: @@ -86,11 +92,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,49 +104,81 @@ 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 != matching_bundle.runner: + return True if set(_bundle_artifacts(active_bundle)) != set(_bundle_artifacts(matching_bundle)): return True return not _bundle_is_valid_locally(active_bundle) -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": - 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(chestnut: bool | None = None, chestnut_active: bool | None = None, + chestnut_loading: bool | None = None, offroad: bool | None = None) -> str: + if chestnut is None: + chestnut = chestnut_present() + state_valid = chestnut_active is not None or chestnut_loading is not None or offroad is not None + big_active = chestnut and (not state_valid or chestnut_active or chestnut_loading or offroad) + return "chestnut" if big_active else "qcom" + + +def get_active_bundle(params: Params | None = None, *, chestnut: 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(chestnut=chestnut)) + + +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 8ca8775875..035f59891c 100644 --- a/openpilot/sunnypilot/models/manager.py +++ b/openpilot/sunnypilot/models/manager.py @@ -17,12 +17,17 @@ 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) +class DownloadCancelled(Exception): + pass + + class ModelManagerSP: """Manages model downloads and status reporting""" @@ -30,11 +35,25 @@ class ModelManagerSP: self.params = Params() 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, chestnut=self.chestnut_present) self._chunk_size = 128 * 1000 # 128 KB chunks self._download_start_times: dict[str, float] = {} # Track start time per model + self._download_ref: bytes | str | None = None + + def _download_interrupted(self) -> bool: + # only removal cancels: a different ref is a queued selection that + # _release_download_ref leaves in place for the next tick + return self.params.get("ModelManager_DownloadRef") is None + + def _release_download_ref(self) -> None: + if self.params.get("ModelManager_DownloadRef") == self._download_ref: + self.params.remove("ModelManager_DownloadRef") + self._download_ref = None def _sync_artifact_progress(self, source_artifact) -> None: """Mirror download progress to all artifacts sharing the same filename in the selected bundle.""" @@ -76,8 +95,8 @@ class ModelManagerSP: f.write(chunk) bytes_downloaded += len(chunk) - if self.params.get("ModelManager_DownloadIndex") is None: - raise Exception("Download cancelled") + if self._download_interrupted(): + raise DownloadCancelled("Download cancelled") if total_size > 0: progress = (bytes_downloaded / total_size) * 100 @@ -90,7 +109,7 @@ class ModelManagerSP: # Clean up start time after download completes del self._download_start_times[model.fileName] - async def _download_chunked(self, base_url: str, base_path: str, artifact) -> None: + async def _download_chunked(self, base_url: str, base_path: str, artifact, skip: frozenset[int] | set[int] = frozenset()) -> None: from openpilot.common.file_chunker import get_chunk_name, get_manifest_path num_chunks = len(artifact.chunks) @@ -102,8 +121,11 @@ class ModelManagerSP: # Shared connection saves a TCP+TLS handshake per chunk. # Keep sequential: the link saturates on one stream and Session is not thread-safe. + completed = len(skip) with requests.Session() as session: for i, _ in enumerate(artifact.chunks): + if i in skip: + continue chunk_url = get_chunk_name(base_url, i, num_chunks) chunk_path = get_chunk_name(base_path, i, num_chunks) chunk_downloaded = 0 @@ -114,15 +136,16 @@ class ModelManagerSP: for data in response.iter_content(chunk_size=self._chunk_size): f.write(data) chunk_downloaded += len(data) - if self.params.get("ModelManager_DownloadIndex") is None: - raise Exception("Download cancelled") + if self._download_interrupted(): + raise DownloadCancelled("Download cancelled") intra = chunk_downloaded / max(chunk_size, 1) - progress = min(99.0, ((i + intra) / num_chunks) * 100) + progress = min(99.0, ((completed + intra) / num_chunks) * 100) artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.downloading artifact.downloadProgress.progress = progress artifact.downloadProgress.eta = self._calculate_eta(artifact.fileName, progress) self._sync_artifact_progress(artifact) self._report_status() + completed += 1 with open(manifest_path, 'w') as f: # noqa: ASYNC230 f.write(str(num_chunks)) @@ -133,6 +156,8 @@ class ModelManagerSP: async def _process_artifact(self, artifact, destination_path: str) -> None: if not artifact.downloadUri.uri: return None + if self._download_interrupted(): + raise DownloadCancelled("Download cancelled") url = artifact.downloadUri.uri expected_hash = artifact.downloadUri.sha256 @@ -140,21 +165,23 @@ class ModelManagerSP: full_path = os.path.join(destination_path, filename) try: + # progress counts only valid chunks so a resumed download continues the + # bar from where verification left it, instead of falling back to zero is_cached = False + valid_chunks: set[int] = set() if len(artifact.chunks) > 0: from openpilot.common.file_chunker import get_chunk_name num_chunks = len(artifact.chunks) - chunks_valid = True for i, chunk in enumerate(artifact.chunks): - chunk_path = get_chunk_name(full_path, i, num_chunks) - if not await verify_file(chunk_path, chunk.sha256): - chunks_valid = False - break - artifact.downloadProgress.progress = ((i + 1) / num_chunks) * 100 + if self._download_interrupted(): + raise DownloadCancelled("Download cancelled") + if await verify_file(get_chunk_name(full_path, i, num_chunks), chunk.sha256): + valid_chunks.add(i) + artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.verifying + artifact.downloadProgress.progress = (len(valid_chunks) / num_chunks) * 100 self._sync_artifact_progress(artifact) self._report_status() - if chunks_valid and num_chunks > 0: - is_cached = True + is_cached = len(valid_chunks) == num_chunks else: if await verify_file(full_path, expected_hash): is_cached = True @@ -168,7 +195,7 @@ class ModelManagerSP: return if len(artifact.chunks) > 0: - await self._download_chunked(url, full_path, artifact) + await self._download_chunked(url, full_path, artifact, skip=valid_chunks) from openpilot.common.file_chunker import get_chunk_name for i, chunk in enumerate(artifact.chunks): chunk_path = get_chunk_name(full_path, i, len(artifact.chunks)) @@ -185,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]: @@ -216,8 +254,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: @@ -239,10 +276,11 @@ 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 + if self._download_interrupted(): + raise DownloadCancelled("Download cancelled") + self.selected_bundle.status = custom.ModelManagerSP.DownloadStatus.downloaded + self.params.put(ACTIVE_BUNDLE_KEYS[source], model_bundle.to_dict(), block=True) + self.active_bundle = get_active_bundle(self.params, chestnut=self.chestnut_present) except Exception: if self.selected_bundle is not None: @@ -252,9 +290,30 @@ 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)) + 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""" @@ -262,21 +321,20 @@ class ModelManagerSP: while True: try: - self.available_models = self.model_fetcher.get_available_bundles() - validate_active_bundle(self.params, self.available_models) - self.active_bundle = get_active_bundle(self.params) + self.sm.update(0) + 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, chestnut=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): - try: - self.download(model_to_download, Paths.model_root()) - except Exception as e: - cloudlog.exception(e) - finally: - self.params.remove("ModelManager_DownloadIndex") - self.selected_bundle = None + if get_selected_bundle(self.params, "chestnut") is not None and get_selected_bundle(self.params, "qcom") is None: + if self.params.get("ModelManager_DownloadRef") is None: + from openpilot.sunnypilot.models.model_name import DEFAULT_MODEL_REF + if DEFAULT_MODEL_REF: + self.params.put("ModelManager_DownloadRef", DEFAULT_MODEL_REF) + + self._process_download_requests() if self.params.get("ModelManager_ClearCache"): self.clear_model_cache() @@ -294,12 +352,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/model_name.py b/openpilot/sunnypilot/models/model_name.py index 374e8473df..3420391824 100644 --- a/openpilot/sunnypilot/models/model_name.py +++ b/openpilot/sunnypilot/models/model_name.py @@ -1,2 +1,4 @@ DEFAULT_MODEL = "CD210" -DEFAULT_BIG_MODEL = "Lebowski" +DEFAULT_MODEL_REF = "5b6436a90cf6902b8aaa71c2b6f3d7164d8ae391" +DEFAULT_BIG_MODEL = "BMRLNAP Model v4" +DEFAULT_BIG_MODEL_REF = "f877d7a0ccc3cce943c76e285214c020cd65c899" diff --git a/openpilot/sunnypilot/models/tests/big_model_hash b/openpilot/sunnypilot/models/tests/big_model_hash new file mode 100644 index 0000000000..308096afe4 --- /dev/null +++ b/openpilot/sunnypilot/models/tests/big_model_hash @@ -0,0 +1 @@ +2c814f08a2c51323b87839fbf8d2c2a9853a2b5536271b3d67f7b7a2de7f9374 \ No newline at end of file diff --git a/openpilot/sunnypilot/models/tests/model_hash b/openpilot/sunnypilot/models/tests/model_hash index 13a16294ff..ffe44d3bf9 100644 --- a/openpilot/sunnypilot/models/tests/model_hash +++ b/openpilot/sunnypilot/models/tests/model_hash @@ -1 +1 @@ -49133798d9cd9cacf47085c7ef8122bfee88cd9c6192a8314c81bfb1b37f5809 \ No newline at end of file +c5be11d2fb1115be953c541f30c50f7c71a00bc4a0e128e19aa11b60689317fc \ No newline at end of file diff --git a/openpilot/sunnypilot/models/tests/test_default_model.py b/openpilot/sunnypilot/models/tests/test_default_model.py index b72c2b4c89..71a18fc612 100644 --- a/openpilot/sunnypilot/models/tests/test_default_model.py +++ b/openpilot/sunnypilot/models/tests/test_default_model.py @@ -5,19 +5,47 @@ This file is part of sunnypilot and is licensed under the MIT License. See the LICENSE.md file in the root directory for more details. """ +import os +import subprocess + from openpilot.sunnypilot import get_file_hash -from openpilot.sunnypilot.models.default_model import MODEL_HASH_PATH, SUPERCOMBO_ONNX_PATH +from openpilot.sunnypilot.models.default_model import MODEL_HASH_PATH, SUPERCOMBO_ONNX_PATH, BIG_MODEL_HASH_PATH, \ + BIG_SUPERCOMBO_ONNX_PATH, _read_model_name_fields import hashlib from openpilot.common.test import OpenpilotTestCase +def _get_lfs_oid(path: str) -> str: + """Extract the LFS OID (SHA256 of actual content) from git, works whether the file is smudged or not.""" + pointer = subprocess.check_output(["git", "show", f"HEAD:{path}"], text=True) + for line in pointer.splitlines(): + if line.startswith("oid sha256:"): + return line.split(":", 1)[1] + raise ValueError(f"No LFS OID found for {path}") + + class TestDefaultModel(OpenpilotTestCase): def test_compare_onnx_hashes(self): + fields = _read_model_name_fields() supercombo_hash = get_file_hash(SUPERCOMBO_ONNX_PATH) - - combined_hash = hashlib.sha256(supercombo_hash.encode()).hexdigest() + fingerprint = f"{supercombo_hash}:{fields.get('DEFAULT_MODEL', '')}:{fields.get('DEFAULT_MODEL_REF', '')}" + combined_hash = hashlib.sha256(fingerprint.encode()).hexdigest() with open(MODEL_HASH_PATH) as f: current_hash = f.read().strip() assert combined_hash == current_hash, "Run openpilot/sunnypilot/models/default_model.py to update the default model name and hash" + + def test_compare_big_onnx_hashes(self): + if not os.path.exists(BIG_SUPERCOMBO_ONNX_PATH): + self.skipTest("big_driving_supercombo.onnx not present") + + fields = _read_model_name_fields() + oid = _get_lfs_oid(os.path.relpath(BIG_SUPERCOMBO_ONNX_PATH, os.getcwd())) + big_fingerprint = f"{oid}:{fields.get('DEFAULT_BIG_MODEL', '')}:{fields.get('DEFAULT_BIG_MODEL_REF', '')}" + combined_hash = hashlib.sha256(big_fingerprint.encode()).hexdigest() + + with open(BIG_MODEL_HASH_PATH) as f: + current_hash = f.read().strip() + + assert combined_hash == current_hash, "Run openpilot/sunnypilot/models/default_model.py to update the default model name and hash" diff --git a/openpilot/sunnypilot/models/tests/test_manager_download.py b/openpilot/sunnypilot/models/tests/test_manager_download.py index 67fb9023af..b995d4fbd1 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] @@ -98,11 +103,13 @@ 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 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 +256,166 @@ 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 + self.manager._download_ref = b"ref" + asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact)) + assert os.path.isfile(get_manifest_path(base_path)) + self.run_with_server(body) + + def test_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 + self.manager._download_ref = b"ref" + with self.assertRaises(Exception) as ctx: + asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact)) + assert 'cancelled' in str(ctx.exception).lower() + assert not os.path.isfile(get_manifest_path(base_path)) + self.run_with_server(body) + + def test_replaced_download_ref_queues_instead_of_cancelling(self): + """Selecting another model mid-transfer lets the running download finish.""" + def body(): + artifact = self.make_artifact(chunked=True) + base_path = os.path.join(self.dest, artifact.fileName) + self.manager.params.get.side_effect = lambda key: b"other-ref" if key == "ModelManager_DownloadRef" else None + self.manager._download_ref = b"ref" + asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact)) + assert os.path.isfile(get_manifest_path(base_path)) + self.run_with_server(body) + + def test_replaced_download_ref_is_kept(self): + """A selection made during a download must survive that download's cleanup.""" + self.manager.params.get.return_value = b"new-ref" + self.manager._download_ref = b"old-ref" + self.manager._release_download_ref() + self.manager.params.remove.assert_not_called() + + def test_own_download_ref_is_released(self): + self.manager.params.get.return_value = b"ref" + self.manager._download_ref = b"ref" + self.manager._release_download_ref() + self.manager.params.remove.assert_called_once_with("ModelManager_DownloadRef") + + def test_cached_bundle_cancel_skips_slot_write(self): + """A cancel must stop an already-on-disk bundle before it is applied to the slot.""" + def body(): + artifact = self.make_artifact(chunked=True) + base_path = os.path.join(self.dest, artifact.fileName) + for i, data in enumerate(CHUNK_BODIES): + with open(get_chunk_name(base_path, i, len(CHUNK_BODIES)), 'wb') as f: + f.write(data) + self._bundle.ref = "test-ref" + params, store = self._make_params_with_store() + store["ModelManager_DownloadRef"] = None # removed -> cancelled + self.manager.params = params + self.manager._download_ref = b"ref" + with self.assertRaises(Exception) as ctx: + asyncio.run(self.manager._download_bundle(self._bundle, self.dest, "qcom")) + assert 'cancelled' in str(ctx.exception).lower() + assert "ModelManager_ActiveBundle" not in store + assert all(os.path.isfile(p) for p in self.chunk_paths(base_path)), "cancel must not delete cached chunks" + self.run_with_server(body) + + def test_resume_skips_valid_chunks(self): + """A chunk already on disk is kept and not re-downloaded; progress starts above its share.""" + def body(): + artifact = self.make_artifact(chunked=True) + base_path = os.path.join(self.dest, artifact.fileName) + with open(get_chunk_name(base_path, 0, len(CHUNK_BODIES)), 'wb') as f: + f.write(CHUNK_BODIES[0]) + + asyncio.run(self.manager._process_artifact(artifact, self.dest)) + + chunk0_suffix = get_chunk_name('', 0, len(CHUNK_BODIES)) + assert not any(p.endswith(chunk0_suffix) for p in DownloadHandler.request_paths), "valid chunk was re-downloaded" + for i, expected in enumerate(CHUNK_BODIES): + with open(get_chunk_name(base_path, i, len(CHUNK_BODIES)), 'rb') as f: + assert f.read() == expected + assert os.path.isfile(get_manifest_path(base_path)) + assert min(self.reported) >= (1 / len(CHUNK_BODIES)) * 100 - 1, "progress must not restart below the resumed share" + self.run_with_server(body) + + def test_verify_reports_valid_fraction_then_cached(self): + """A fully cached bundle publishes climbing verify progress and ends cached.""" + def body(): + artifact = self.make_artifact(chunked=True) + base_path = os.path.join(self.dest, artifact.fileName) + for i, data in enumerate(CHUNK_BODIES): + with open(get_chunk_name(base_path, i, len(CHUNK_BODIES)), 'wb') as f: + f.write(data) + + asyncio.run(self.manager._process_artifact(artifact, self.dest)) + + assert DownloadHandler.request_paths == [], "cached bundle must not hit the network" + assert [round(p) for p in self.reported[:3]] == [33, 67, 100] + assert artifact.downloadProgress.status == custom.ModelManagerSP.DownloadStatus.cached + self.run_with_server(body) + + def _make_params_with_store(self): + params = mock.MagicMock() + store = {} + + 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 = helpers.REQUIRED_JSON_VERSION + params, store = self._make_params_with_store() + self.manager.params = params + asyncio.run(self.manager._download_bundle(self._bundle, self.dest, "qcom")) + + assert "ModelManager_ActiveBundle" in store, "qcom download must write the qcom slot" + assert "ModelManager_ActiveBundleChestnut" not in store, "qcom download must not touch the chestnut 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_chestnut_slot(self): + """A download resolved to the chestnut source writes the chestnut active bundle slot only.""" + def body(): + self.make_artifact(chunked=True) + self._bundle.ref = "big-ref" + self._bundle.minimumSelectorVersion = helpers.REQUIRED_JSON_VERSION + params, store = self._make_params_with_store() + self.manager.params = params + asyncio.run(self.manager._download_bundle(self._bundle, self.dest, "chestnut")) + + assert "ModelManager_ActiveBundleChestnut" in store, "chestnut download must write the chestnut slot" + assert "ModelManager_ActiveBundle" not in store, "chestnut 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 +434,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], "chestnut": []}) == (small, "qcom") + + def test_chestnut_ref_resolves_to_chestnut_slot(self): + big = self._bundle("big") + assert resolve_bundle_by_ref("big", {"qcom": [], "chestnut": [big]}) == (big, "chestnut") + + def test_unknown_ref_returns_none(self): + source_bundles = {"qcom": [self._bundle("small")], "chestnut": []} + 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 (chestnut) 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": str(helpers.REQUIRED_JSON_VERSION), + "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, chestnut_manifest): + params = mock.MagicMock() + + def get(key): + if key == "ModelManager_ModelsCache": + return qcom_manifest + if key == "ModelManager_ModelsCache_Chestnut": + return chestnut_manifest + if key in ("ModelManager_LastSyncTime", "ModelManager_LastSyncTime_Chestnut"): + 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) == "chestnut" + + 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("chestnut")] == ["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") + chestnut_bundles = get_cached_bundles(params, "chestnut") + assert [b.ref for b in qcom_bundles] == ["aaa"] + assert [b.ref for b in chestnut_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, "chestnut") == [] + + 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, + "chestnut": ModelFetcher.MODEL_URL_CHESTNUT, + } + + + +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 (chestnut) 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, chestnut_manifest): + params = mock.MagicMock() + + def get(key): + if key == "ModelManager_ModelsCache": + return qcom_manifest + if key == "ModelManager_ModelsCache_Chestnut": + return chestnut_manifest + if key in ("ModelManager_LastSyncTime", "ModelManager_LastSyncTime_Chestnut"): + 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_chestnut_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("chestnut") + 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("chestnut")] == ["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 = helpers.REQUIRED_JSON_VERSION + if runner is not None: + bundle.runner = runner + return bundle.to_dict() + + def _params(self, qcom=None, chestnut=None): + params = mock.MagicMock() + + def get(key, *args, **kwargs): + return {"ModelManager_ActiveBundle": qcom, "ModelManager_ActiveBundleChestnut": chestnut}.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.chestnut_present", return_value=False): + validate_active_bundles(params, {"qcom": [], "chestnut": []}) + 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"), chestnut=big_raw) + catalog = {"qcom": [custom.ModelManagerSP.ModelBundle(**self._raw_bundle("other"))], + "chestnut": [custom.ModelManagerSP.ModelBundle(**big_raw)]} + with mock.patch("openpilot.sunnypilot.models.helpers.chestnut_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: chestnut 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 = helpers.REQUIRED_JSON_VERSION + return bundle.to_dict() + + def _params(self, qcom=None, chestnut=None): + params = mock.MagicMock() + + def get(key, *args, **kwargs): + if key == "ModelManager_ActiveBundle": + return qcom + if key == "ModelManager_ActiveBundleChestnut": + return chestnut + 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"), chestnut=self._raw_bundle("big")) + assert get_selected_bundle(params, "qcom").ref == "small" + assert get_selected_bundle(params, "chestnut").ref == "big" + + def test_no_gpu_uses_qcom_slot(self): + params = self._params(qcom=self._raw_bundle("small"), chestnut=self._raw_bundle("big")) + with mock.patch("openpilot.sunnypilot.models.helpers.chestnut_present", return_value=False): + assert get_active_bundle(params).ref == "small" + + def test_gpu_uses_chestnut_slot(self): + params = self._params(qcom=self._raw_bundle("small"), chestnut=self._raw_bundle("big")) + with mock.patch("openpilot.sunnypilot.models.helpers.chestnut_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"), chestnut=None) + with mock.patch("openpilot.sunnypilot.models.helpers.chestnut_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 = helpers.REQUIRED_JSON_VERSION + return bundle.to_dict() + + def test_runtime_no_gpu(self): + with mock.patch("openpilot.sunnypilot.models.helpers.chestnut_present", return_value=False): + assert get_active_source() == "qcom" + + def test_runtime_gpu_present(self): + with mock.patch("openpilot.sunnypilot.models.helpers.chestnut_present", return_value=True): + assert get_active_source() == "chestnut" + + def test_display_offroad_gpu_present_shows_big(self): + assert get_active_source(chestnut=True, chestnut_active=False, chestnut_loading=False, offroad=True) == "chestnut" + + def test_display_onroad_gpu_loading_shows_big(self): + assert get_active_source(chestnut=True, chestnut_active=False, chestnut_loading=True, offroad=False) == "chestnut" + + def test_display_onroad_gpu_active_shows_big(self): + assert get_active_source(chestnut=True, chestnut_active=True, chestnut_loading=False, offroad=False) == "chestnut" + + def test_display_onroad_gpu_idle_shows_small(self): + assert get_active_source(chestnut=True, chestnut_active=False, chestnut_loading=False, offroad=False) == "qcom" + + def test_display_active_none_is_idle(self): + assert get_active_source(chestnut=True, chestnut_active=None, chestnut_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_ActiveBundleChestnut": self._raw_bundle("big")}.get(key) + with mock.patch("openpilot.sunnypilot.models.helpers.chestnut_present", return_value=False): + assert get_active_bundle(params).ref == "small" + assert get_selected_bundle(params, get_active_source(chestnut=True, chestnut_active=False, + chestnut_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/selfdrive/assets/icons_mici/sunnylink.png b/openpilot/sunnypilot/selfdrive/assets/icons_mici/sunnylink.png new file mode 100644 index 0000000000..6639536f9a --- /dev/null +++ b/openpilot/sunnypilot/selfdrive/assets/icons_mici/sunnylink.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:447099e93e303b29e7b3eac237bb0f27f8c5e12786991139aee2432532a75f58 +size 12310 diff --git a/openpilot/sunnypilot/selfdrive/selfdrived/events.py b/openpilot/sunnypilot/selfdrive/selfdrived/events.py index 3c010cc776..b50873dea4 100644 --- a/openpilot/sunnypilot/selfdrive/selfdrived/events.py +++ b/openpilot/sunnypilot/selfdrive/selfdrived/events.py @@ -252,4 +252,12 @@ EVENTS_SP: dict[int, dict[str, Alert | AlertCallbackType]] = { AlertStatus.userPrompt, AlertSize.small, Priority.LOW, VisualAlert.none, AudibleAlert.prompt, 0.1), }, + + EventNameSP.bigModelReady: { + ET.PERMANENT: Alert( + "Big Model Ready", + "", + AlertStatus.normal, AlertSize.small, + Priority.LOW, VisualAlert.none, AudibleAlert.prompt, 2.), + }, } diff --git a/openpilot/sunnypilot/sunnylink/athena/sunnylinkd.py b/openpilot/sunnypilot/sunnylink/athena/sunnylinkd.py index 3425a85624..c31e4ac711 100755 --- a/openpilot/sunnypilot/sunnylink/athena/sunnylinkd.py +++ b/openpilot/sunnypilot/sunnylink/athena/sunnylinkd.py @@ -28,7 +28,6 @@ from websocket import (ABNF, WebSocket, WebSocketException, WebSocketTimeoutExce create_connection, WebSocketConnectionClosedException) import openpilot.cereal.messaging as messaging -from openpilot.selfdrive.modeld.helpers import usbgpu_present from openpilot.sunnypilot.models.model_name import DEFAULT_MODEL, DEFAULT_BIG_MODEL from openpilot.sunnypilot.selfdrive.car.sync_sunnylink_params import update_car_list_param from openpilot.sunnypilot.sunnylink.api import SunnylinkApi @@ -182,11 +181,9 @@ def getParamsMetadata() -> str: schema = generate_schema() schema["capabilities"] = generate_capabilities() schema["capability_labels"] = CAPABILITY_LABELS - # mirrors get_default_model() — ui_state unavailable in sunnylinkd process - show_big = (usbgpu_present() - and (params.get_bool("UsbGpuActive") or params.get_bool("UsbGpuLoading") or params.get_bool("IsOffroad"))) - schema["default_model"] = DEFAULT_BIG_MODEL if show_big else DEFAULT_MODEL - schema["usbgpu_active"] = params.get_bool("UsbGpuActive") + schema["default_model"] = DEFAULT_MODEL + schema["default_big_model"] = DEFAULT_BIG_MODEL + schema["chestnut_active"] = params.get_bool("ChestnutActive") raw = json.dumps(schema, separators=(",", ":")).encode("utf-8") return base64.b64encode(gzip.compress(raw)).decode("utf-8") except Exception: diff --git a/openpilot/sunnypilot/sunnylink/statsd.py b/openpilot/sunnypilot/sunnylink/statsd.py index 7e8faf6327..9d01ac07fd 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_ActiveBundleChestnut', 'ModelManager_Favs', 'EnableSunnylinkUploader', 'SunnylinkEnabled', diff --git a/openpilot/sunnypilot/system/params_migration.py b/openpilot/sunnypilot/system/params_migration.py index 130fd64310..41cad9ad28 100644 --- a/openpilot/sunnypilot/system/params_migration.py +++ b/openpilot/sunnypilot/system/params_migration.py @@ -84,6 +84,25 @@ 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_ActiveBundleChestnut") is not None: + return + if (chestnut_bundle := _params.get("ModelManager_ActiveBundleUSBGPU")) is not None: + _params.put("ModelManager_ActiveBundleChestnut", chestnut_bundle, block=True) + cloudlog.info("params_migration: seeded ModelManager_ActiveBundleChestnut from ModelManager_ActiveBundleUSBGPU") + return + if (bundle := _params.get("ModelManager_ActiveBundle")) is None: + return + _params.put("ModelManager_ActiveBundleChestnut", bundle, block=True) + cloudlog.info("params_migration: seeded ModelManager_ActiveBundleChestnut 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 +139,6 @@ def run_migration(_params): # seed TeslaMadsScreenButton for existing Tesla installs _migrate_tesla_mads_screen_button(_params) + + # seed the chestnut 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..683c9dcdc7 --- /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_chestnut_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_ActiveBundleChestnut") == bundle + assert params.get("ModelManager_ActiveBundle") == bundle + + def test_noop_when_chestnut_slot_already_set(self): + params = Params() + params.put("ModelManager_ActiveBundle", {"ref": "small"}, block=True) + params.put("ModelManager_ActiveBundleChestnut", {"ref": "big"}, block=True) + _migrate_model_bundle_slots(params) + assert params.get("ModelManager_ActiveBundleChestnut") == {"ref": "big"} + + def test_noop_when_no_selection(self): + params = Params() + _migrate_model_bundle_slots(params) + assert params.get("ModelManager_ActiveBundleChestnut") is None diff --git a/openpilot/system/hardware/chestnut/status.py b/openpilot/system/hardware/chestnut/status.py new file mode 100644 index 0000000000..393d989dad --- /dev/null +++ b/openpilot/system/hardware/chestnut/status.py @@ -0,0 +1,101 @@ +import time + +from openpilot.common.hardware.usb import CHESTNUT_USB_PRODUCT, is_chestnut_usb_id +from openpilot.common.version import get_build_metadata, CHESTNUT_BRANCHES +from openpilot.selfdrive.modeld.helpers import MODELS_DIR, chestnut_compiled + + +CHESTNUT_RELEASE_BRANCHES = ("release-chestnut", "release-chestnut-staging") +CHESTNUT_POWERED_VOLTAGE = 5000 +GPU_TEMP_LIMIT = 100. +MEMORY_TEMP_LIMIT = 95. +TEMP_HYSTERESIS = 5. + + +class ChestnutStatus: + def __init__(self): + self.started = time.monotonic() + self.offroad = True + self.pcie_failed = False + self.power_seen = False + self.power_unavailable = False + self.power_lost = False + self.power_restored = False + self.link_failures = 0 + self.model_loading_seen = False + self.model_attempted = False + self.overheated = False + self.usb_seen = False + self.usb_failed = False + + def update(self, offroad: bool, branch: str, usb_state: list[dict], firmware_failed: bool, + model_loading: bool, model_active: bool | None, state, set_alert) -> None: + detected = [d for d in usb_state if is_chestnut_usb_id(d["vendorId"], d["productId"], include_bootloader=True)] + devices = [d for d in detected if is_chestnut_usb_id(d["vendorId"], d["productId"])] + firmware_ok = len(devices) == 1 and devices[0]["product"] == CHESTNUT_USB_PRODUCT + + if self.offroad and not offroad: + self.pcie_failed = False + self.power_seen = False + self.power_unavailable = False + self.power_lost = False + self.power_restored = False + self.link_failures = 0 + self.model_loading_seen = False + self.model_attempted = False + self.usb_seen = firmware_ok + self.usb_failed = False + + self.model_loading_seen |= model_loading + self.model_attempted |= self.model_loading_seen and not model_loading and model_active is not None + + if not offroad and self.usb_seen and not firmware_ok: + self.usb_failed = True + + if not offroad and state is not None: + powered = state.supplyVoltage >= CHESTNUT_POWERED_VOLTAGE + power_lost = state.supplyFault or not powered + if self.model_attempted and power_lost and not self.power_lost: + self.power_unavailable = not self.power_seen + self.power_seen |= powered + + if not offroad and self.model_attempted and state is not None: + self.link_failures = self.link_failures + 1 if state.pcieLtssm != 0x78 else 0 + self.pcie_failed |= self.link_failures >= 2 or power_lost + self.power_lost |= power_lost + + if self.pcie_failed and self.power_lost and state is not None: + self.power_restored |= not state.supplyFault and state.supplyVoltage >= CHESTNUT_POWERED_VOLTAGE + if self.usb_failed: + self.pcie_failed = False + self.power_seen = False + self.power_unavailable = False + self.power_lost = False + self.power_restored = False + + if state is not None: + gpu_limit = GPU_TEMP_LIMIT - (TEMP_HYSTERESIS if self.overheated else 0.) + memory_limit = MEMORY_TEMP_LIMIT - (TEMP_HYSTERESIS if self.overheated else 0.) + self.overheated = state.tempC >= gpu_limit or state.memoryTempC >= memory_limit + + release = branch in CHESTNUT_RELEASE_BRANCHES + missing = self.usb_failed or (offroad and release and time.monotonic() - self.started > 10. and len(detected) != 1) + slow_usb = offroad and len(devices) == 1 and devices[0]["speedMbps"] < 5000 + big_model_available = (MODELS_DIR / 'big_driving_supercombo.onnx').is_file() or chestnut_compiled() + current_channel = get_build_metadata().channel + chestnut_target = CHESTNUT_BRANCHES.get(current_channel) + chestnut_needs_switch = len(devices) == 1 and not big_model_available and chestnut_target is not None + set_alert("Offroad_ChestnutBranch", chestnut_needs_switch, chestnut_target if chestnut_needs_switch else None) + set_alert("Offroad_ChestnutNotDetected", missing) + set_alert("Offroad_ChestnutOverheated", self.overheated, f"{state.tempC:.0f} °C" if state is not None else None) + set_alert("Offroad_ChestnutUsbSlow", slow_usb, f"{devices[0]['speedMbps']} Mbps" if slow_usb else None) + if self.power_lost: + pcie_alert = ("Chestnut power restored. 12V is stable again, cycle ignition." if self.power_restored else + "Chestnut power disconnected. Check 12V connection, then cycle ignition." if self.power_unavailable else + "Chestnut power lost. Possibly caused by an engine-crank voltage drop. Check 12V connection, then cycle ignition.") + else: + pcie_alert = "Chestnut GPU unavailable. PCIe link is not up. Check the GPU is securely seated." + set_alert("Offroad_ChestnutPcieUnavailable", self.pcie_failed, pcie_alert) + set_alert("Offroad_ChestnutUncompiled", offroad and firmware_ok and not chestnut_compiled()) + set_alert("Offroad_ChestnutUpdateFailed", offroad and firmware_failed) + self.offroad = offroad diff --git a/openpilot/system/hardware/hardwared.py b/openpilot/system/hardware/hardwared.py index 8aed8be5b9..0f051f6de3 100755 --- a/openpilot/system/hardware/hardwared.py +++ b/openpilot/system/hardware/hardwared.py @@ -16,19 +16,19 @@ from openpilot.common.utils import strip_deprecated_keys from openpilot.common.filter_simple import FirstOrderFilter from openpilot.common.params import Params from openpilot.common.realtime import DT_HW -from openpilot.selfdrive.modeld.helpers import MODELS_DIR, usbgpu_compiled from openpilot.selfdrive.selfdrived.alertmanager import set_offroad_alert from openpilot.common.hardware import HARDWARE, COMMA_HARDWARE from openpilot.common.basedir import BASEDIR -from openpilot.common.hardware.usb import CHESTNUT_FW_VERSION, CHESTNUT_ROM_USB_IDS, CHESTNUT_USB_IDS, get_usb_state, get_usb_topology, set_usb_state +from openpilot.common.git import get_short_branch +from openpilot.common.hardware.usb import CHESTNUT_FW_VERSION, CHESTNUT_USB_PRODUCT, get_usb_state, get_usb_topology, is_chestnut_usb_id, set_usb_state from openpilot.common.linux import LinuxSystemStats from openpilot.system.loggerd.config import get_available_percent from openpilot.common.swaglog import cloudlog from openpilot.sunnypilot.system.statsd import statlog from openpilot.system.hardware.power_monitoring import PowerMonitoring from openpilot.system.hardware.fan_controller import FanController -from openpilot.common.version import terms_version, training_version, get_build_metadata, terms_version_sp, CHESTNUT_BRANCHES - +from openpilot.system.hardware.chestnut.status import ChestnutStatus +from openpilot.common.version import terms_version, training_version, get_build_metadata, terms_version_sp ThermalStatus = log.DeviceState.ThermalStatus NetworkType = log.DeviceState.NetworkType @@ -49,6 +49,11 @@ class Chestnut: self.attempts = 0 self.last_attempt = 0. self.flashed = False + self.mismatch = False + + @property + def failed(self) -> bool: + return self.mismatch and self.attempts >= self.MAX_ATTEMPTS and self.thread is not None and not self.thread.is_alive() and not self.flashed def flash(self) -> None: ret = subprocess.run(["sudo", sys.executable, os.path.join(BASEDIR, "openpilot/system/hardware/chestnut/flash.py"), CHESTNUT_FW_VERSION], @@ -57,9 +62,9 @@ class Chestnut: self.flashed = ret.returncode == 0 def update(self, offroad: bool, usb_state: list[dict]) -> None: - mismatch = any((d["vendorId"], d["productId"]) in CHESTNUT_USB_IDS + CHESTNUT_ROM_USB_IDS and - d["product"] != f"custom {CHESTNUT_FW_VERSION}-CLEAN" for d in usb_state) - if not mismatch: + self.mismatch = any(is_chestnut_usb_id(d["vendorId"], d["productId"], include_bootloader=True) and + d["product"] != CHESTNUT_USB_PRODUCT for d in usb_state) + if not self.mismatch: self.flashed = False return @@ -191,7 +196,7 @@ def hw_state_thread(end_event, hw_queue): def hardware_thread(end_event, hw_queue) -> None: system_stats = LinuxSystemStats() pm = messaging.PubMaster(['deviceState']) - sm = messaging.SubMaster(["peripheralState", "gpsLocationExternal", "selfdriveState", "pandaStates"], poll="pandaStates") + sm = messaging.SubMaster(["peripheralState", "gpsLocationExternal", "selfdriveState", "pandaStates", "chestnutState"], poll="pandaStates") count = 0 @@ -239,7 +244,8 @@ def hardware_thread(end_event, hw_queue) -> None: fan_controller = FanController(int(1./DT_HW)) chestnut = Chestnut() - big_model_available = (MODELS_DIR / 'big_driving_supercombo.onnx').is_file() or usbgpu_compiled() + chestnut_status = ChestnutStatus() + branch = get_short_branch() while not end_event.is_set(): sm.update(PANDA_STATES_TIMEOUT) @@ -301,12 +307,11 @@ def hardware_thread(end_event, hw_queue) -> None: set_usb_state(msg.deviceState, last_hw_state.usb_state) chestnut.update(started_ts is None, last_hw_state.usb_state) - current_channel = get_build_metadata().channel - chestnut_target = CHESTNUT_BRANCHES.get(current_channel) - chestnut_needs_switch = msg.deviceState.chestnutPresent and not big_model_available and chestnut_target is not None - set_offroad_alert_if_changed("Offroad_ChestnutBranch", chestnut_needs_switch, - extra_text=chestnut_target if chestnut_needs_switch else None) - + chestnut_state = sm["chestnutState"] + chestnut_valid = sm.alive["chestnutState"] and sm.valid["chestnutState"] + chestnut_status.update(started_ts is None, branch, last_hw_state.usb_state, chestnut.failed, + params.get_bool("ChestnutLoading"), params.get("ChestnutActive"), + chestnut_state if chestnut_valid else None, set_offroad_alert_if_changed) # this subset is only used for offroad temp_sources = [ msg.deviceState.memoryTempC, diff --git a/openpilot/system/ui/lib/application.py b/openpilot/system/ui/lib/application.py index 0db4878d94..5a2848d4ae 100644 --- a/openpilot/system/ui/lib/application.py +++ b/openpilot/system/ui/lib/application.py @@ -13,7 +13,7 @@ import subprocess from contextlib import contextmanager from collections.abc import Callable from collections import deque -from enum import StrEnum +from enum import IntEnum, StrEnum from pathlib import Path from typing import NamedTuple from importlib.resources import as_file, files @@ -118,6 +118,18 @@ class FontWeight(StrEnum): DISPLAY = "Inter-Bold.ttf" +class TextAlignment(IntEnum): + LEFT = 0 + CENTER = 1 + RIGHT = 2 + + +class TextAlignmentVertical(IntEnum): + TOP = 0 + MIDDLE = 1 + BOTTOM = 2 + + def font_fallback(font: rl.Font) -> rl.Font: """Use a Noto fallback for languages not covered by Inter.""" if multilang.requires_font_fallback(): @@ -335,7 +347,6 @@ class GuiApplication(GuiApplicationExt): rl.set_target_fps(0 if OFFSCREEN or vblank_control else fps) self._target_fps = fps - self._set_styles() self._load_fonts() self._patch_text_functions() self._patch_scissor_mode() @@ -739,14 +750,6 @@ class GuiApplication(GuiApplicationExt): self._fonts[font_weight_file] = font if multilang.requires_font_fallback(): self.fallback_font() - rl.gui_set_font(self._fonts[FontWeight.NORMAL]) - - def _set_styles(self): - rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiControlProperty.BORDER_WIDTH, 0) - rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_SIZE, DEFAULT_TEXT_SIZE) - rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.BACKGROUND_COLOR, rl.color_to_int(rl.BLACK)) - rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiControlProperty.TEXT_COLOR_NORMAL, rl.color_to_int(DEFAULT_TEXT_COLOR)) - rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiControlProperty.BASE_COLOR_NORMAL, rl.color_to_int(rl.Color(50, 50, 50, 255))) def _patch_text_functions(self): # Wrap pyray text APIs to apply a global text size scale so our px sizes match Qt diff --git a/openpilot/system/ui/lib/utils.py b/openpilot/system/ui/lib/utils.py deleted file mode 100644 index e97b3ba9d9..0000000000 --- a/openpilot/system/ui/lib/utils.py +++ /dev/null @@ -1,19 +0,0 @@ -import pyray as rl -from collections.abc import Sequence - - -class GuiStyleContext: - def __init__(self, styles: Sequence[tuple[int, int, int]]): - """styles is a list of tuples (control, prop, new_value)""" - self.styles = styles - self.prev_styles: list[tuple[int, int, int]] = [] - - def __enter__(self): - for control, prop, new_value in self.styles: - prev_value = rl.gui_get_style(control, prop) - self.prev_styles.append((control, prop, prev_value)) - rl.gui_set_style(control, prop, new_value) - - def __exit__(self, exc_type, exc_value, traceback): - for control, prop, prev_value in self.prev_styles: - rl.gui_set_style(control, prop, prev_value) diff --git a/openpilot/system/ui/mici_setup.py b/openpilot/system/ui/mici_setup.py index efec377dd5..3e88e1942b 100755 --- a/openpilot/system/ui/mici_setup.py +++ b/openpilot/system/ui/mici_setup.py @@ -18,7 +18,7 @@ from openpilot.common.realtime import config_realtime_process, set_core_affinity from openpilot.common.swaglog import cloudlog from openpilot.common.time_helpers import system_time_valid from openpilot.common.utils import run_cmd -from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment, TextAlignmentVertical from openpilot.system.ui.lib.wifi_manager import WifiManager, ConnectStatus from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.nav_widget import NavWidget @@ -105,8 +105,8 @@ class StartPage(Widget): super().__init__() self._title = UnifiedLabel("start", 64, text_color=rl.Color(255, 255, 255, int(255 * 0.9)), - font_weight=FontWeight.DISPLAY, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + font_weight=FontWeight.DISPLAY, alignment=TextAlignment.CENTER, + alignment_vertical=TextAlignmentVertical.MIDDLE) self._start_bg_txt = gui_app.texture("icons_mici/setup/start_button.png", 500, 224, keep_aspect_ratio=False) self._start_bg_pressed_txt = gui_app.texture("icons_mici/setup/start_button_pressed.png", 500, 224, keep_aspect_ratio=False) @@ -197,7 +197,7 @@ class DownloadingPage(NavWidget): self._title_label = UnifiedLabel("downloading...", 64, text_color=rl.Color(255, 255, 255, int(255 * 0.9)), font_weight=FontWeight.DISPLAY) self._progress_label = UnifiedLabel("", 132, text_color=rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)), - font_weight=FontWeight.ROMAN, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM) + font_weight=FontWeight.ROMAN, alignment_vertical=TextAlignmentVertical.BOTTOM) self._progress = 0 def _back_enabled(self) -> bool: @@ -261,8 +261,8 @@ class BigPillButton(BigButton): super().__init__(*args, **kwargs) self._label.set_font_size(48) - self._label.set_alignment(rl.GuiTextAlignment.TEXT_ALIGN_CENTER) - self._label.set_alignment_vertical(rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + self._label.set_alignment(TextAlignment.CENTER) + self._label.set_alignment_vertical(TextAlignmentVertical.MIDDLE) def _load_images(self): if self._green: diff --git a/openpilot/system/ui/mici_updater.py b/openpilot/system/ui/mici_updater.py index d9009b8259..cc16145793 100755 --- a/openpilot/system/ui/mici_updater.py +++ b/openpilot/system/ui/mici_updater.py @@ -7,7 +7,7 @@ import pyray as rl from openpilot.common.realtime import config_realtime_process, set_core_affinity from openpilot.common.hardware import HARDWARE, COMMA_HARDWARE from openpilot.common.swaglog import cloudlog -from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignmentVertical from openpilot.system.ui.widgets.nav_widget import NavWidget from openpilot.system.ui.widgets.scroller import Scroller from openpilot.system.ui.widgets.label import UnifiedLabel @@ -30,7 +30,7 @@ class ProgressPage(NavWidget): font_weight=FontWeight.DISPLAY, line_height=0.8) self._progress_percent_label = UnifiedLabel("", 132, text_color=rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)), font_weight=FontWeight.ROMAN, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM) + alignment_vertical=TextAlignmentVertical.BOTTOM) def _back_enabled(self) -> bool: return False diff --git a/openpilot/system/ui/sunnypilot/lib/utils.py b/openpilot/system/ui/sunnypilot/lib/utils.py index b9ed152aff..30493ad27a 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, TextAlignmentVertical 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=TextAlignmentVertical.MIDDLE) def set_value(self, value: str | Callable[[], str], color: rl.Color = style.ITEM_TEXT_VALUE_COLOR): if self.value != _resolve_value(value, ""): diff --git a/openpilot/system/ui/sunnypilot/widgets/download_status.py b/openpilot/system/ui/sunnypilot/widgets/download_status.py index b299c464f1..c85814d0eb 100644 --- a/openpilot/system/ui/sunnypilot/widgets/download_status.py +++ b/openpilot/system/ui/sunnypilot/widgets/download_status.py @@ -9,6 +9,8 @@ import math import numpy as np import pyray as rl +from openpilot.system.ui.lib.application import TextAlignment, TextAlignmentVertical + 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 @@ -16,6 +18,7 @@ from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.sunnypilot.lib.styles import style from openpilot.system.ui.sunnypilot.widgets.list_view import ListItemSP from openpilot.system.ui.widgets.label import UnifiedLabel +from openpilot.system.ui.sunnypilot.lib.utils import UnifiedLabelSP from openpilot.system.ui.widgets.list_view import ItemAction FONT_SIZE = style.ITEM_TEXT_FONT_SIZE @@ -24,6 +27,8 @@ ICON_PADDING = 12 BAR_WIDTH = 1100 BAR_HEIGHT = 20 +SEGMENT_GAP = 24 +SEGMENT_NAME_MAX_WIDTH = 380 BAR_GAP = 16 BAR_RADIUS = BAR_HEIGHT / 2 CAPSULE_POINTS = 24 @@ -45,6 +50,8 @@ class DownloadStatusAction(ItemAction): super().__init__(width=BAR_WIDTH) self.name = "" self.status_text = "" + self.segments: list[tuple[str, rl.Color, str | None, rl.Color | None]] | None = None + self._segment_labels: list[UnifiedLabelSP] = [] self.downloading = False self.text_color = rl.GRAY self.icon: str | None = None @@ -56,13 +63,14 @@ class DownloadStatusAction(ItemAction): 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) + alignment=TextAlignment.LEFT, + alignment_vertical=TextAlignmentVertical.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) + alignment=TextAlignment.RIGHT, + alignment_vertical=TextAlignmentVertical.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 +93,22 @@ class DownloadStatusAction(ItemAction): def get_width_hint(self) -> float: if self.downloading: return BAR_WIDTH + if self.segments: + return sum(total for _, _, total in self._measured_segments()) width = measure_text_cached(self._font, self._idle_text, FONT_SIZE).x if self.icon: width += ICON_SIZE + ICON_PADDING return width + def _measured_segments(self): + """[(segment, text width, total width incl. icon and gap)]""" + out = [] + for i, seg in enumerate(self.segments or []): + text_width = min(measure_text_cached(self._font, seg[0], FONT_SIZE).x, SEGMENT_NAME_MAX_WIDTH) + total = text_width + (ICON_PADDING + ICON_SIZE if seg[2] else 0) + (SEGMENT_GAP if i else 0) + out.append((seg, text_width, total)) + return out + def _render(self, rect: rl.Rectangle): if self.downloading: self._render_downloading(rect) @@ -134,6 +153,8 @@ class DownloadStatusAction(ItemAction): def _render_downloading(self, rect: rl.Rectangle): percent = f"{int(self._progress.x)}%" + if self.status_text: + percent = f"{self.status_text} {percent}" text_height = measure_text_cached(self._font, percent, FONT_SIZE).y top = rect.y + (rect.height - (text_height + BAR_GAP + BAR_HEIGHT)) / 2 @@ -148,6 +169,9 @@ class DownloadStatusAction(ItemAction): self._draw_fill(rail, max(0.0, min(rect.width, rect.width * (self._progress.x / 100.0)))) def _render_idle(self, rect: rl.Rectangle): + if self.segments: + self._render_segments(rect) + return text = self._idle_text text_size = measure_text_cached(self._font, text, FONT_SIZE) right = rect.x + rect.width @@ -161,6 +185,29 @@ class DownloadStatusAction(ItemAction): rl.draw_text_ex(self._font, text, rl.Vector2(right - text_size.x, rect.y + (rect.height - text_size.y) / 2), FONT_SIZE, 0, self.text_color) + def _render_segments(self, rect: rl.Rectangle): + measured = self._measured_segments() + while len(self._segment_labels) < len(measured): + self._segment_labels.append(UnifiedLabelSP("", font_size=FONT_SIZE, max_width=SEGMENT_NAME_MAX_WIDTH, + scroll=True, wrap_text=False)) + x = rect.x + rect.width - sum(total for _, _, total in measured) + for i, ((text, color, icon, icon_color), text_width, _) in enumerate(measured): + if i: + x += SEGMENT_GAP + label = self._segment_labels[i] + if label.text != text: + label.set_text(text) + label.set_text_color(color) + text_height = measure_text_cached(self._font, text, FONT_SIZE).y + label.set_position(x, rect.y + (rect.height - text_height) / 2) + label.render() + x += text_width + if icon: + texture = gui_app.texture(icon, ICON_SIZE, ICON_SIZE, keep_aspect_ratio=True) + rl.draw_texture_v(texture, rl.Vector2(x + ICON_PADDING, rect.y + (rect.height - texture.height) / 2), + icon_color or color) + x += ICON_PADDING + ICON_SIZE + def download_status_item(title): return ListItemSP(title=title, action_item=DownloadStatusAction(), title_color=style.ITEM_TEXT_COLOR) diff --git a/openpilot/system/ui/sunnypilot/widgets/list_view.py b/openpilot/system/ui/sunnypilot/widgets/list_view.py index ff4b8c13dd..7d5529a341 100644 --- a/openpilot/system/ui/sunnypilot/widgets/list_view.py +++ b/openpilot/system/ui/sunnypilot/widgets/list_view.py @@ -8,7 +8,7 @@ from collections.abc import Callable, Sequence import pyray as rl from openpilot.common.params import Params -from openpilot.system.ui.lib.application import gui_app, MousePos, FontWeight +from openpilot.system.ui.lib.application import gui_app, MousePos, FontWeight, TextAlignment, TextAlignmentVertical from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.sunnypilot.widgets.toggle import ToggleSP from openpilot.system.ui.widgets import Widget @@ -90,8 +90,8 @@ class ButtonActionSP(ButtonAction): if value_text: value_rect = rl.Rectangle(rect.x, rect.y, rect.width - BUTTON_WIDTH - TEXT_PADDING, rect.height) gui_label(value_rect, value_text, font_size=style.ITEM_TEXT_FONT_SIZE, color=self._value_color, - font_weight=FontWeight.NORMAL, alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + font_weight=FontWeight.NORMAL, alignment=TextAlignment.LEFT, + alignment_vertical=TextAlignmentVertical.MIDDLE) pressed = self._pressed self._pressed = False @@ -319,7 +319,7 @@ class ListItemSP(ListItem): ) if value_rect.width > 0: gui_label(value_rect, value_text, font_size=style.ITEM_TEXT_FONT_SIZE, color=self._right_value_color, font_weight=FontWeight.NORMAL, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + alignment=TextAlignment.RIGHT, alignment_vertical=TextAlignmentVertical.MIDDLE) # Render toggle and handle callback if self.action_item.render(left_rect) and self.action_item.enabled: diff --git a/openpilot/system/ui/sunnypilot/widgets/tree_dialog.py b/openpilot/system/ui/sunnypilot/widgets/tree_dialog.py index 97150e1028..7a0d47895b 100644 --- a/openpilot/system/ui/sunnypilot/widgets/tree_dialog.py +++ b/openpilot/system/ui/sunnypilot/widgets/tree_dialog.py @@ -8,6 +8,7 @@ from dataclasses import dataclass, field import pyray as rl from openpilot.common.params import Params +from openpilot.system.ui.lib.application import TextAlignment from openpilot.system.ui.lib.application import FontWeight from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.widgets import DialogResult @@ -35,7 +36,7 @@ class TreeFolder: class TreeItemWidget(Button): def __init__(self, text, ref, is_folder=False, indent_level=0, click_callback=None, favorite_callback=None, is_favorite=False, is_expanded=False): - super().__init__(text, click_callback, button_style=ButtonStyle.NORMAL, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, + super().__init__(text, click_callback, button_style=ButtonStyle.NORMAL, text_alignment=TextAlignment.LEFT, text_padding=20 + indent_level * 30, elide_right=True) self.text = text self.ref = ref diff --git a/openpilot/system/ui/tici_reset.py b/openpilot/system/ui/tici_reset.py index 5c175dbfa6..ffe90bbb87 100755 --- a/openpilot/system/ui/tici_reset.py +++ b/openpilot/system/ui/tici_reset.py @@ -11,7 +11,7 @@ from openpilot.common.hardware import PC from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.button import Button, ButtonStyle -from openpilot.system.ui.widgets.label import gui_label, gui_text_box +from openpilot.system.ui.widgets.label import UnifiedLabel, gui_label USERDATA = "/dev/disk/by-partlabel/userdata" TIMEOUT = 3*60 @@ -38,6 +38,7 @@ class Reset(Widget): self._cancel_button = Button("Cancel", gui_app.request_close) self._confirm_button = Button("Confirm", self._confirm, button_style=ButtonStyle.PRIMARY) self._reboot_button = Button("Reboot", self._reboot) + self._body_label = UnifiedLabel(self._get_body_text, 90, line_height=1 / 0.9) @staticmethod def _reboot() -> None: @@ -75,7 +76,7 @@ class Reset(Widget): gui_label(label_rect, "System Reset", 100, font_weight=FontWeight.BOLD) text_rect = rl.Rectangle(content_rect.x + 140, content_rect.y + 140, content_rect.width - 280, content_rect.height - 90 - 100 * FONT_SCALE) - gui_text_box(text_rect, self._get_body_text(), 90) + self._body_label.render(text_rect) button_height = 160 button_spacing = 50 diff --git a/openpilot/system/ui/tici_setup.py b/openpilot/system/ui/tici_setup.py index 36f47ccf1b..17c3cf5e68 100755 --- a/openpilot/system/ui/tici_setup.py +++ b/openpilot/system/ui/tici_setup.py @@ -13,7 +13,7 @@ import pyray as rl from openpilot.cereal import log from openpilot.common.hardware import HARDWARE from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel -from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE +from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE, TextAlignment, TextAlignmentVertical from openpilot.system.ui.widgets import DialogResult, Widget from openpilot.system.ui.widgets.button import Button, ButtonStyle, ButtonRadio from openpilot.system.ui.widgets.keyboard import Keyboard @@ -67,17 +67,17 @@ class Setup(Widget): self.warning = gui_app.texture("icons/warning.png", 150, 150) self.checkmark = gui_app.texture("icons/circled_check.png", 100, 100) - self._low_voltage_title_label = Label("WARNING: Low Voltage", TITLE_FONT_SIZE, FontWeight.MEDIUM, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, + self._low_voltage_title_label = Label("WARNING: Low Voltage", TITLE_FONT_SIZE, FontWeight.MEDIUM, TextAlignment.LEFT, text_color=rl.Color(255, 89, 79, 255), text_padding=20) self._low_voltage_body_label = Label("Power your device in a car with a harness or proceed at your own risk.", BODY_FONT_SIZE, - text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20) + text_alignment=TextAlignment.LEFT, text_padding=20) self._low_voltage_continue_button = Button("Continue", self._low_voltage_continue_button_callback) self._low_voltage_poweroff_button = Button("Power Off", HARDWARE.shutdown) self._getting_started_button = Button("", self._getting_started_button_callback, button_style=ButtonStyle.PRIMARY, border_radius=0) - self._getting_started_title_label = Label("Getting Started", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20) + self._getting_started_title_label = Label("Getting Started", TITLE_FONT_SIZE, FontWeight.BOLD, TextAlignment.LEFT, text_padding=20) self._getting_started_body_label = Label("Before we get on the road, let's finish installation and cover some details.", - BODY_FONT_SIZE, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20) + BODY_FONT_SIZE, text_alignment=TextAlignment.LEFT, text_padding=20) self._software_selection_openpilot_button = ButtonRadio("openpilot", self.checkmark, font_size=BODY_FONT_SIZE, text_padding=80) self._software_selection_custom_software_button = ButtonRadio("Custom Software", self.checkmark, font_size=BODY_FONT_SIZE, text_padding=80) @@ -85,32 +85,32 @@ class Setup(Widget): button_style=ButtonStyle.PRIMARY) self._software_selection_continue_button.set_enabled(False) self._software_selection_back_button = Button("Back", self._software_selection_back_button_callback) - self._software_selection_title_label = Label("Choose Software to Use", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, + self._software_selection_title_label = Label("Choose Software to Use", TITLE_FONT_SIZE, FontWeight.BOLD, TextAlignment.LEFT, text_padding=20) self._download_failed_reboot_button = Button("Reboot device", HARDWARE.reboot) self._download_failed_startover_button = Button("Start over", self._download_failed_startover_button_callback, button_style=ButtonStyle.PRIMARY) - self._download_failed_title_label = Label("Download Failed", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20) - self._download_failed_url_label = Label("", 52, FontWeight.NORMAL, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20) - self._download_failed_body_label = Label("", BODY_FONT_SIZE, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20) + self._download_failed_title_label = Label("Download Failed", TITLE_FONT_SIZE, FontWeight.BOLD, TextAlignment.LEFT, text_padding=20) + self._download_failed_url_label = Label("", 52, FontWeight.NORMAL, TextAlignment.LEFT, text_padding=20) + self._download_failed_body_label = Label("", BODY_FONT_SIZE, text_alignment=TextAlignment.LEFT, text_padding=20) self._network_setup_back_button = Button("Back", self._network_setup_back_button_callback) self._network_setup_continue_button = Button("Waiting for internet", self._network_setup_continue_button_callback, button_style=ButtonStyle.PRIMARY) self._network_setup_continue_button.set_enabled(False) - self._network_setup_title_label = Label("Connect to Wi-Fi", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20) + self._network_setup_title_label = Label("Connect to Wi-Fi", TITLE_FONT_SIZE, FontWeight.BOLD, TextAlignment.LEFT, text_padding=20) self._custom_software_warning_continue_button = Button("Scroll to continue", self._custom_software_warning_continue_button_callback, button_style=ButtonStyle.PRIMARY) self._custom_software_warning_continue_button.set_enabled(False) self._custom_software_warning_back_button = Button("Back", self._custom_software_warning_back_button_callback) - self._custom_software_warning_title_label = Label("WARNING: Custom Software", 81, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, + self._custom_software_warning_title_label = Label("WARNING: Custom Software", 81, FontWeight.BOLD, TextAlignment.LEFT, text_color=rl.Color(255, 89, 79, 255), text_padding=60) self._yellow_warning_icon = gui_app.texture("icons/yellow_warning.png", int(68 * FONT_SCALE), int(68 * FONT_SCALE)) self._custom_software_warning_body_labels = [ - Label(text, 68, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, - text_alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, + Label(text, 68, text_alignment=TextAlignment.LEFT, + text_alignment_vertical=TextAlignmentVertical.TOP, text_padding=60, icon=self._yellow_warning_icon if has_icon else None) for text, has_icon in [ ("Use caution when installing third-party software.", False), diff --git a/openpilot/system/ui/tici_updater.py b/openpilot/system/ui/tici_updater.py index 27cf6579a8..2cb86cca88 100755 --- a/openpilot/system/ui/tici_updater.py +++ b/openpilot/system/ui/tici_updater.py @@ -10,7 +10,7 @@ from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE from openpilot.system.ui.lib.wifi_manager import WifiManager from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.button import Button, ButtonStyle -from openpilot.system.ui.widgets.label import gui_text_box, gui_label +from openpilot.system.ui.widgets.label import UnifiedLabel, gui_label from openpilot.system.ui.widgets.network import WifiManagerUI # Constants @@ -50,6 +50,8 @@ class Updater(Widget): self._install_button = Button("Install", click_callback=self.install_update, button_style=ButtonStyle.PRIMARY) self._back_button = Button("Back", click_callback=lambda: self.set_current_screen(Screen.PROMPT)) self._reboot_button = Button("Reboot", click_callback=lambda: HARDWARE.reboot()) + self._desc_label = UnifiedLabel("An operating system update is required. Connect your device to Wi-Fi for the fastest update experience. " + + "The download size is approximately 1GB.", BODY_FONT_SIZE, line_height=1 / 0.9) def set_current_screen(self, screen: Screen): self.current_screen = screen @@ -99,11 +101,8 @@ class Updater(Widget): gui_label(title_rect, "Update Required", TITLE_FONT_SIZE, font_weight=FontWeight.BOLD) # Description - desc_text = ("An operating system update is required. Connect your device to Wi-Fi for the fastest update experience. " + - "The download size is approximately 1GB.") - desc_rect = rl.Rectangle(MARGIN + 50, 250 + TITLE_FONT_SIZE * FONT_SCALE + 75, rect.width - MARGIN * 2 - 100, BODY_FONT_SIZE * FONT_SCALE * 4) - gui_text_box(desc_rect, desc_text, BODY_FONT_SIZE) + self._desc_label.render(desc_rect) # Buttons at the bottom button_y = rect.height - MARGIN - BUTTON_HEIGHT diff --git a/openpilot/system/ui/widgets/button.py b/openpilot/system/ui/widgets/button.py index 36ef3bedab..c031b2f141 100644 --- a/openpilot/system/ui/widgets/button.py +++ b/openpilot/system/ui/widgets/button.py @@ -3,7 +3,7 @@ from enum import IntEnum import pyray as rl -from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos +from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, TextAlignment from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.label import Label from openpilot.common.filter_simple import FirstOrderFilter @@ -86,7 +86,7 @@ class Button(Widget): font_weight: FontWeight = FontWeight.MEDIUM, button_style: ButtonStyle = ButtonStyle.NORMAL, border_radius: int = 10, - text_alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_CENTER, + text_alignment: int = TextAlignment.CENTER, text_padding: int = 20, icon=None, elide_right: bool = False, @@ -139,7 +139,7 @@ class ButtonRadio(Button): icon, click_callback: Callable[[], None] | None = None, font_size: int = DEFAULT_BUTTON_FONT_SIZE, - text_alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT, + text_alignment: int = TextAlignment.LEFT, border_radius: int = 10, text_padding: int = 20, ): diff --git a/openpilot/system/ui/widgets/icon_widget.py b/openpilot/system/ui/widgets/icon_widget.py index bf7790b937..f6f8a47231 100644 --- a/openpilot/system/ui/widgets/icon_widget.py +++ b/openpilot/system/ui/widgets/icon_widget.py @@ -14,3 +14,6 @@ class IconWidget(Widget): def _render(self, _) -> None: color = rl.Color(255, 255, 255, int(self._opacity * 255)) rl.draw_texture_ex(self._texture, rl.Vector2(self._rect.x, self._rect.y), 0.0, 1.0, color) + + def set_opacity(self, opacity: float) -> None: + self._opacity = opacity diff --git a/openpilot/system/ui/widgets/keyboard.py b/openpilot/system/ui/widgets/keyboard.py index 49c59a431f..c9d2187cbb 100644 --- a/openpilot/system/ui/widgets/keyboard.py +++ b/openpilot/system/ui/widgets/keyboard.py @@ -5,7 +5,7 @@ from collections.abc import Callable import pyray as rl -from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.widgets import DialogResult, Widget from openpilot.system.ui.widgets.button import ButtonStyle, Button @@ -65,8 +65,8 @@ class Keyboard(Widget): self._layout_name: Literal["lowercase", "uppercase", "numbers", "specials"] = "lowercase" self._caps_lock = False self._last_shift_press_time = 0 - self._title = Label("", 90, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20) - self._sub_title = Label("", 55, FontWeight.NORMAL, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20) + self._title = Label("", 90, FontWeight.BOLD, TextAlignment.LEFT, text_padding=20) + self._sub_title = Label("", 55, FontWeight.NORMAL, TextAlignment.LEFT, text_padding=20) self._max_text_size = max_text_size self._min_text_size = min_text_size diff --git a/openpilot/system/ui/widgets/label.py b/openpilot/system/ui/widgets/label.py index a3e827321c..60fa3e25e0 100644 --- a/openpilot/system/ui/widgets/label.py +++ b/openpilot/system/ui/widgets/label.py @@ -4,10 +4,9 @@ from collections.abc import Callable from typing import Union import pyray as rl -from openpilot.system.ui.lib.application import gui_app, FontWeight, DEFAULT_TEXT_SIZE, DEFAULT_TEXT_COLOR, FONT_SCALE +from openpilot.system.ui.lib.application import gui_app, FontWeight, DEFAULT_TEXT_SIZE, DEFAULT_TEXT_COLOR, FONT_SCALE, TextAlignment, TextAlignmentVertical from openpilot.system.ui.widgets import Widget from openpilot.system.ui.lib.text_measure import measure_text_cached -from openpilot.system.ui.lib.utils import GuiStyleContext from openpilot.system.ui.lib.wrap_text import wrap_text ICON_PADDING = 15 @@ -32,8 +31,8 @@ def gui_label( font_size: int = DEFAULT_TEXT_SIZE, color: rl.Color = DEFAULT_TEXT_COLOR, font_weight: FontWeight = FontWeight.NORMAL, - alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT, - alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, + alignment: int = TextAlignment.LEFT, + alignment_vertical: int = TextAlignmentVertical.MIDDLE, elide_right: bool = True ): font = gui_app.font(font_weight) @@ -57,16 +56,16 @@ def gui_label( # Calculate horizontal position based on alignment text_x = rect.x + { - rl.GuiTextAlignment.TEXT_ALIGN_LEFT: 0, - rl.GuiTextAlignment.TEXT_ALIGN_CENTER: (rect.width - text_size.x) / 2, - rl.GuiTextAlignment.TEXT_ALIGN_RIGHT: rect.width - text_size.x, + TextAlignment.LEFT: 0, + TextAlignment.CENTER: (rect.width - text_size.x) / 2, + TextAlignment.RIGHT: rect.width - text_size.x, }.get(alignment, 0) # Calculate vertical position based on alignment text_y = rect.y + { - rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP: 0, - rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE: (rect.height - text_size.y) / 2, - rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM: rect.height - text_size.y, + TextAlignmentVertical.TOP: 0, + TextAlignmentVertical.MIDDLE: (rect.height - text_size.y) / 2, + TextAlignmentVertical.BOTTOM: rect.height - text_size.y, }.get(alignment_vertical, 0) # Draw the text in the specified rectangle @@ -74,42 +73,14 @@ def gui_label( rl.draw_text_ex(font, display_text, rl.Vector2(text_x, text_y), font_size, 0, color) -def gui_text_box( - rect: rl.Rectangle, - text: str, - font_size: int = DEFAULT_TEXT_SIZE, - color: rl.Color = DEFAULT_TEXT_COLOR, - alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT, - alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, - font_weight: FontWeight = FontWeight.NORMAL, - line_scale: float = 1.0, -): - styles = [ - (rl.GuiControl.DEFAULT, rl.GuiControlProperty.TEXT_COLOR_NORMAL, rl.color_to_int(color)), - (rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_SIZE, round(font_size * FONT_SCALE)), - (rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_LINE_SPACING, round(font_size * FONT_SCALE * line_scale)), - (rl.GuiControl.DEFAULT, rl.GuiControlProperty.TEXT_ALIGNMENT, alignment), - (rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_ALIGNMENT_VERTICAL, alignment_vertical), - (rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_WRAP_MODE, rl.GuiTextWrapMode.TEXT_WRAP_WORD) - ] - if font_weight != FontWeight.NORMAL: - rl.gui_set_font(gui_app.font(font_weight)) - - with GuiStyleContext(styles): - rl.gui_label(rect, text) - - if font_weight != FontWeight.NORMAL: - rl.gui_set_font(gui_app.font(FontWeight.NORMAL)) - - # Non-interactive text area. Can render an optional specified icon. class Label(Widget): def __init__(self, text: str | Callable[[], str], font_size: int = DEFAULT_TEXT_SIZE, font_weight: FontWeight = FontWeight.NORMAL, - text_alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_CENTER, - text_alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, + text_alignment: int = TextAlignment.CENTER, + text_alignment_vertical: int = TextAlignmentVertical.MIDDLE, text_padding: int = 0, text_color: rl.Color = DEFAULT_TEXT_COLOR, icon: Union[rl.Texture, None] = None, @@ -181,10 +152,10 @@ class Label(Widget): self._update_text(self._text) text_size = self._text_size[0] if self._text_size else rl.Vector2(0.0, 0.0) - if self._text_alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE: + if self._text_alignment_vertical == TextAlignmentVertical.MIDDLE: total_text_height = sum(ts.y for ts in self._text_size) or self._font_size * FONT_SCALE text_pos = rl.Vector2(self._rect.x, (self._rect.y + (self._rect.height - total_text_height) // 2)) - elif self._text_alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM: + elif self._text_alignment_vertical == TextAlignmentVertical.BOTTOM: total_text_height = sum(ts.y for ts in self._text_size) or self._font_size * FONT_SCALE text_pos = rl.Vector2(self._rect.x, self._rect.y + self._rect.height - total_text_height) else: @@ -193,10 +164,10 @@ class Label(Widget): if self._icon: icon_y = self._rect.y + (self._rect.height - self._icon.height) / 2 if len(self._text_wrapped) > 0: - if self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_LEFT: + if self._text_alignment == TextAlignment.LEFT: icon_x = self._rect.x + self._text_padding text_pos.x = self._rect.x + self._icon.width + ICON_PADDING - elif self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_CENTER: + elif self._text_alignment == TextAlignment.CENTER: total_width = self._icon.width + ICON_PADDING + text_size.x icon_x = self._rect.x + (self._rect.width - total_width) / 2 text_pos.x = self._rect.x + self._icon.width + ICON_PADDING @@ -208,11 +179,11 @@ class Label(Widget): for text, text_size in zip(self._text_wrapped, self._text_size, strict=True): line_pos = rl.Vector2(text_pos.x, text_pos.y) - if self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_LEFT: + if self._text_alignment == TextAlignment.LEFT: line_pos.x += self._text_padding - elif self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_CENTER: + elif self._text_alignment == TextAlignment.CENTER: line_pos.x += (self._rect.width - text_size.x) // 2 - elif self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_RIGHT: + elif self._text_alignment == TextAlignment.RIGHT: line_pos.x += self._rect.width - text_size.x - self._text_padding rl.draw_text_ex(self._font, text, line_pos, self._font_size, 0, self._text_color) @@ -221,7 +192,7 @@ class Label(Widget): class UnifiedLabel(Widget): """ - Unified label widget that combines functionality from gui_label, gui_text_box, and Label. + Unified label widget that combines functionality from gui_label and Label. Supports: - Text wrapping @@ -241,8 +212,8 @@ class UnifiedLabel(Widget): font_size: int = DEFAULT_TEXT_SIZE, font_weight: FontWeight = FontWeight.NORMAL, text_color: rl.Color = DEFAULT_TEXT_COLOR, - alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT, - alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, + alignment: int = TextAlignment.LEFT, + alignment_vertical: int = TextAlignmentVertical.TOP, text_padding: int = 0, max_width: int | None = None, elide: bool = True, @@ -561,9 +532,9 @@ class UnifiedLabel(Widget): total_visible_height += size.y * self._line_height # Calculate vertical alignment offset - if self._alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP: + if self._alignment_vertical == TextAlignmentVertical.TOP: start_y = self._rect.y - elif self._alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM: + elif self._alignment_vertical == TextAlignmentVertical.BOTTOM: start_y = self._rect.y + self._rect.height - total_visible_height else: # TEXT_ALIGN_MIDDLE start_y = self._rect.y + (self._rect.height - total_visible_height) / 2 @@ -640,11 +611,11 @@ class UnifiedLabel(Widget): def _render_line(self, line, size, current_y, x_offset=0.0): # Calculate horizontal position - if self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_LEFT: + if self._alignment == TextAlignment.LEFT: line_x = self._rect.x + self._text_padding - elif self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_CENTER: + elif self._alignment == TextAlignment.CENTER: line_x = self._rect.x + (self._rect.width - size.x) / 2 - elif self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_RIGHT: + elif self._alignment == TextAlignment.RIGHT: line_x = self._rect.x + self._rect.width - size.x - self._text_padding else: line_x = self._rect.x + self._text_padding @@ -662,9 +633,9 @@ class UnifiedLabel(Widget): def _render_line_shimmer(self, line, line_x, current_y): # Shimmer range based on widest line so sweep is even across all lines max_width = self.text_width - if self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_RIGHT: + if self._alignment == TextAlignment.RIGHT: shimmer_left = self._rect.x + self._rect.width - self._text_padding - max_width - elif self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_CENTER: + elif self._alignment == TextAlignment.CENTER: shimmer_left = self._rect.x + (self._rect.width - max_width) / 2 else: shimmer_left = self._rect.x + self._text_padding diff --git a/openpilot/system/ui/widgets/list_view.py b/openpilot/system/ui/widgets/list_view.py index 61c77b7600..b376928c58 100644 --- a/openpilot/system/ui/widgets/list_view.py +++ b/openpilot/system/ui/widgets/list_view.py @@ -3,7 +3,7 @@ import os import pyray as rl from collections.abc import Callable, Sequence from abc import ABC -from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos +from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, TextAlignment, TextAlignmentVertical from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.widgets import Widget @@ -139,8 +139,8 @@ class ButtonAction(ItemAction): if value_text: value_rect = rl.Rectangle(rect.x, rect.y, rect.width - BUTTON_WIDTH - TEXT_PADDING, rect.height) gui_label(value_rect, value_text, font_size=ITEM_TEXT_FONT_SIZE, color=ITEM_TEXT_VALUE_COLOR, - font_weight=FontWeight.NORMAL, alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + font_weight=FontWeight.NORMAL, alignment=TextAlignment.LEFT, + alignment_vertical=TextAlignmentVertical.MIDDLE) # TODO: just use the generic Widget click callbacks everywhere, no returning from render pressed = self._pressed @@ -168,8 +168,8 @@ class TextAction(ItemAction): def _render(self, rect: rl.Rectangle) -> bool: gui_label(self._rect, self.text, font_size=ITEM_TEXT_FONT_SIZE, color=self.color, - font_weight=FontWeight.NORMAL, alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + font_weight=FontWeight.NORMAL, alignment=TextAlignment.RIGHT, + alignment_vertical=TextAlignmentVertical.MIDDLE) return False def set_text(self, text: str | Callable[[], str]): diff --git a/openpilot/system/ui/widgets/network.py b/openpilot/system/ui/widgets/network.py index fb999c0441..2a7d97cd64 100644 --- a/openpilot/system/ui/widgets/network.py +++ b/openpilot/system/ui/widgets/network.py @@ -3,7 +3,7 @@ from functools import partial from typing import Any, cast import pyray as rl -from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.application import gui_app, TextAlignment from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel from openpilot.system.ui.lib.wifi_manager import WifiManager, SecurityType, Network, MeteredType, normalize_ssid @@ -67,7 +67,7 @@ class NavButton(Widget): def _render(self, _): color = rl.Color(74, 74, 74, 255) if self.is_pressed else rl.Color(57, 57, 57, 255) rl.draw_rectangle_rounded(self._rect, 0.6, 10, color) - gui_label(self.rect, self.text, font_size=60, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) + gui_label(self.rect, self.text, font_size=60, alignment=TextAlignment.CENTER) class NetworkUI(Widget): @@ -315,7 +315,7 @@ class WifiManagerUI(Widget): def _render(self, rect: rl.Rectangle): if not self._networks: - gui_label(rect, tr("Scanning Wi-Fi networks..."), 72, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) + gui_label(rect, tr("Scanning Wi-Fi networks..."), 72, alignment=TextAlignment.CENTER) return if self.state == UIState.NEEDS_AUTH and self._state_network: @@ -389,7 +389,7 @@ class WifiManagerUI(Widget): if status_text: status_text_rect = rl.Rectangle(security_icon_rect.x - 410, rect.y, 410, ITEM_HEIGHT) - gui_label(status_text_rect, status_text, font_size=48, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) + gui_label(status_text_rect, status_text, font_size=48, alignment=TextAlignment.CENTER) else: # If the network is saved, show the "Forget" button if self._wifi_manager.is_connection_saved(network.ssid): @@ -455,7 +455,7 @@ class WifiManagerUI(Widget): self._networks = networks for n in self._networks: self._networks_buttons[n.ssid] = Button(normalize_ssid(n.ssid), partial(self._networks_buttons_callback, n), font_size=55, - text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, button_style=ButtonStyle.TRANSPARENT_WHITE_TEXT) + text_alignment=TextAlignment.LEFT, button_style=ButtonStyle.TRANSPARENT_WHITE_TEXT) self._networks_buttons[n.ssid].set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid()) self._forget_networks_buttons[n.ssid] = Button(tr("Forget"), partial(self._forget_networks_buttons_callback, n), button_style=ButtonStyle.FORGET_WIFI, font_size=45) diff --git a/openpilot/system/ui/widgets/option_dialog.py b/openpilot/system/ui/widgets/option_dialog.py index 206400a74f..816503c4a0 100644 --- a/openpilot/system/ui/widgets/option_dialog.py +++ b/openpilot/system/ui/widgets/option_dialog.py @@ -1,6 +1,6 @@ import pyray as rl from collections.abc import Callable -from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.widgets import Widget, DialogResult from openpilot.system.ui.widgets.button import Button, ButtonStyle @@ -29,7 +29,7 @@ class MultiOptionDialog(Widget): # Create scroller with option buttons self.option_buttons = [Button(option, click_callback=lambda opt=option: self._on_option_clicked(opt), font_weight=option_font_weight, - text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, button_style=ButtonStyle.NORMAL, + text_alignment=TextAlignment.LEFT, button_style=ButtonStyle.NORMAL, text_padding=50, elide_right=True) for option in options] self.scroller = Scroller(self.option_buttons, spacing=LIST_ITEM_SPACING) diff --git a/openpilot/system/ui/widgets/slider.py b/openpilot/system/ui/widgets/slider.py index bf965954f2..7bdf056219 100644 --- a/openpilot/system/ui/widgets/slider.py +++ b/openpilot/system/ui/widgets/slider.py @@ -3,7 +3,7 @@ from collections.abc import Callable import pyray as rl -from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment, TextAlignmentVertical from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.label import UnifiedLabel from openpilot.common.filter_simple import FirstOrderFilter, BounceFilter @@ -41,8 +41,8 @@ class SliderBase(Widget, abc.ABC): self._is_dragging_circle = False self._label = self._child(UnifiedLabel(title, font_size=36, font_weight=FontWeight.SEMI_BOLD, text_color=rl.WHITE, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, line_height=0.9, shimmer=True)) + alignment=TextAlignment.RIGHT, + alignment_vertical=TextAlignmentVertical.MIDDLE, line_height=0.9, shimmer=True)) @abc.abstractmethod def _load_assets(self): diff --git a/openpilot/tools/cabana/SConscript b/openpilot/tools/cabana/SConscript index 387129709e..63b725a759 100644 --- a/openpilot/tools/cabana/SConscript +++ b/openpilot/tools/cabana/SConscript @@ -99,10 +99,10 @@ cabana_env.Command(assets, "assets/assets.qrc", f"rcc $SOURCES -o $TARGET") cabana_env.Depends(assets, Glob('/assets/*', exclude=[assets, "assets/assets.o"])) cabana_srcs = ['mainwin.cc', 'streams/pandastream.cc', 'streams/devicestream.cc', 'streams/livestream.cc', 'streams/abstractstream.cc', 'streams/replaystream.cc', 'binaryview.cc', 'historylog.cc', 'videowidget.cc', 'signalview.cc', - 'streams/routes.cc', 'dbc/dbc.cc', 'dbc/dbcfile.cc', 'dbc/dbcmanager.cc', 'dbc/dbcqt.cc', - 'utils/export.cc', 'utils/util.cc', 'utils/elidedlabel.cc', + 'routesdialog.cc', 'routes.cc', 'dbc/dbc.cc', 'dbc/dbcfile.cc', 'dbc/dbcmanager.cc', + 'utils/export.cc', 'utils/util.cc', 'utils/qtutil.cc', 'utils/strings.cc', 'utils/elidedlabel.cc', 'chart/chartswidget.cc', 'chart/chart.cc', 'chart/signalselector.cc', 'chart/tiplabel.cc', 'chart/sparkline.cc', - 'commands.cc', 'messageswidget.cc', 'streamselector.cc', 'settings.cc', 'panda.cc', + 'commands.cc', 'messageswidget.cc', 'streamselector.cc', 'settings.cc', 'settingsdialog.cc', 'panda.cc', 'cameraview.cc', 'detailwidget.cc', 'tools/findsimilarbits.cc', 'tools/findsignal.cc', 'tools/routeinfo.cc'] if arch != "Darwin": cabana_srcs += ['streams/socketcanstream.cc'] @@ -119,8 +119,12 @@ if GetOption('extras'): dbc_core_test_env.Object('tests/dbc_core_model', 'dbc/dbc.cc'), dbc_core_test_env.Object('tests/dbc_core_file', 'dbc/dbcfile.cc'), dbc_core_test_env.Object('tests/dbc_core_manager', 'dbc/dbcmanager.cc'), + dbc_core_test_env.Object('tests/dbc_core_strings', 'utils/strings.cc'), + dbc_core_test_env.Object('tests/dbc_core_util', 'utils/util.cc'), + dbc_core_test_env.Object('tests/dbc_core_icons', bootstrap_icons_src), + dbc_core_test_env.Object('tests/dbc_core_routes', 'routes.cc'), ] - dbc_core_test_env.Program('tests/test_dbc_core', dbc_core_test_objects) + dbc_core_test_env.Program('tests/test_dbc_core', dbc_core_test_objects, LIBS=[replay_lib, common]) output_json_file = 'openpilot/tools/cabana/dbc/car_fingerprint_to_dbc.json' generate_dbc = cabana_env.Command('#' + output_json_file, diff --git a/openpilot/tools/cabana/binaryview.cc b/openpilot/tools/cabana/binaryview.cc index 5e919dc6a3..20fd2ec1ec 100644 --- a/openpilot/tools/cabana/binaryview.cc +++ b/openpilot/tools/cabana/binaryview.cc @@ -1,5 +1,4 @@ #include "tools/cabana/binaryview.h" -#include "tools/cabana/dbc/dbcqt.h" #include @@ -14,6 +13,7 @@ #include #include "tools/cabana/commands.h" +#include "tools/cabana/utils/qtutil.h" // BinaryView @@ -36,8 +36,8 @@ BinaryView::BinaryView(QWidget *parent) : QTableView(parent) { setMouseTracking(true); setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &BinaryView::refresh); - QObject::connect(undoNotifier(), &QtUndoNotifier::indexChanged, this, &BinaryView::refresh); + connections_.push_back(dbc()->fileChanged.connect([this]() { refresh(); })); + connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { refresh(); })); addShortcuts(); setWhatsThis(R"( @@ -386,7 +386,7 @@ QVariant BinaryViewModel::headerData(int section, Qt::Orientation orientation, i QVariant BinaryViewModel::data(const QModelIndex &index, int role) const { auto item = (const BinaryViewModel::Item *)index.internalPointer(); - return role == Qt::ToolTipRole && item && !item->sigs.empty() ? signalToolTip(item->sigs.back()) : QVariant(); + return role == Qt::ToolTipRole && item && !item->sigs.empty() ? QString::fromStdString(utils::signalToolTip(item->sigs.back())) : QVariant(); } // BinaryItemDelegate diff --git a/openpilot/tools/cabana/binaryview.h b/openpilot/tools/cabana/binaryview.h index c49067a1a2..c918ee01e7 100644 --- a/openpilot/tools/cabana/binaryview.h +++ b/openpilot/tools/cabana/binaryview.h @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -100,5 +101,6 @@ private: bool is_message_active = false; const cabana::Signal *resize_sig = nullptr; const cabana::Signal *hovered_sig = nullptr; + Connections connections_; friend class BinaryItemDelegate; }; diff --git a/openpilot/tools/cabana/cabana.cc b/openpilot/tools/cabana/cabana.cc index 8b21143faf..b18484c556 100644 --- a/openpilot/tools/cabana/cabana.cc +++ b/openpilot/tools/cabana/cabana.cc @@ -12,6 +12,7 @@ #ifdef __linux__ #include "tools/cabana/streams/socketcanstream.h" #endif +#include "tools/cabana/utils/qtutil.h" namespace { @@ -134,7 +135,14 @@ int main(int argc, char *argv[]) { app.setApplicationDisplayName("Cabana"); //app.setWindowIcon(QIcon(":cabana-icon.png")); // TODO: do this in imgui - UnixSignalHandler signalHandler; + // Marshal exit onto the GUI thread (qApp methods are not thread-safe). + UnixSignalHandler signalHandler([]() { + QMetaObject::invokeMethod(qApp, []() { + printf("\nexiting...\n"); + qApp->closeAllWindows(); + qApp->exit(); + }, Qt::QueuedConnection); + }); utils::setTheme(settings.theme); CabanaArgs args; @@ -146,19 +154,19 @@ int main(int argc, char *argv[]) { AbstractStream *stream = nullptr; if (args.msgq) { - stream = new DeviceStream(&app); + stream = new DeviceStream(); } else if (!args.zmq.empty()) { - stream = new DeviceStream(&app, QString::fromStdString(args.zmq)); + stream = new DeviceStream(args.zmq); } else if (args.panda || !args.panda_serial.empty()) { try { - stream = new PandaStream(&app, {.serial = args.panda_serial}); + stream = new PandaStream({.serial = args.panda_serial}); } catch (std::exception &e) { fprintf(stderr, "%s\n", e.what()); return 0; } #ifdef __linux__ } else if (SocketCanStream::available() && !args.socketcan.empty()) { - stream = new SocketCanStream(&app, {.device = args.socketcan}); + stream = new SocketCanStream({.device = args.socketcan}); #endif } else { uint32_t replay_flags = REPLAY_FLAG_NONE; @@ -174,7 +182,8 @@ int main(int argc, char *argv[]) { route = DEMO_ROUTE; } if (!route.isEmpty()) { - auto replay_stream = std::make_unique(&app); + auto replay_stream = std::make_unique(); + Connection err = replay_stream->error.connect([](const std::string &msg) { fprintf(stderr, "%s\n", msg.c_str()); }); if (!replay_stream->loadRoute(route.toStdString(), args.data_dir, replay_flags, args.auto_source)) { return 0; } diff --git a/openpilot/tools/cabana/cameraview.cc b/openpilot/tools/cabana/cameraview.cc index 9bd6b9be19..b8c70ab1bf 100644 --- a/openpilot/tools/cabana/cameraview.cc +++ b/openpilot/tools/cabana/cameraview.cc @@ -9,13 +9,11 @@ #include #include "common/yuv.h" +#include "tools/cabana/utils/util.h" CameraWidget::CameraWidget(std::string stream_name, VisionStreamType type, QWidget* parent) : stream_name(stream_name), active_stream_type(type), requested_stream_type(type), QWidget(parent) { setAttribute(Qt::WA_OpaquePaintEvent); - qRegisterMetaType>("availableStreams"); - QObject::connect(this, &CameraWidget::vipcThreadFrameReceived, this, &CameraWidget::vipcFrameReceived, Qt::QueuedConnection); - QObject::connect(this, &CameraWidget::vipcAvailableStreamsUpdated, this, &CameraWidget::availableStreamsUpdated, Qt::QueuedConnection); QObject::connect(QApplication::instance(), &QCoreApplication::aboutToQuit, this, &CameraWidget::stopVipcThread); } @@ -38,10 +36,6 @@ void CameraWidget::stopVipcThread() { } } -void CameraWidget::availableStreamsUpdated(std::set streams) { - available_streams = streams; -} - void CameraWidget::paintEvent(QPaintEvent *event) { QPainter p(this); p.fillRect(rect(), bg); @@ -67,10 +61,6 @@ void CameraWidget::paintEvent(QPaintEvent *event) { p.drawImage(video_rect, rgb_frame); } -void CameraWidget::vipcFrameReceived() { - update(); -} - void CameraWidget::vipcThread() { VisionStreamType cur_stream = requested_stream_type; std::unique_ptr vipc_client; @@ -93,7 +83,11 @@ void CameraWidget::vipcThread() { std::this_thread::sleep_for(std::chrono::milliseconds(100)); continue; } - emit vipcAvailableStreamsUpdated(streams); + utils::runOnMainThread([this, alive = std::weak_ptr(alive_), streams]() { + if (alive.expired()) return; + available_streams = streams; + availableStreamsUpdated(streams); + }); if (!vipc_client->connect(false)) { std::this_thread::sleep_for(std::chrono::milliseconds(100)); @@ -112,7 +106,9 @@ void CameraWidget::vipcThread() { std::lock_guard lk(frame_lock); rgb_frame.swap(rgb_back); } - emit vipcThreadFrameReceived(); + utils::runOnMainThread([this, alive = std::weak_ptr(alive_)]() { + if (!alive.expired()) update(); + }); } } } diff --git a/openpilot/tools/cabana/cameraview.h b/openpilot/tools/cabana/cameraview.h index 3b55dd9ed9..ac292a701c 100644 --- a/openpilot/tools/cabana/cameraview.h +++ b/openpilot/tools/cabana/cameraview.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -11,11 +12,10 @@ #include #include "openpilot/cereal/visionstream.h" +#include "tools/cabana/core/observable.h" #include "msgq/visionipc/visionipc_client.h" class CameraWidget : public QWidget { - Q_OBJECT - public: explicit CameraWidget(std::string stream_name, VisionStreamType stream_type, QWidget* parent = nullptr); ~CameraWidget(); @@ -23,16 +23,14 @@ public: VisionStreamType getStreamType() { return active_stream_type; } void stopVipcThread(); -signals: - void clicked(); - void vipcThreadFrameReceived(); - void vipcAvailableStreamsUpdated(std::set); + Observable<> clicked; + Observable> availableStreamsUpdated; // invoked on the main thread protected: void paintEvent(QPaintEvent *event) override; void showEvent(QShowEvent *event) override; void hideEvent(QHideEvent *event) override { stopVipcThread(); } - void mouseReleaseEvent(QMouseEvent *event) override { emit clicked(); } + void mouseReleaseEvent(QMouseEvent *event) override { clicked(); } void vipcThread(); void clearFrames(); @@ -47,10 +45,5 @@ protected: std::thread vipc_thread; std::atomic vipc_exit = false; std::mutex frame_lock; - -protected slots: - void vipcFrameReceived(); - void availableStreamsUpdated(std::set streams); + std::shared_ptr alive_ = std::make_shared(true); }; - -Q_DECLARE_METATYPE(std::set); diff --git a/openpilot/tools/cabana/chart/chart.cc b/openpilot/tools/cabana/chart/chart.cc index 8496c26994..478ff3ce9d 100644 --- a/openpilot/tools/cabana/chart/chart.cc +++ b/openpilot/tools/cabana/chart/chart.cc @@ -1,5 +1,4 @@ #include "tools/cabana/chart/chart.h" -#include "tools/cabana/dbc/dbcqt.h" #include #include @@ -37,10 +36,10 @@ ChartView::ChartView(const std::pair &x_range, ChartsWidget *par createToolButtons(); signal_value_font.setPointSize(9); - QObject::connect(dbcNotifier(), &QtDBCNotifier::signalRemoved, this, &ChartView::signalRemoved); - QObject::connect(dbcNotifier(), &QtDBCNotifier::signalUpdated, this, &ChartView::signalUpdated); - QObject::connect(dbcNotifier(), &QtDBCNotifier::msgRemoved, this, &ChartView::msgRemoved); - QObject::connect(dbcNotifier(), &QtDBCNotifier::msgUpdated, this, &ChartView::msgUpdated); + connections_.push_back(dbc()->signalRemoved.connect([this](const cabana::Signal *sig) { signalRemoved(sig); })); + connections_.push_back(dbc()->signalUpdated.connect([this](const cabana::Signal *sig) { signalUpdated(sig); })); + connections_.push_back(dbc()->msgRemoved.connect([this](MessageId id) { msgRemoved(id); })); + connections_.push_back(dbc()->msgUpdated.connect([this](MessageId id) { msgUpdated(id); })); } void ChartView::createToolButtons() { @@ -249,7 +248,7 @@ void ChartView::updateSeries(const cabana::Signal *sig, const MessageEventsMap * } if (!can->liveStreaming()) { - s.segment_tree.build(s.vals); + s.segment_tree.build(s.vals.size(), [&vals = s.vals](int i) { return vals[i].y(); }); } } } diff --git a/openpilot/tools/cabana/chart/chart.h b/openpilot/tools/cabana/chart/chart.h index b03623475a..3188e1f401 100644 --- a/openpilot/tools/cabana/chart/chart.h +++ b/openpilot/tools/cabana/chart/chart.h @@ -10,6 +10,7 @@ #include "tools/cabana/chart/tiplabel.h" #include "tools/cabana/dbc/dbcmanager.h" #include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/utils/qtutil.h" enum class SeriesType { Line = 0, @@ -126,5 +127,6 @@ private: double tooltip_x = -1; QFont signal_value_font; ChartsWidget *charts_widget; + Connections connections_; friend class ChartsWidget; }; diff --git a/openpilot/tools/cabana/chart/chartswidget.cc b/openpilot/tools/cabana/chart/chartswidget.cc index 3144c5132d..2e3e86bab0 100644 --- a/openpilot/tools/cabana/chart/chartswidget.cc +++ b/openpilot/tools/cabana/chart/chartswidget.cc @@ -1,5 +1,4 @@ #include "tools/cabana/chart/chartswidget.h" -#include "tools/cabana/dbc/dbcqt.h" #include #include @@ -76,10 +75,10 @@ ChartsWidget::ChartsWidget(QWidget *parent) : QFrame(parent) { redo_zoom_action = toolbar->addAction(utils::icon("arrow-clockwise"), tr("Redo Zoom"), [this]() { zoom_undo_stack.redo(); }); undo_zoom_action->setEnabled(false); redo_zoom_action->setEnabled(false); - zoom_undo_stack.setCallbacks({.index_changed = [this]() { + connections_.push_back(zoom_undo_stack.indexChanged.connect([this]() { undo_zoom_action->setEnabled(zoom_undo_stack.canUndo()); redo_zoom_action->setEnabled(zoom_undo_stack.canRedo()); - }}); + })); reset_zoom_action = toolbar->addWidget(reset_zoom_btn = new ToolButton("zoom-out", tr("Reset Zoom"))); reset_zoom_btn->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); @@ -122,16 +121,16 @@ ChartsWidget::ChartsWidget(QWidget *parent) : QFrame(parent) { align_timer->setSingleShot(true); QObject::connect(align_timer, &QTimer::timeout, this, &ChartsWidget::alignCharts); QObject::connect(auto_scroll_timer, &QTimer::timeout, this, &ChartsWidget::doAutoScroll); - QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &ChartsWidget::removeAll); - QObject::connect(can, &AbstractStream::eventsMerged, this, &ChartsWidget::eventsMerged); - QObject::connect(can, &AbstractStream::msgsReceived, this, &ChartsWidget::updateState); - QObject::connect(can, &AbstractStream::seeking, this, &ChartsWidget::updateState); - QObject::connect(can, &AbstractStream::timeRangeChanged, this, &ChartsWidget::timeRangeChanged); + connections_.push_back(dbc()->fileChanged.connect([this]() { removeAll(); })); + connections_.push_back(can->eventsMerged.connect([this](const MessageEventsMap &events) { eventsMerged(events); })); + connections_.push_back(can->msgsReceived.connect([this](const std::set *, bool) { updateState(); })); + connections_.push_back(can->seeking.connect([this](double) { updateState(); })); + connections_.push_back(can->timeRangeChanged.connect([this](const auto &range) { timeRangeChanged(range); })); QObject::connect(range_slider, &QSlider::valueChanged, this, &ChartsWidget::setMaxChartRange); QObject::connect(new_plot_btn, &QToolButton::clicked, this, &ChartsWidget::newChart); QObject::connect(remove_all_btn, &QToolButton::clicked, this, &ChartsWidget::removeAll); QObject::connect(reset_zoom_btn, &QToolButton::clicked, this, &ChartsWidget::zoomReset); - QObject::connect(&settings, &Settings::changed, this, &ChartsWidget::settingChanged); + connections_.push_back(settings.changed.connect([this]() { settingChanged(); })); QObject::connect(new_tab_btn, &QToolButton::clicked, this, &ChartsWidget::newTab); QObject::connect(this, &ChartsWidget::seriesChanged, this, &ChartsWidget::updateTabBar); QObject::connect(tabbar, &QTabBar::tabCloseRequested, this, &ChartsWidget::removeTab); @@ -246,7 +245,7 @@ void ChartsWidget::setIsDocked(bool docked) { void ChartsWidget::updateToolBar() { title_label->setText(tr("Charts: %1").arg(charts.size())); columns_action->setText(tr("Columns: %1").arg(column_count)); - range_lb->setText(utils::formatSeconds(max_chart_range)); + range_lb->setText(QString::fromStdString(utils::formatSeconds(max_chart_range))); bool is_zoomed = can->timeRange().has_value(); range_lb_action->setVisible(!is_zoomed); diff --git a/openpilot/tools/cabana/chart/chartswidget.h b/openpilot/tools/cabana/chart/chartswidget.h index 8b3003dcd6..17e18a90d2 100644 --- a/openpilot/tools/cabana/chart/chartswidget.h +++ b/openpilot/tools/cabana/chart/chartswidget.h @@ -13,6 +13,7 @@ #include "tools/cabana/commands.h" #include "tools/cabana/dbc/dbcmanager.h" #include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/utils/qtutil.h" const int CHART_MIN_WIDTH = 300; @@ -123,6 +124,7 @@ private: QTimer *align_timer; int current_theme = 0; bool value_tip_visible_ = false; + Connections connections_; friend class ChartView; friend class ChartsContainer; }; diff --git a/openpilot/tools/cabana/chart/signalselector.cc b/openpilot/tools/cabana/chart/signalselector.cc index 85832e796b..90825dc402 100644 --- a/openpilot/tools/cabana/chart/signalselector.cc +++ b/openpilot/tools/cabana/chart/signalselector.cc @@ -1,5 +1,4 @@ #include "tools/cabana/chart/signalselector.h" -#include "tools/cabana/dbc/dbcqt.h" #include #include @@ -9,6 +8,7 @@ #include #include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/utils/qtutil.h" SignalSelector::SignalSelector(QString title, QWidget *parent) : QDialog(parent) { setWindowTitle(title); diff --git a/openpilot/tools/cabana/chart/sparkline.cc b/openpilot/tools/cabana/chart/sparkline.cc index f5bef0fc2e..587a35956d 100644 --- a/openpilot/tools/cabana/chart/sparkline.cc +++ b/openpilot/tools/cabana/chart/sparkline.cc @@ -3,6 +3,7 @@ #include #include #include +#include "tools/cabana/utils/qtutil.h" void Sparkline::update(const cabana::Signal *sig, CanEventIter first, CanEventIter last, int range, QSize size) { if (first == last || size.isEmpty()) { diff --git a/openpilot/tools/cabana/chart/tiplabel.cc b/openpilot/tools/cabana/chart/tiplabel.cc index 233fa80373..c250ebf877 100644 --- a/openpilot/tools/cabana/chart/tiplabel.cc +++ b/openpilot/tools/cabana/chart/tiplabel.cc @@ -7,7 +7,7 @@ #include #include "tools/cabana/settings.h" -#include "tools/cabana/utils/util.h" +#include "tools/cabana/utils/qtutil.h" TipLabel::TipLabel(QWidget *parent) : QLabel(parent, Qt::ToolTip | Qt::FramelessWindowHint) { setAttribute(Qt::WA_ShowWithoutActivating); diff --git a/openpilot/tools/cabana/commands.cc b/openpilot/tools/cabana/commands.cc index b47bf90b0c..40d1c5ba18 100644 --- a/openpilot/tools/cabana/commands.cc +++ b/openpilot/tools/cabana/commands.cc @@ -1,5 +1,6 @@ #include "tools/cabana/commands.h" +#include #include // UndoStack @@ -28,22 +29,22 @@ void UndoStack::clear() { bool was_clean = isClean(); commands_.clear(); index_ = clean_index_ = 0; - if (callbacks_.index_changed) callbacks_.index_changed(); - if (!was_clean && callbacks_.clean_changed) callbacks_.clean_changed(true); + indexChanged(); + if (!was_clean) cleanChanged(true); } void UndoStack::setClean() { if (!isClean()) { clean_index_ = index_; - if (callbacks_.clean_changed) callbacks_.clean_changed(true); + cleanChanged(true); } } void UndoStack::setIndex(int index) { bool was_clean = isClean(); index_ = index; - if (callbacks_.index_changed) callbacks_.index_changed(); - if (isClean() != was_clean && callbacks_.clean_changed) callbacks_.clean_changed(isClean()); + indexChanged(); + if (isClean() != was_clean) cleanChanged(isClean()); } UndoStack *UndoStack::instance() { @@ -51,18 +52,6 @@ UndoStack *UndoStack::instance() { return &undo_stack; } -QtUndoNotifier::QtUndoNotifier(QObject *parent) : QObject(parent) { - UndoStack::instance()->setCallbacks({ - .index_changed = [this]() { emit indexChanged(); }, - .clean_changed = [this](bool clean) { emit cleanChanged(clean); }, - }); -} - -QtUndoNotifier *undoNotifier() { - static QtUndoNotifier notifier; - return ¬ifier; -} - // EditMsgCommand EditMsgCommand::EditMsgCommand(const MessageId &id, const std::string &name, int size, diff --git a/openpilot/tools/cabana/commands.h b/openpilot/tools/cabana/commands.h index 200a4f2f5b..1e40499515 100644 --- a/openpilot/tools/cabana/commands.h +++ b/openpilot/tools/cabana/commands.h @@ -1,13 +1,11 @@ #pragma once -#include #include #include #include #include -#include - +#include "tools/cabana/core/observable.h" #include "tools/cabana/dbc/dbcmanager.h" #include "tools/cabana/streams/abstractstream.h" @@ -21,11 +19,6 @@ public: class UndoStack { public: - struct Callbacks { - std::function index_changed; - std::function clean_changed; - }; - void push(UndoCommand *cmd); // takes ownership and calls redo() void undo(); void redo(); @@ -36,31 +29,18 @@ public: bool canRedo() const { return index_ < (int)commands_.size(); } std::string undoText() const { return canUndo() ? commands_[index_ - 1]->text : ""; } std::string redoText() const { return canRedo() ? commands_[index_]->text : ""; } - void setCallbacks(Callbacks callbacks) { callbacks_ = std::move(callbacks); } static UndoStack *instance(); + Observable<> indexChanged; + Observable cleanChanged; + private: void setIndex(int index); std::vector> commands_; int index_ = 0; int clean_index_ = 0; - Callbacks callbacks_; }; -// emits Qt signals for the global undo stack -class QtUndoNotifier : public QObject { - Q_OBJECT - -public: - explicit QtUndoNotifier(QObject *parent = nullptr); - -signals: - void indexChanged(); - void cleanChanged(bool clean); -}; - -QtUndoNotifier *undoNotifier(); - class EditMsgCommand : public UndoCommand { public: EditMsgCommand(const MessageId &id, const std::string &name, int size, const std::string &node, diff --git a/openpilot/tools/cabana/core/observable.h b/openpilot/tools/cabana/core/observable.h new file mode 100644 index 0000000000..0c992bf1da --- /dev/null +++ b/openpilot/tools/cabana/core/observable.h @@ -0,0 +1,83 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace observable_detail { +struct HandlerTable { + virtual ~HandlerTable() = default; + virtual void erase(int id) = 0; +}; +} // namespace observable_detail + +// disconnects on destruction; safe to outlive the Observable +class Connection { +public: + Connection() = default; + Connection(std::weak_ptr table, int id) : table_(std::move(table)), id_(id) {} + Connection(Connection &&other) noexcept { *this = std::move(other); } + Connection &operator=(Connection &&other) noexcept { + if (this != &other) { + disconnect(); + table_ = std::move(other.table_); + id_ = std::exchange(other.id_, -1); + } + return *this; + } + Connection(const Connection &) = delete; + Connection &operator=(const Connection &) = delete; + ~Connection() { disconnect(); } + + void disconnect() { + if (auto table = table_.lock()) table->erase(id_); + table_.reset(); + id_ = -1; + } + +private: + std::weak_ptr table_; + int id_ = -1; +}; + +using Connections = std::vector; + +// main thread only. handlers may disconnect (or destroy the Observable) while being invoked. +template +class Observable { +public: + using Handler = std::function; + + Observable() = default; + Observable(const Observable &) = delete; + Observable &operator=(const Observable &) = delete; + + [[nodiscard]] Connection connect(Handler handler) { + int id = table_->next_id++; + table_->handlers.emplace(id, std::make_shared(std::move(handler))); + return Connection(table_, id); + } + + void operator()(Args... args) const { + auto table = table_; + std::vector ids; + ids.reserve(table->handlers.size()); + for (const auto &[id, _] : table->handlers) ids.push_back(id); + for (int id : ids) { + auto it = table->handlers.find(id); + if (it == table->handlers.end()) continue; + auto handler = it->second; + (*handler)(args...); + } + } + +private: + struct Table : observable_detail::HandlerTable { + std::map> handlers; + int next_id = 0; + void erase(int id) override { handlers.erase(id); } + }; + std::shared_ptr table_ = std::make_shared
(); +}; diff --git a/openpilot/tools/cabana/dbc/dbcmanager.cc b/openpilot/tools/cabana/dbc/dbcmanager.cc index 7a95a4f809..551619ea82 100644 --- a/openpilot/tools/cabana/dbc/dbcmanager.cc +++ b/openpilot/tools/cabana/dbc/dbcmanager.cc @@ -17,7 +17,7 @@ bool DBCManager::open(const SourceSet &sources, const std::string &dbc_file_name return false; } - if (callbacks_.file_changed) callbacks_.file_changed(); + fileChanged(); return true; } @@ -32,7 +32,7 @@ bool DBCManager::open(const SourceSet &sources, const std::string &name, const s return false; } - if (callbacks_.file_changed) callbacks_.file_changed(); + fileChanged(); return true; } @@ -40,26 +40,26 @@ void DBCManager::close(const SourceSet &sources) { for (auto s : sources) { dbc_files[s] = nullptr; } - if (callbacks_.file_changed) callbacks_.file_changed(); + fileChanged(); } void DBCManager::close(DBCFile *dbc_file) { for (auto &[_, f] : dbc_files) { if (f.get() == dbc_file) f = nullptr; } - if (callbacks_.file_changed) callbacks_.file_changed(); + fileChanged(); } void DBCManager::closeAll() { dbc_files.clear(); - if (callbacks_.file_changed) callbacks_.file_changed(); + fileChanged(); } void DBCManager::addSignal(const MessageId &id, const cabana::Signal &sig) { if (auto m = msg(id)) { if (auto s = m->addSignal(sig)) { - if (callbacks_.signal_added) callbacks_.signal_added(id, s); - if (callbacks_.mask_updated) callbacks_.mask_updated(); + signalAdded(id, s); + maskUpdated(); } } } @@ -67,8 +67,8 @@ void DBCManager::addSignal(const MessageId &id, const cabana::Signal &sig) { void DBCManager::updateSignal(const MessageId &id, const std::string &sig_name, const cabana::Signal &sig) { if (auto m = msg(id)) { if (auto s = m->updateSignal(sig_name, sig)) { - if (callbacks_.signal_updated) callbacks_.signal_updated(s); - if (callbacks_.mask_updated) callbacks_.mask_updated(); + signalUpdated(s); + maskUpdated(); } } } @@ -76,9 +76,9 @@ void DBCManager::updateSignal(const MessageId &id, const std::string &sig_name, void DBCManager::removeSignal(const MessageId &id, const std::string &sig_name) { if (auto m = msg(id)) { if (auto s = m->sig(sig_name)) { - if (callbacks_.signal_removed) callbacks_.signal_removed(s); + signalRemoved(s); m->removeSignal(sig_name); - if (callbacks_.mask_updated) callbacks_.mask_updated(); + maskUpdated(); } } } @@ -87,15 +87,15 @@ void DBCManager::updateMsg(const MessageId &id, const std::string &name, uint32_ auto dbc_file = findDBCFile(id); assert(dbc_file); // This should be impossible dbc_file->updateMsg(id, name, size, node, comment); - if (callbacks_.msg_updated) callbacks_.msg_updated(id); + msgUpdated(id); } void DBCManager::removeMsg(const MessageId &id) { auto dbc_file = findDBCFile(id); assert(dbc_file); // This should be impossible dbc_file->removeMsg(id); - if (callbacks_.msg_removed) callbacks_.msg_removed(id); - if (callbacks_.mask_updated) callbacks_.mask_updated(); + msgRemoved(id); + maskUpdated(); } std::string DBCManager::newMsgName(const MessageId &id) { diff --git a/openpilot/tools/cabana/dbc/dbcmanager.h b/openpilot/tools/cabana/dbc/dbcmanager.h index 5a09fae03d..6bc86a8167 100644 --- a/openpilot/tools/cabana/dbc/dbcmanager.h +++ b/openpilot/tools/cabana/dbc/dbcmanager.h @@ -1,12 +1,12 @@ #pragma once -#include #include #include #include #include #include +#include "tools/cabana/core/observable.h" #include "tools/cabana/dbc/dbcfile.h" typedef std::set SourceSet; @@ -15,16 +15,6 @@ inline bool operator<(const std::shared_ptr &l, const std::shared_ptr signal_added; - std::function signal_removed; - std::function signal_updated; - std::function msg_updated; - std::function msg_removed; - std::function file_changed; - std::function mask_updated; - }; - DBCManager() = default; bool open(const SourceSet &sources, const std::string &dbc_file_name, std::string *error = nullptr); bool open(const SourceSet &sources, const std::string &name, const std::string &content, std::string *error = nullptr); @@ -54,11 +44,17 @@ public: DBCFile *findDBCFile(const uint8_t source); inline DBCFile *findDBCFile(const MessageId &id) { return findDBCFile(id.source); } std::set allDBCFiles(); - void setCallbacks(Callbacks callbacks) { callbacks_ = std::move(callbacks); } + + Observable signalAdded; + Observable signalRemoved; + Observable signalUpdated; + Observable msgUpdated; + Observable msgRemoved; + Observable<> fileChanged; + Observable<> maskUpdated; private: std::map> dbc_files; - Callbacks callbacks_; }; DBCManager *dbc(); diff --git a/openpilot/tools/cabana/dbc/dbcqt.cc b/openpilot/tools/cabana/dbc/dbcqt.cc deleted file mode 100644 index 4354caf3f5..0000000000 --- a/openpilot/tools/cabana/dbc/dbcqt.cc +++ /dev/null @@ -1,18 +0,0 @@ -#include "tools/cabana/dbc/dbcqt.h" - -QtDBCNotifier::QtDBCNotifier(QObject *parent) : QObject(parent) { - dbc()->setCallbacks({ - .signal_added = [this](MessageId id, const cabana::Signal *sig) { emit signalAdded(id, sig); }, - .signal_removed = [this](const cabana::Signal *sig) { emit signalRemoved(sig); }, - .signal_updated = [this](const cabana::Signal *sig) { emit signalUpdated(sig); }, - .msg_updated = [this](MessageId id) { emit msgUpdated(id); }, - .msg_removed = [this](MessageId id) { emit msgRemoved(id); }, - .file_changed = [this]() { emit DBCFileChanged(); }, - .mask_updated = [this]() { emit maskUpdated(); }, - }); -} - -QtDBCNotifier *dbcNotifier() { - static QtDBCNotifier notifier; - return ¬ifier; -} diff --git a/openpilot/tools/cabana/dbc/dbcqt.h b/openpilot/tools/cabana/dbc/dbcqt.h deleted file mode 100644 index b889f854ed..0000000000 --- a/openpilot/tools/cabana/dbc/dbcqt.h +++ /dev/null @@ -1,27 +0,0 @@ -#pragma once - -#include -#include - -#include "tools/cabana/dbc/dbcmanager.h" - -Q_DECLARE_METATYPE(MessageId) -Q_DECLARE_METATYPE(ValueDescription) - -class QtDBCNotifier : public QObject { - Q_OBJECT - -public: - explicit QtDBCNotifier(QObject *parent = nullptr); - -signals: - void signalAdded(MessageId id, const cabana::Signal *sig); - void signalRemoved(const cabana::Signal *sig); - void signalUpdated(const cabana::Signal *sig); - void msgUpdated(MessageId id); - void msgRemoved(MessageId id); - void DBCFileChanged(); - void maskUpdated(); -}; - -QtDBCNotifier *dbcNotifier(); diff --git a/openpilot/tools/cabana/detailwidget.cc b/openpilot/tools/cabana/detailwidget.cc index 6b62f54959..d0cccb5992 100644 --- a/openpilot/tools/cabana/detailwidget.cc +++ b/openpilot/tools/cabana/detailwidget.cc @@ -1,5 +1,4 @@ #include "tools/cabana/detailwidget.h" -#include "tools/cabana/dbc/dbcqt.h" #include #include @@ -56,9 +55,9 @@ DetailWidget::DetailWidget(ChartsWidget *charts, QWidget *parent) : charts(chart QObject::connect(signal_view, &SignalView::showChart, charts, &ChartsWidget::showChart); QObject::connect(signal_view, &SignalView::highlight, binary_view, &BinaryView::highlight); QObject::connect(tab_widget, &QTabWidget::currentChanged, [this]() { updateState(); }); - QObject::connect(can, &AbstractStream::msgsReceived, this, &DetailWidget::updateState); - QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &DetailWidget::refresh); - QObject::connect(undoNotifier(), &QtUndoNotifier::indexChanged, this, &DetailWidget::refresh); + connections_.push_back(can->msgsReceived.connect([this](const std::set *msgs, bool) { updateState(msgs); })); + connections_.push_back(dbc()->fileChanged.connect([this]() { refresh(); })); + connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { refresh(); })); QObject::connect(tabbar, &QTabBar::customContextMenuRequested, this, &DetailWidget::showTabBarContextMenu); QObject::connect(tabbar, &QTabBar::currentChanged, [this](int index) { if (index != -1) { @@ -97,11 +96,11 @@ void DetailWidget::createToolBar() { layout()->addWidget(toolbar); connect(heatmap_live, &QAbstractButton::toggled, this, [this](bool on) { binary_view->setHeatmapLiveMode(on); }); - connect(can, &AbstractStream::timeRangeChanged, this, [=](const std::optional> &range) { + connections_.push_back(can->timeRangeChanged.connect([=](const std::optional> &range) { auto text = range ? QString("%1 - %2").arg(range->first, 0, 'f', 3).arg(range->second, 0, 'f', 3) : "All"; heatmap_all->setText(text); (range ? heatmap_all : heatmap_live)->setChecked(true); - }); + })); } void DetailWidget::showTabBarContextMenu(const QPoint &pt) { diff --git a/openpilot/tools/cabana/detailwidget.h b/openpilot/tools/cabana/detailwidget.h index 0fe1535c7a..c003548da4 100644 --- a/openpilot/tools/cabana/detailwidget.h +++ b/openpilot/tools/cabana/detailwidget.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -11,6 +12,7 @@ #include "tools/cabana/historylog.h" #include "tools/cabana/signalview.h" #include "tools/cabana/utils/elidedlabel.h" +#include "tools/cabana/utils/qtutil.h" class EditMessageDialog : public QDialog { public: @@ -57,6 +59,7 @@ private: SignalView *signal_view; ChartsWidget *charts; QSplitter *splitter; + Connections connections_; }; class CenterWidget : public QWidget { diff --git a/openpilot/tools/cabana/historylog.cc b/openpilot/tools/cabana/historylog.cc index 3fd569c648..26c0f4168f 100644 --- a/openpilot/tools/cabana/historylog.cc +++ b/openpilot/tools/cabana/historylog.cc @@ -1,5 +1,4 @@ #include "tools/cabana/historylog.h" -#include "tools/cabana/dbc/dbcqt.h" #include @@ -10,6 +9,12 @@ #include "tools/cabana/commands.h" #include "tools/cabana/utils/export.h" +HistoryLogModel::HistoryLogModel(QObject *parent) : QAbstractTableModel(parent) { + connections_.push_back(can->seekedTo.connect([this](double) { reset(); })); + connections_.push_back(dbc()->fileChanged.connect([this]() { reset(); })); + connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { reset(); })); +} + QVariant HistoryLogModel::data(const QModelIndex &index, int role) const { const auto &m = messages[index.row()]; const int col = index.column(); @@ -207,9 +212,6 @@ LogsWidget::LogsWidget(QWidget *parent) : QFrame(parent) { QObject::connect(comp_box, SIGNAL(activated(int)), this, SLOT(filterChanged())); QObject::connect(value_edit, &QLineEdit::textEdited, this, &LogsWidget::filterChanged); QObject::connect(export_btn, &QToolButton::clicked, this, &LogsWidget::exportToCSV); - QObject::connect(can, &AbstractStream::seekedTo, model, &HistoryLogModel::reset); - QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, model, &HistoryLogModel::reset); - QObject::connect(undoNotifier(), &QtUndoNotifier::indexChanged, model, &HistoryLogModel::reset); QObject::connect(model, &HistoryLogModel::modelReset, this, &LogsWidget::modelReset); QObject::connect(model, &HistoryLogModel::rowsInserted, [this]() { export_btn->setEnabled(true); }); } diff --git a/openpilot/tools/cabana/historylog.h b/openpilot/tools/cabana/historylog.h index 1d3200b200..1b75e3b8a9 100644 --- a/openpilot/tools/cabana/historylog.h +++ b/openpilot/tools/cabana/historylog.h @@ -10,6 +10,7 @@ #include "tools/cabana/dbc/dbcmanager.h" #include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/utils/qtutil.h" class HeaderView : public QHeaderView { public: @@ -22,7 +23,7 @@ class HistoryLogModel : public QAbstractTableModel { Q_OBJECT public: - HistoryLogModel(QObject *parent) : QAbstractTableModel(parent) {} + HistoryLogModel(QObject *parent); void setMessage(const MessageId &message_id); void updateState(bool clear = false); void setFilter(int sig_idx, const QString &value, std::function cmp); @@ -54,6 +55,7 @@ public: std::deque messages; std::vector sigs; bool hex_mode = false; + Connections connections_; }; class LogsWidget : public QFrame { diff --git a/openpilot/tools/cabana/mainwin.cc b/openpilot/tools/cabana/mainwin.cc index 953f573a0b..2c19df530c 100644 --- a/openpilot/tools/cabana/mainwin.cc +++ b/openpilot/tools/cabana/mainwin.cc @@ -1,5 +1,4 @@ #include "tools/cabana/mainwin.h" -#include "tools/cabana/dbc/dbcqt.h" #include #include @@ -20,9 +19,11 @@ #include "json11/json11.hpp" #include "tools/cabana/commands.h" +#include "tools/cabana/settingsdialog.h" #include "tools/cabana/streamselector.h" #include "tools/cabana/tools/findsignal.h" #include "tools/cabana/utils/export.h" +#include "tools/cabana/utils/qtutil.h" #include "tools/replay/py_downloader.h" #include "tools/replay/util.h" @@ -41,15 +42,13 @@ MainWindow::MainWindow(AbstractStream *stream, const QString &dbc_file) : QMainW restoreGeometry(utils::qbytes(settings.geometry)); restoreState(utils::qbytes(settings.window_state)); - // install handlers + // download handlers are called from download threads static auto static_main_win = this; - qRegisterMetaType("uint64_t"); - qRegisterMetaType("SourceSet"); installDownloadProgressHandler([](uint64_t cur, uint64_t total, bool success) { - emit static_main_win->updateProgressBar(cur, total, success); + utils::runOnMainThread([=]() { static_main_win->updateDownloadProgress(cur, total, success); }); }); installMessageHandler([](ReplyMsgType type, const std::string msg) { - emit static_main_win->showMessage(QString::fromStdString(msg), 2000); + utils::runOnMainThread([=]() { static_main_win->statusBar()->showMessage(QString::fromStdString(msg), 2000); }); }); setStyleSheet(QString(R"(QMainWindow::separator { @@ -57,11 +56,14 @@ MainWindow::MainWindow(AbstractStream *stream, const QString &dbc_file) : QMainW height: %1px; /* when horizontal */ })").arg(style()->pixelMetric(QStyle::PM_SplitterWidth))); - QObject::connect(this, &MainWindow::showMessage, statusBar(), &QStatusBar::showMessage); - QObject::connect(this, &MainWindow::updateProgressBar, this, &MainWindow::updateDownloadProgress); - QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &MainWindow::DBCFileChanged); - QObject::connect(undoNotifier(), &QtUndoNotifier::cleanChanged, this, &MainWindow::undoStackCleanChanged); - QObject::connect(&settings, &Settings::changed, this, &MainWindow::updateStatus); + connections_.push_back(dbc()->fileChanged.connect([this]() { DBCFileChanged(); })); + connections_.push_back(UndoStack::instance()->cleanChanged.connect([this](bool clean) { undoStackCleanChanged(clean); })); + connections_.push_back(settings.changed.connect([this]() { updateStatus(); })); + + // temporary pump for the non-Qt main thread queue until imgui owns the loop + auto *queue_timer = new QTimer(this); + QObject::connect(queue_timer, &QTimer::timeout, utils::drainMainThreadQueue); + queue_timer->start(10); QTimer::singleShot(0, this, [=]() { stream ? openStream(stream, dbc_file) : selectAndOpenStream(); }); show(); @@ -136,7 +138,7 @@ void MainWindow::createActions() { undo_act->setShortcuts(QKeySequence::Undo); redo_act = edit_menu->addAction(tr("&Redo"), []() { UndoStack::instance()->redo(); }); redo_act->setShortcuts(QKeySequence::Redo); - QObject::connect(undoNotifier(), &QtUndoNotifier::indexChanged, this, &MainWindow::updateUndoRedoActions); + connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { updateUndoRedoActions(); })); updateUndoRedoActions(); // View Menu @@ -259,14 +261,14 @@ void MainWindow::selectAndOpenStream() { if (dlg.exec()) { openStream(dlg.stream(), dlg.dbcFile()); } else if (!can) { - openStream(new DummyStream(this)); + openStream(new DummyStream()); } } void MainWindow::closeStream() { - openStream(new DummyStream(this)); + openStream(new DummyStream()); if (dbc()->nonEmptyDBCCount() > 0) { - emit dbcNotifier()->DBCFileChanged(); + dbc()->fileChanged(); } statusBar()->showMessage(tr("stream closed")); } @@ -336,13 +338,19 @@ void MainWindow::loadFromClipboard(SourceSet s, bool close_all) { } } +// stream threads read the global `can` until its destructor joins them +MainWindow::~MainWindow() { + delete can; + can = nullptr; +} + void MainWindow::openStream(AbstractStream *stream, const QString &dbc_file) { - if (can) { - QObject::connect(can, &QObject::destroyed, this, [=]() { startStream(stream, dbc_file); }); - can->deleteLater(); - } else { - startStream(stream, dbc_file); - } + stream_connections_.clear(); + if (wait_dlg_) wait_dlg_->deleteLater(); + wait_dlg_ = nullptr; + delete can; + can = nullptr; + startStream(stream, dbc_file); } void MainWindow::startStream(AbstractStream *stream, QString dbc_file) { @@ -350,8 +358,10 @@ void MainWindow::startStream(AbstractStream *stream, QString dbc_file) { delete messages_widget; delete video_splitter; - can = stream; - can->setParent(this); // take ownership + can = stream; // take ownership + stream_connections_.push_back(can->error.connect([this](const std::string &msg) { + QMessageBox::warning(this, tr("Error"), QString::fromStdString(msg)); + })); can->start(); loadFile(dbc_file); @@ -373,18 +383,19 @@ void MainWindow::startStream(AbstractStream *stream, QString dbc_file) { newFile(); } - QObject::connect(can, &AbstractStream::eventsMerged, this, &MainWindow::eventsMerged); + stream_connections_.push_back(can->eventsMerged.connect([this](const MessageEventsMap &) { eventsMerged(); })); if (has_stream) { - auto wait_dlg = new QProgressDialog( + wait_dlg_ = new QProgressDialog( can->liveStreaming() ? tr("Waiting for the live stream to start...") : tr("Loading segment data..."), tr("&Abort"), 0, 100, this); - wait_dlg->setWindowModality(Qt::WindowModal); - wait_dlg->setFixedSize(400, wait_dlg->sizeHint().height()); - QObject::connect(wait_dlg, &QProgressDialog::canceled, this, &MainWindow::close); - QObject::connect(can, &AbstractStream::eventsMerged, wait_dlg, &QProgressDialog::deleteLater); - QObject::connect(this, &MainWindow::updateProgressBar, wait_dlg, [=](uint64_t cur, uint64_t total, bool success) { - wait_dlg->setValue((int)((cur / (double)total) * 100)); + wait_dlg_->setWindowModality(Qt::WindowModal); + wait_dlg_->setFixedSize(400, wait_dlg_->sizeHint().height()); + QObject::connect(wait_dlg_, &QProgressDialog::canceled, this, &MainWindow::close); + wait_dlg_connection_ = can->eventsMerged.connect([this](const MessageEventsMap &) { + wait_dlg_->deleteLater(); + wait_dlg_ = nullptr; + wait_dlg_connection_.disconnect(); }); } } @@ -544,6 +555,7 @@ void MainWindow::remindSaveChanges() { } void MainWindow::updateDownloadProgress(uint64_t cur, uint64_t total, bool success) { + if (wait_dlg_) wait_dlg_->setValue((int)((cur / (double)total) * 100)); if (success && cur < total) { progress_bar->setValue((cur / (double)total) * 100); progress_bar->setFormat(tr("Downloading %p% (%1)").arg(formattedDataSize(total).c_str())); @@ -610,7 +622,7 @@ void MainWindow::closeEvent(QCloseEvent *event) { } void MainWindow::setOption() { - SettingsDlg dlg(this); + SettingsDialog dlg(this); dlg.exec(); } diff --git a/openpilot/tools/cabana/mainwin.h b/openpilot/tools/cabana/mainwin.h index 279ea3b969..a57232ceb2 100644 --- a/openpilot/tools/cabana/mainwin.h +++ b/openpilot/tools/cabana/mainwin.h @@ -19,11 +19,14 @@ #include "tools/cabana/videowidget.h" #include "tools/cabana/tools/findsimilarbits.h" +class QProgressDialog; + class MainWindow : public QMainWindow { Q_OBJECT public: MainWindow(AbstractStream *stream, const QString &dbc_file); + ~MainWindow(); void toggleChartsDocking(); void showStatusMessage(const QString &msg, int timeout = 0) { statusBar()->showMessage(msg, timeout); } void loadFile(const QString &fn, SourceSet s = SOURCE_ALL); @@ -42,10 +45,6 @@ public slots: void saveAs(); void saveToClipboard(); -signals: - void showMessage(const QString &msg, int timeout); - void updateProgressBar(uint64_t cur, uint64_t total, bool success); - protected: void startStream(AbstractStream *stream, QString dbc_file); bool eventFilter(QObject *obj, QEvent *event) override; @@ -104,6 +103,10 @@ protected: QAction *redo_act = nullptr; QString car_fingerprint; std::vector default_state; + Connections connections_; + Connections stream_connections_; + Connection wait_dlg_connection_; + QProgressDialog *wait_dlg_ = nullptr; }; class HelpOverlay : public QWidget { diff --git a/openpilot/tools/cabana/messageswidget.cc b/openpilot/tools/cabana/messageswidget.cc index b07e3ea0bf..2325bb3ae4 100644 --- a/openpilot/tools/cabana/messageswidget.cc +++ b/openpilot/tools/cabana/messageswidget.cc @@ -1,5 +1,4 @@ #include "tools/cabana/messageswidget.h" -#include "tools/cabana/dbc/dbcqt.h" #include #include @@ -43,9 +42,6 @@ MessagesWidget::MessagesWidget(QWidget *parent) : menu(new QMenu(this)), QWidget QObject::connect(menu, &QMenu::aboutToShow, this, &MessagesWidget::menuAboutToShow); QObject::connect(header, &MessageViewHeader::customContextMenuRequested, this, &MessagesWidget::headerContextMenuEvent); QObject::connect(view->horizontalScrollBar(), &QScrollBar::valueChanged, header, &MessageViewHeader::updateHeaderPositions); - QObject::connect(can, &AbstractStream::msgsReceived, model, &MessageListModel::msgsReceived); - QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, model, &MessageListModel::dbcModified); - QObject::connect(undoNotifier(), &QtUndoNotifier::indexChanged, model, &MessageListModel::dbcModified); QObject::connect(model, &MessageListModel::modelReset, [this]() { if (current_msg_id) { selectMessage(*current_msg_id); @@ -96,7 +92,7 @@ QWidget *MessagesWidget::createToolBar() { QObject::connect(suppress_add, &QPushButton::clicked, this, &MessagesWidget::suppressHighlighted); QObject::connect(suppress_clear, &QPushButton::clicked, this, &MessagesWidget::suppressHighlighted); - QObject::connect(suppress_defined_signals, &QCheckBox::stateChanged, can, &AbstractStream::suppressDefinedSignals); + QObject::connect(suppress_defined_signals, &QCheckBox::stateChanged, this, [](int state) { can->suppressDefinedSignals(state); }); suppressHighlighted(); return toolbar; @@ -161,6 +157,12 @@ void MessagesWidget::setMultiLineBytes(bool multi) { // MessageListModel +MessageListModel::MessageListModel(QObject *parent) : QAbstractTableModel(parent) { + connections_.push_back(can->msgsReceived.connect([this](const std::set *msgs, bool has_new_ids) { msgsReceived(msgs, has_new_ids); })); + connections_.push_back(dbc()->fileChanged.connect([this]() { dbcModified(); })); + connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { dbcModified(); })); +} + QVariant MessageListModel::headerData(int section, Qt::Orientation orientation, int role) const { if (orientation == Qt::Horizontal && role == Qt::DisplayRole) { switch (section) { @@ -193,7 +195,7 @@ QVariant MessageListModel::data(const QModelIndex &index, int role) const { switch (index.column()) { case Column::NAME: return item.name; case Column::SOURCE: return item.id.source != INVALID_SOURCE ? QString::number(item.id.source) : NA; - case Column::ADDRESS: return toHexString(item.id.address); + case Column::ADDRESS: return QString::fromStdString(utils::toHexString(item.id.address)); case Column::NODE: return item.node; case Column::FREQ: return item.id.source != INVALID_SOURCE ? getFreq(can->lastMessage(item.id).freq) : NA; case Column::COUNT: return item.id.source != INVALID_SOURCE ? QString::number(can->lastMessage(item.id).count) : NA; @@ -286,7 +288,7 @@ bool MessageListModel::match(const MessageListModel::Item &item) { match = parseRange(txt, item.id.source); break; case Column::ADDRESS: - match = toHexString(item.id.address).contains(txt, Qt::CaseInsensitive); + match = QString::fromStdString(utils::toHexString(item.id.address)).contains(txt, Qt::CaseInsensitive); match = match || parseRange(txt, item.id.address, 16); break; case Column::NODE: @@ -299,7 +301,7 @@ bool MessageListModel::match(const MessageListModel::Item &item) { match = parseRange(txt, data.count); break; case Column::DATA: - match = utils::toHex(data.dat).contains(txt, Qt::CaseInsensitive); + match = QString::fromStdString(utils::toHex(data.dat)).contains(txt, Qt::CaseInsensitive); break; } } diff --git a/openpilot/tools/cabana/messageswidget.h b/openpilot/tools/cabana/messageswidget.h index 0a9cd256d8..7c9fc0253f 100644 --- a/openpilot/tools/cabana/messageswidget.h +++ b/openpilot/tools/cabana/messageswidget.h @@ -11,11 +11,13 @@ #include #include #include +#include #include #include #include "tools/cabana/dbc/dbcmanager.h" #include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/utils/qtutil.h" class MessageListModel : public QAbstractTableModel { Q_OBJECT @@ -31,7 +33,7 @@ public: DATA, }; - MessageListModel(QObject *parent) : QAbstractTableModel(parent) {} + MessageListModel(QObject *parent); QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; int columnCount(const QModelIndex &parent = QModelIndex()) const override { return Column::DATA + 1; } QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const; @@ -63,6 +65,7 @@ private: int sort_column = 0; Qt::SortOrder sort_order = Qt::AscendingOrder; int sort_threshold_ = 0; + Connections connections_; }; class MessageView : public QTreeView { diff --git a/openpilot/tools/cabana/routes.cc b/openpilot/tools/cabana/routes.cc new file mode 100644 index 0000000000..50a1eacf8d --- /dev/null +++ b/openpilot/tools/cabana/routes.cc @@ -0,0 +1,115 @@ +#include "tools/cabana/routes.h" + +#include +#include +#include +#include + +#include "json11/json11.hpp" +#include "tools/replay/py_downloader.h" + +namespace routes { + +std::pair checkApiResponse(const std::string &result) { + if (result.empty()) return {false, 500}; + std::string err; + auto doc = json11::Json::parse(result, err); + if (!err.empty()) return {false, 500}; + if (doc.is_object() && doc["error"].is_string()) { + return {false, doc["error"].string_value() == "unauthorized" ? 401 : 500}; + } + return {true, 0}; +} + +int64_t nowUnixMs() { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); +} + +int64_t parseIsoToUnixMs(const std::string &s) { + std::string bytes = s; + if (!bytes.empty() && (bytes.back() == 'Z' || bytes.back() == 'z')) bytes.pop_back(); + int millis = 0; + auto dot = bytes.find('.'); + if (dot != std::string::npos) { + std::string frac = bytes.substr(dot + 1); + bytes = bytes.substr(0, dot); + while (frac.size() < 3) frac.push_back('0'); + millis = std::atoi(frac.substr(0, 3).c_str()); + } + std::tm tm{}; + const char *ret = strptime(bytes.c_str(), "%Y-%m-%dT%H:%M:%S", &tm); + if (!ret) ret = strptime(bytes.c_str(), "%Y-%m-%d %H:%M:%S", &tm); + if (!ret) return 0; + time_t secs = timegm(&tm); + if (secs == static_cast(-1)) return 0; + return static_cast(secs) * 1000 + millis; +} + +std::string formatUnixMs(int64_t ms) { + time_t secs = static_cast(ms / 1000); + std::tm tm{}; + localtime_r(&secs, &tm); + char buf[64]; + std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm); + return buf; +} + +std::vector parseDevices(const std::string &json) { + std::vector devices; + std::string err; + auto doc = json11::Json::parse(json, err); + if (err.empty() && doc.is_array()) { + for (const auto &device : doc.array_items()) { + devices.push_back({device["dongle_id"].string_value()}); + } + } + return devices; +} + +std::vector parseRoutes(const std::string &json, bool preserved) { + std::vector items; + std::string err; + auto doc = json11::Json::parse(json, err); + if (err.empty() && doc.is_array()) { + for (const auto &route : doc.array_items()) { + RouteInfo info; + info.name = route["fullname"].string_value(); + if (preserved) { + info.start_ms = parseIsoToUnixMs(route["start_time"].string_value()); + info.end_ms = parseIsoToUnixMs(route["end_time"].string_value()); + } else { + info.start_ms = static_cast(route["start_time_utc_millis"].number_value()); + info.end_ms = static_cast(route["end_time_utc_millis"].number_value()); + } + items.push_back(std::move(info)); + } + } + return items; +} + +void fetchDevices(DevicesCallback callback) { + std::thread([callback = std::move(callback)]() { + std::string result = PyDownloader::getDevices(); + auto [success, error_code] = checkApiResponse(result); + callback(success ? parseDevices(result) : std::vector{}, success, error_code); + }).detach(); +} + +void fetchRoutes(const std::string &dongle_id, int period_days, RoutesCallback callback) { + const bool preserved = period_days == -1; + int64_t start_ms = 0, end_ms = 0; + if (!preserved) { + end_ms = nowUnixMs(); + start_ms = end_ms - static_cast(period_days) * 24LL * 60LL * 60LL * 1000LL; + } + + std::thread([dongle_id, start_ms, end_ms, preserved, callback = std::move(callback)]() { + std::string result = PyDownloader::getDeviceRoutes(dongle_id, start_ms, end_ms, preserved); + auto [success, error_code] = checkApiResponse(result); + callback(success ? parseRoutes(result, preserved) : std::vector{}, success, error_code); + }).detach(); +} + +} // namespace routes diff --git a/openpilot/tools/cabana/routes.h b/openpilot/tools/cabana/routes.h new file mode 100644 index 0000000000..ef6fc8dee0 --- /dev/null +++ b/openpilot/tools/cabana/routes.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace routes { + +struct DeviceInfo { + std::string dongle_id; +}; + +struct RouteInfo { + std::string name; + int64_t start_ms = 0; + int64_t end_ms = 0; +}; + +using DevicesCallback = std::function devices, bool success, int error_code)>; +using RoutesCallback = std::function routes, bool success, int error_code)>; + +// Parse a PyDownloader JSON response into (success, error_code). +std::pair checkApiResponse(const std::string &result); + +int64_t nowUnixMs(); +// Parse ISO-8601 (with optional fractional seconds / Z) to unix ms. Returns 0 on failure. +int64_t parseIsoToUnixMs(const std::string &s); +// Local time, "%Y-%m-%d %H:%M:%S". +std::string formatUnixMs(int64_t ms); + +std::vector parseDevices(const std::string &json); +// preserved routes report ISO-8601 timestamps instead of unix millis +std::vector parseRoutes(const std::string &json, bool preserved); + +// Both fetch on a detached thread and invoke the callback from that thread. +void fetchDevices(DevicesCallback callback); +// period_days of -1 requests preserved routes +void fetchRoutes(const std::string &dongle_id, int period_days, RoutesCallback callback); + +} // namespace routes diff --git a/openpilot/tools/cabana/routesdialog.cc b/openpilot/tools/cabana/routesdialog.cc new file mode 100644 index 0000000000..33b773edb9 --- /dev/null +++ b/openpilot/tools/cabana/routesdialog.cc @@ -0,0 +1,110 @@ +#include "tools/cabana/routesdialog.h" + +#include +#include + +#include +#include +#include +#include +#include + +#include "tools/cabana/utils/util.h" + +// The RouteListWidget class extends QListWidget to display a custom message when empty +class RouteListWidget : public QListWidget { +public: + RouteListWidget(QWidget *parent = nullptr) : QListWidget(parent) {} + void setEmptyText(const QString &text) { + empty_text_ = text; + viewport()->update(); + } + void paintEvent(QPaintEvent *event) override { + QListWidget::paintEvent(event); + if (count() == 0) { + QPainter painter(viewport()); + painter.drawText(viewport()->rect(), Qt::AlignCenter, empty_text_); + } + } + QString empty_text_ = tr("No items"); +}; + +RoutesDialog::RoutesDialog(QWidget *parent) : QDialog(parent) { + setWindowTitle(tr("Remote routes")); + + QFormLayout *layout = new QFormLayout(this); + layout->addRow(tr("Device"), device_list_ = new QComboBox(this)); + layout->addRow(period_selector_ = new QComboBox(this)); + layout->addRow(route_list_ = new RouteListWidget(this)); + auto button_box = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + layout->addRow(button_box); + + device_list_->addItem(tr("Loading...")); + period_selector_->addItem(tr("Last week"), 7); + period_selector_->addItem(tr("Last 2 weeks"), 14); + period_selector_->addItem(tr("Last month"), 30); + period_selector_->addItem(tr("Last 6 months"), 180); + period_selector_->addItem(tr("Preserved"), -1); + + connect(device_list_, QOverload::of(&QComboBox::currentIndexChanged), this, &RoutesDialog::fetchRoutes); + connect(period_selector_, QOverload::of(&QComboBox::currentIndexChanged), this, &RoutesDialog::fetchRoutes); + connect(route_list_, &QListWidget::itemDoubleClicked, this, &QDialog::accept); + connect(button_box, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(button_box, &QDialogButtonBox::rejected, this, &QDialog::reject); + + routes::fetchDevices([this, alive = std::weak_ptr(alive_)](std::vector devices, bool success, int error_code) { + utils::runOnMainThread([this, alive, devices = std::move(devices), success, error_code]() { + if (!alive.expired()) setDeviceList(devices, success, error_code); + }); + }); +} + +void RoutesDialog::setDeviceList(const std::vector &devices, bool success, int error_code) { + if (success) { + device_list_->clear(); + for (const auto &device : devices) { + QString dongle_id = QString::fromStdString(device.dongle_id); + device_list_->addItem(dongle_id, dongle_id); + } + } else { + QMessageBox::warning(this, tr("Error"), error_code == 401 ? tr("Unauthorized. Authenticate with openpilot/tools/lib/auth.py") : tr("Network error")); + reject(); + } +} + +void RoutesDialog::fetchRoutes() { + if (device_list_->currentIndex() == -1 || device_list_->currentData().isNull()) + return; + + route_list_->clear(); + route_list_->setEmptyText(tr("Loading...")); + + int request_id = ++fetch_id_; + auto on_routes = [this, alive = std::weak_ptr(alive_), request_id](std::vector list, bool success, int) { + utils::runOnMainThread([this, alive, list = std::move(list), success, request_id]() { + if (!alive.expired() && fetch_id_ == request_id) setRouteList(list, success); + }); + }; + routes::fetchRoutes(device_list_->currentText().toStdString(), period_selector_->currentData().toInt(), std::move(on_routes)); +} + +void RoutesDialog::setRouteList(const std::vector &list, bool success) { + if (success) { + for (const auto &route : list) { + const int mins = static_cast((route.end_ms - route.start_ms) / 60000); + auto item = new QListWidgetItem(QString::fromStdString(routes::formatUnixMs(route.start_ms) + " " + std::to_string(mins) + "min")); + item->setData(Qt::UserRole, QString::fromStdString(route.name)); + route_list_->addItem(item); + } + if (route_list_->count() > 0) route_list_->setCurrentRow(0); + } else { + QMessageBox::warning(this, tr("Error"), tr("Failed to fetch routes. Check your network connection.")); + reject(); + } + route_list_->setEmptyText(tr("No items")); +} + +std::string RoutesDialog::route() { + auto current_item = route_list_->currentItem(); + return current_item ? current_item->data(Qt::UserRole).toString().toStdString() : ""; +} diff --git a/openpilot/tools/cabana/streams/routes.h b/openpilot/tools/cabana/routesdialog.h similarity index 67% rename from openpilot/tools/cabana/streams/routes.h rename to openpilot/tools/cabana/routesdialog.h index 6ed145603f..4983f1b558 100644 --- a/openpilot/tools/cabana/streams/routes.h +++ b/openpilot/tools/cabana/routesdialog.h @@ -2,21 +2,24 @@ #include #include +#include #include #include +#include "tools/cabana/routes.h" + class RouteListWidget; class RoutesDialog : public QDialog { Q_OBJECT public: RoutesDialog(QWidget *parent); - QString route(); + std::string route(); protected: - void parseDeviceList(const QString &json, bool success, int error_code); - void parseRouteList(const QString &json, bool success, int error_code); + void setDeviceList(const std::vector &devices, bool success, int error_code); + void setRouteList(const std::vector &list, bool success); void fetchRoutes(); QComboBox *device_list_; diff --git a/openpilot/tools/cabana/settings.cc b/openpilot/tools/cabana/settings.cc index 0c8136b84c..bacbbd5330 100644 --- a/openpilot/tools/cabana/settings.cc +++ b/openpilot/tools/cabana/settings.cc @@ -24,19 +24,11 @@ #include #endif -#include -#include -#include -#include -#include #include #include "json11/json11.hpp" #include "tools/cabana/utils/util.h" -const int MIN_CACHE_MINIUTES = 30; -const int MAX_CACHE_MINIUTES = 120; - Settings settings; namespace { @@ -535,84 +527,3 @@ void Settings::save() { settingsOp(stored_settings.values, [](auto &s, const char *key, const auto &value) { writeSetting(s, key, value); }); saveSettings(stored_settings.values); } - -// SettingsDlg - -SettingsDlg::SettingsDlg(QWidget *parent) : QDialog(parent) { - setWindowTitle(tr("Settings")); - QVBoxLayout *main_layout = new QVBoxLayout(this); - QGroupBox *groupbox = new QGroupBox("General"); - QFormLayout *form_layout = new QFormLayout(groupbox); - - form_layout->addRow(tr("Color Theme"), theme = new QComboBox(this)); - theme->setToolTip(tr("You may need to restart cabana after changes theme")); - theme->addItems({tr("Automatic"), tr("Light"), tr("Dark")}); - theme->setCurrentIndex(settings.theme); - - form_layout->addRow("FPS", fps = new QSpinBox(this)); - fps->setRange(10, 100); - fps->setSingleStep(10); - fps->setValue(settings.fps); - - form_layout->addRow(tr("Max Cached Minutes"), cached_minutes = new QSpinBox(this)); - cached_minutes->setRange(MIN_CACHE_MINIUTES, MAX_CACHE_MINIUTES); - cached_minutes->setSingleStep(1); - cached_minutes->setValue(settings.max_cached_minutes); - main_layout->addWidget(groupbox); - - groupbox = new QGroupBox("New Signal Settings"); - form_layout = new QFormLayout(groupbox); - form_layout->addRow(tr("Drag Direction"), drag_direction = new QComboBox(this)); - drag_direction->addItems({tr("MSB First"), tr("LSB First"), tr("Always Little Endian"), tr("Always Big Endian")}); - drag_direction->setCurrentIndex(settings.drag_direction); - main_layout->addWidget(groupbox); - - groupbox = new QGroupBox("Chart"); - form_layout = new QFormLayout(groupbox); - form_layout->addRow(tr("Chart Height"), chart_height = new QSpinBox(this)); - chart_height->setRange(100, 500); - chart_height->setSingleStep(10); - chart_height->setValue(settings.chart_height); - main_layout->addWidget(groupbox); - - log_livestream = new QGroupBox(tr("Enable live stream logging"), this); - log_livestream->setCheckable(true); - log_livestream->setChecked(settings.log_livestream); - QHBoxLayout *path_layout = new QHBoxLayout(log_livestream); - path_layout->addWidget(log_path = new QLineEdit(QString::fromStdString(settings.log_path), this)); - log_path->setReadOnly(true); - auto browse_btn = new QPushButton(tr("B&rowse...")); - path_layout->addWidget(browse_btn); - main_layout->addWidget(log_livestream); - - auto buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); - main_layout->addWidget(buttonBox); - setFixedSize(400, sizeHint().height()); - - QObject::connect(browse_btn, &QPushButton::clicked, [this]() { - QString fn = QFileDialog::getExistingDirectory( - this, tr("Log File Location"), - QString::fromStdString(utils::homePath()), - QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks); - if (!fn.isEmpty()) { - log_path->setText(fn); - } - }); - QObject::connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); - QObject::connect(buttonBox, &QDialogButtonBox::accepted, this, &SettingsDlg::save); -} - -void SettingsDlg::save() { - if (std::exchange(settings.theme, theme->currentIndex()) != settings.theme) { - // set theme before emit changed - utils::setTheme(settings.theme); - } - settings.fps = fps->value(); - settings.max_cached_minutes = cached_minutes->value(); - settings.chart_height = chart_height->value(); - settings.log_livestream = log_livestream->isChecked(); - settings.log_path = log_path->text().toStdString(); - settings.drag_direction = (Settings::DragDirection)drag_direction->currentIndex(); - emit settings.changed(); - QDialog::accept(); -} diff --git a/openpilot/tools/cabana/settings.h b/openpilot/tools/cabana/settings.h index 7357ecf4fc..52353dc667 100644 --- a/openpilot/tools/cabana/settings.h +++ b/openpilot/tools/cabana/settings.h @@ -3,17 +3,10 @@ #include #include -#include -#include -#include -#include -#include - +#include "tools/cabana/core/observable.h" #include "tools/cabana/core/settings.h" -class Settings : public QObject, public CabanaSettingsState { - Q_OBJECT - +class Settings : public CabanaSettingsState { public: Settings(); void save(); @@ -24,22 +17,7 @@ public: std::vector window_state; std::vector message_header_state; -signals: - void changed(); -}; - -class SettingsDlg : public QDialog { -public: - SettingsDlg(QWidget *parent); - void save(); - QSpinBox *fps; - QSpinBox *cached_minutes; - QSpinBox *chart_height; - QComboBox *chart_series_type; - QComboBox *theme; - QGroupBox *log_livestream; - QLineEdit *log_path; - QComboBox *drag_direction; + Observable<> changed; }; extern Settings settings; diff --git a/openpilot/tools/cabana/settingsdialog.cc b/openpilot/tools/cabana/settingsdialog.cc new file mode 100644 index 0000000000..5dd8b7617c --- /dev/null +++ b/openpilot/tools/cabana/settingsdialog.cc @@ -0,0 +1,94 @@ +#include "tools/cabana/settingsdialog.h" + +#include + +#include +#include +#include +#include +#include + +#include "tools/cabana/settings.h" +#include "tools/cabana/utils/qtutil.h" + +const int MIN_CACHE_MINIUTES = 30; +const int MAX_CACHE_MINIUTES = 120; + +SettingsDialog::SettingsDialog(QWidget *parent) : QDialog(parent) { + setWindowTitle(tr("Settings")); + QVBoxLayout *main_layout = new QVBoxLayout(this); + QGroupBox *groupbox = new QGroupBox("General"); + QFormLayout *form_layout = new QFormLayout(groupbox); + + form_layout->addRow(tr("Color Theme"), theme = new QComboBox(this)); + theme->setToolTip(tr("You may need to restart cabana after changes theme")); + theme->addItems({tr("Automatic"), tr("Light"), tr("Dark")}); + theme->setCurrentIndex(settings.theme); + + form_layout->addRow("FPS", fps = new QSpinBox(this)); + fps->setRange(10, 100); + fps->setSingleStep(10); + fps->setValue(settings.fps); + + form_layout->addRow(tr("Max Cached Minutes"), cached_minutes = new QSpinBox(this)); + cached_minutes->setRange(MIN_CACHE_MINIUTES, MAX_CACHE_MINIUTES); + cached_minutes->setSingleStep(1); + cached_minutes->setValue(settings.max_cached_minutes); + main_layout->addWidget(groupbox); + + groupbox = new QGroupBox("New Signal Settings"); + form_layout = new QFormLayout(groupbox); + form_layout->addRow(tr("Drag Direction"), drag_direction = new QComboBox(this)); + drag_direction->addItems({tr("MSB First"), tr("LSB First"), tr("Always Little Endian"), tr("Always Big Endian")}); + drag_direction->setCurrentIndex(settings.drag_direction); + main_layout->addWidget(groupbox); + + groupbox = new QGroupBox("Chart"); + form_layout = new QFormLayout(groupbox); + form_layout->addRow(tr("Chart Height"), chart_height = new QSpinBox(this)); + chart_height->setRange(100, 500); + chart_height->setSingleStep(10); + chart_height->setValue(settings.chart_height); + main_layout->addWidget(groupbox); + + log_livestream = new QGroupBox(tr("Enable live stream logging"), this); + log_livestream->setCheckable(true); + log_livestream->setChecked(settings.log_livestream); + QHBoxLayout *path_layout = new QHBoxLayout(log_livestream); + path_layout->addWidget(log_path = new QLineEdit(QString::fromStdString(settings.log_path), this)); + log_path->setReadOnly(true); + auto browse_btn = new QPushButton(tr("B&rowse...")); + path_layout->addWidget(browse_btn); + main_layout->addWidget(log_livestream); + + auto buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + main_layout->addWidget(buttonBox); + setFixedSize(400, sizeHint().height()); + + QObject::connect(browse_btn, &QPushButton::clicked, [this]() { + QString fn = QFileDialog::getExistingDirectory( + this, tr("Log File Location"), + QString::fromStdString(utils::homePath()), + QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks); + if (!fn.isEmpty()) { + log_path->setText(fn); + } + }); + QObject::connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + QObject::connect(buttonBox, &QDialogButtonBox::accepted, this, &SettingsDialog::save); +} + +void SettingsDialog::save() { + if (std::exchange(settings.theme, theme->currentIndex()) != settings.theme) { + // set theme before emit changed + utils::setTheme(settings.theme); + } + settings.fps = fps->value(); + settings.max_cached_minutes = cached_minutes->value(); + settings.chart_height = chart_height->value(); + settings.log_livestream = log_livestream->isChecked(); + settings.log_path = log_path->text().toStdString(); + settings.drag_direction = (Settings::DragDirection)drag_direction->currentIndex(); + settings.changed(); + QDialog::accept(); +} diff --git a/openpilot/tools/cabana/settingsdialog.h b/openpilot/tools/cabana/settingsdialog.h new file mode 100644 index 0000000000..c5c723c1cb --- /dev/null +++ b/openpilot/tools/cabana/settingsdialog.h @@ -0,0 +1,21 @@ +#pragma once + +#include +#include +#include +#include +#include + +class SettingsDialog : public QDialog { +public: + SettingsDialog(QWidget *parent); + void save(); + QSpinBox *fps; + QSpinBox *cached_minutes; + QSpinBox *chart_height; + QComboBox *chart_series_type; + QComboBox *theme; + QGroupBox *log_livestream; + QLineEdit *log_path; + QComboBox *drag_direction; +}; diff --git a/openpilot/tools/cabana/signalview.cc b/openpilot/tools/cabana/signalview.cc index ebb5374140..de80ce2a3f 100644 --- a/openpilot/tools/cabana/signalview.cc +++ b/openpilot/tools/cabana/signalview.cc @@ -1,5 +1,4 @@ #include "tools/cabana/signalview.h" -#include "tools/cabana/dbc/dbcqt.h" #include #include @@ -12,10 +11,11 @@ #include #include #include +#include #include #include "tools/cabana/commands.h" -#include "tools/cabana/utils/util.h" +#include "tools/cabana/utils/qtutil.h" // SignalModel @@ -26,12 +26,12 @@ static QString signalTypeToString(cabana::Signal::Type type) { } SignalModel::SignalModel(QObject *parent) : root(new Item), QAbstractItemModel(parent) { - QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &SignalModel::refresh); - QObject::connect(dbcNotifier(), &QtDBCNotifier::msgUpdated, this, &SignalModel::handleMsgChanged); - QObject::connect(dbcNotifier(), &QtDBCNotifier::msgRemoved, this, &SignalModel::handleMsgChanged); - QObject::connect(dbcNotifier(), &QtDBCNotifier::signalAdded, this, &SignalModel::handleSignalAdded); - QObject::connect(dbcNotifier(), &QtDBCNotifier::signalUpdated, this, &SignalModel::handleSignalUpdated); - QObject::connect(dbcNotifier(), &QtDBCNotifier::signalRemoved, this, &SignalModel::handleSignalRemoved); + connections_.push_back(dbc()->fileChanged.connect([this]() { refresh(); })); + connections_.push_back(dbc()->msgUpdated.connect([this](MessageId id) { handleMsgChanged(id); })); + connections_.push_back(dbc()->msgRemoved.connect([this](MessageId id) { handleMsgChanged(id); })); + connections_.push_back(dbc()->signalAdded.connect([this](MessageId id, const cabana::Signal *sig) { handleSignalAdded(id, sig); })); + connections_.push_back(dbc()->signalUpdated.connect([this](const cabana::Signal *sig) { handleSignalUpdated(sig); })); + connections_.push_back(dbc()->signalRemoved.connect([this](const cabana::Signal *sig) { handleSignalRemoved(sig); })); } void SignalModel::insertItem(SignalModel::Item *root_item, int pos, const cabana::Signal *sig) { @@ -154,7 +154,7 @@ QVariant SignalModel::data(const QModelIndex &index, int role) const { if (item->type == Item::Endian) return item->sig->is_little_endian ? Qt::Checked : Qt::Unchecked; if (item->type == Item::Signed) return item->sig->is_signed ? Qt::Checked : Qt::Unchecked; } else if (role == Qt::ToolTipRole && item->type == Item::Sig) { - return (index.column() == 0) ? signalToolTip(item->sig) : QString(); + return (index.column() == 0) ? QString::fromStdString(utils::signalToolTip(item->sig)) : QString(); } } return {}; @@ -472,11 +472,11 @@ SignalView::SignalView(ChartsWidget *charts, QWidget *parent) : charts(charts), QObject::connect(tree, &QTreeView::entered, [this](const QModelIndex &index) { emit highlight(model->getItem(index)->sig); }); QObject::connect(model, &QAbstractItemModel::modelReset, this, &SignalView::rowsChanged); QObject::connect(model, &QAbstractItemModel::rowsRemoved, this, &SignalView::rowsChanged); - QObject::connect(dbcNotifier(), &QtDBCNotifier::signalAdded, this, &SignalView::handleSignalAdded); - QObject::connect(dbcNotifier(), &QtDBCNotifier::signalUpdated, this, &SignalView::handleSignalUpdated); + connections_.push_back(dbc()->signalAdded.connect([this](MessageId id, const cabana::Signal *sig) { handleSignalAdded(id, sig); })); + connections_.push_back(dbc()->signalUpdated.connect([this](const cabana::Signal *sig) { handleSignalUpdated(sig); })); QObject::connect(tree->verticalScrollBar(), &QScrollBar::valueChanged, [this]() { updateState(); }); QObject::connect(tree->verticalScrollBar(), &QScrollBar::rangeChanged, [this]() { updateState(); }); - QObject::connect(can, &AbstractStream::msgsReceived, this, &SignalView::updateState); + connections_.push_back(can->msgsReceived.connect([this](const std::set *msgs, bool) { updateState(msgs); })); QObject::connect(tree->header(), &QHeaderView::sectionResized, [this](int logicalIndex, int oldSize, int newSize) { if (logicalIndex == 1) { value_column_width = newSize; @@ -571,7 +571,7 @@ void SignalView::signalHovered(const cabana::Signal *sig) { void SignalView::updateToolBar() { signal_count_lb->setText(tr("Signals: %1").arg(model->rowCount())); - sparkline_label->setText(utils::formatSeconds(settings.sparkline_range)); + sparkline_label->setText(QString::fromStdString(utils::formatSeconds(settings.sparkline_range))); } void SignalView::setSparklineRange(int value) { diff --git a/openpilot/tools/cabana/signalview.h b/openpilot/tools/cabana/signalview.h index bccfbab0cc..e2a99ebbd1 100644 --- a/openpilot/tools/cabana/signalview.h +++ b/openpilot/tools/cabana/signalview.h @@ -62,6 +62,7 @@ private: MessageId msg_id; QString filter_str; std::unique_ptr root; + Connections connections_; friend class SignalView; friend class SignalItemDelegate; }; @@ -150,4 +151,5 @@ private: ChartsWidget *charts; QLabel *signal_count_lb; SignalItemDelegate *delegate; + Connections connections_; }; diff --git a/openpilot/tools/cabana/streams/abstractstream.cc b/openpilot/tools/cabana/streams/abstractstream.cc index c58a98084f..7be579269d 100644 --- a/openpilot/tools/cabana/streams/abstractstream.cc +++ b/openpilot/tools/cabana/streams/abstractstream.cc @@ -1,10 +1,9 @@ #include "tools/cabana/streams/abstractstream.h" -#include "tools/cabana/dbc/dbcqt.h" +#include #include #include -#include #include "common/timing.h" #include "tools/cabana/settings.h" @@ -12,15 +11,41 @@ static const int EVENT_NEXT_BUFFER_SIZE = 6 * 1024 * 1024; // 6MB AbstractStream *can = nullptr; -AbstractStream::AbstractStream(QObject *parent) : QObject(parent) { - assert(parent != nullptr); +AbstractStream::AbstractStream() { event_buffer_ = std::make_unique(EVENT_NEXT_BUFFER_SIZE); - QObject::connect(this, &AbstractStream::privateUpdateLastMsgsSignal, this, &AbstractStream::updateLastMessages, Qt::QueuedConnection); - QObject::connect(this, &AbstractStream::seekedTo, this, &AbstractStream::updateLastMsgsTo); - QObject::connect(this, &AbstractStream::seeking, this, [this](double sec) { current_sec_ = sec; }); - QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &AbstractStream::updateMasks); - QObject::connect(dbcNotifier(), &QtDBCNotifier::maskUpdated, this, &AbstractStream::updateMasks); + // connected first so the stream state is updated before any widget handlers run + connections_.push_back(seekedTo.connect([this](double sec) { updateLastMsgsTo(sec); })); + connections_.push_back(seeking.connect([this](double sec) { current_sec_ = sec; })); + connections_.push_back(dbc()->fileChanged.connect([this]() { updateMasks(); })); + connections_.push_back(dbc()->maskUpdated.connect([this]() { updateMasks(); })); +} + +void AbstractStream::postToMainThread(std::function fn) { + utils::runOnMainThread([alive = std::weak_ptr(alive_), fn = std::move(fn)]() { + if (!alive.expired()) fn(); + }); +} + +void AbstractStream::postToMainThreadAndWait(std::function fn) { + assert(!utils::isMainThread()); + std::unique_lock lock(mutex_); + if (exiting_) return; + auto done = std::make_shared(false); + postToMainThread([this, alive = std::weak_ptr(alive_), done, fn = std::move(fn)]() { + fn(); + if (alive.expired()) return; // fn deleted the stream, the waiter was released by cancelWaits() + std::lock_guard lk(mutex_); + *done = true; + wait_cv_.notify_all(); + }); + wait_cv_.wait(lock, [&]() { return *done || exiting_; }); +} + +void AbstractStream::cancelWaits() { + std::lock_guard lk(mutex_); + exiting_ = true; + wait_cv_.notify_all(); } void AbstractStream::updateMasks() { @@ -97,9 +122,8 @@ void AbstractStream::updateLastMessages() { if (sources.size() != prev_src_size) { updateMasks(); - emit sourcesUpdated(sources); } - emit msgsReceived(&msgs, prev_msg_size != last_msgs.size()); + msgsReceived(&msgs, prev_msg_size != last_msgs.size()); } void AbstractStream::setTimeRange(const std::optional> &range) { @@ -107,7 +131,7 @@ void AbstractStream::setTimeRange(const std::optional> if (time_range_ && (current_sec_ < time_range_->first || current_sec_ >= time_range_->second)) { seekTo(time_range_->first); } - emit timeRangeChanged(time_range_); + timeRangeChanged(time_range_); } void AbstractStream::updateEvent(const MessageId &id, double sec, const uint8_t *data, uint8_t size) { @@ -175,16 +199,16 @@ void AbstractStream::updateLastMsgsTo(double sec) { std::any_of(messages_.cbegin(), messages_.cend(), [this](const auto &m) { return !last_msgs.count(m.first); }); last_msgs = messages_; - emit msgsReceived(nullptr, id_changed); + msgsReceived(nullptr, id_changed); std::lock_guard lk(mutex_); seek_finished_ = true; - seek_finished_cv_.notify_one(); + wait_cv_.notify_all(); } void AbstractStream::waitForSeekFinshed() { std::unique_lock lock(mutex_); - seek_finished_cv_.wait(lock, [this]() { return seek_finished_; }); + wait_cv_.wait(lock, [this]() { return seek_finished_ || exiting_; }); seek_finished_ = false; } @@ -218,16 +242,16 @@ void AbstractStream::mergeEvents(const std::vector &events) { } auto pos = std::upper_bound(all_events_.cbegin(), all_events_.cend(), events.front()->mono_time, CompareCanEvent()); all_events_.insert(pos, events.cbegin(), events.cend()); - emit eventsMerged(msg_events); + eventsMerged(msg_events); } } std::pair AbstractStream::eventsInRange(const MessageId &id, std::optional> time_range) const { - const auto &events = can->events(id); + const auto &events = this->events(id); if (!time_range) return {events.begin(), events.end()}; - auto first = std::lower_bound(events.begin(), events.end(), can->toMonoTime(time_range->first), CompareCanEvent()); - auto last = std::upper_bound(first, events.end(), can->toMonoTime(time_range->second), CompareCanEvent()); + auto first = std::lower_bound(events.begin(), events.end(), toMonoTime(time_range->first), CompareCanEvent()); + auto last = std::upper_bound(first, events.end(), toMonoTime(time_range->second), CompareCanEvent()); return {first, last}; } diff --git a/openpilot/tools/cabana/streams/abstractstream.h b/openpilot/tools/cabana/streams/abstractstream.h index 82cb899937..b992b086fd 100644 --- a/openpilot/tools/cabana/streams/abstractstream.h +++ b/openpilot/tools/cabana/streams/abstractstream.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -14,16 +15,15 @@ #include "openpilot/cereal/messaging/messaging.h" #include "tools/cabana/core/can_data.h" +#include "tools/cabana/core/observable.h" #include "tools/cabana/dbc/dbcmanager.h" #include "tools/cabana/utils/util.h" #include "tools/replay/util.h" -class AbstractStream : public QObject { - Q_OBJECT - +class AbstractStream { public: - AbstractStream(QObject *parent); - virtual ~AbstractStream() {} + AbstractStream(); + virtual ~AbstractStream() = default; virtual void start() = 0; virtual bool liveStreaming() const { return true; } virtual void seekTo(double ts) {} @@ -56,21 +56,23 @@ public: void clearSuppressed(); void suppressDefinedSignals(bool suppress); -signals: - void paused(); - void resume(); - void seeking(double sec); - void seekedTo(double sec); - void timeRangeChanged(const std::optional> &range); - void eventsMerged(const MessageEventsMap &events_map); - void msgsReceived(const std::set *new_msgs, bool has_new_ids); - void sourcesUpdated(const SourceSet &s); - void privateUpdateLastMsgsSignal(); + // invoked on the main thread + Observable<> paused; + Observable<> resume; + Observable seeking; + Observable seekedTo; + Observable> &> timeRangeChanged; + Observable eventsMerged; + Observable *, bool> msgsReceived; + Observable error; -public: SourceSet sources; protected: + void postToMainThread(std::function fn); // dropped if the stream is destroyed first + void postToMainThreadAndWait(std::function fn); + void cancelWaits(); // call before joining threads, the main thread isn't pumping events during destruction + void requestUpdateLastMessages() { postToMainThread([this]() { updateLastMessages(); }); } void mergeEvents(const std::vector &events); const CanEvent *newEvent(uint64_t mono_time, const cereal::CanData::Reader &c); void updateEvent(const MessageId &id, double sec, const uint8_t *data, uint8_t size); @@ -87,30 +89,21 @@ private: MessageEventsMap events_; std::unordered_map last_msgs; std::unique_ptr event_buffer_; + std::shared_ptr alive_ = std::make_shared(true); + Connections connections_; // Members accessed in multiple threads. (mutex protected) std::mutex mutex_; - std::condition_variable seek_finished_cv_; + std::condition_variable wait_cv_; bool seek_finished_ = false; + bool exiting_ = false; std::set new_msgs_; std::unordered_map messages_; std::unordered_map> masks_; }; -class AbstractOpenStreamWidget : public QWidget { - Q_OBJECT -public: - AbstractOpenStreamWidget(QWidget *parent = nullptr) : QWidget(parent) {} - virtual AbstractStream *open() = 0; - -signals: - void enableOpenButton(bool); -}; - class DummyStream : public AbstractStream { - Q_OBJECT public: - DummyStream(QObject *parent) : AbstractStream(parent) {} std::string routeName() const override { return "No Stream"; } void start() override {} }; diff --git a/openpilot/tools/cabana/streams/devicestream.cc b/openpilot/tools/cabana/streams/devicestream.cc index 3bd51c079d..986ca13558 100644 --- a/openpilot/tools/cabana/streams/devicestream.cc +++ b/openpilot/tools/cabana/streams/devicestream.cc @@ -1,5 +1,6 @@ #include "tools/cabana/streams/devicestream.h" +#include #include #include #include @@ -9,21 +10,16 @@ #include #include #include +#include #include #include #include "openpilot/cereal/services.h" - -#include -#include -#include -#include - #include "tools/cabana/utils/util.h" // DeviceStream -DeviceStream::DeviceStream(QObject *parent, QString address) : zmq_address(address), LiveStream(parent) { +DeviceStream::DeviceStream(std::string address) : zmq_address(std::move(address)) { } DeviceStream::~DeviceStream() { @@ -50,19 +46,16 @@ void DeviceStream::stopBridge() { } void DeviceStream::start() { - if (!zmq_address.isEmpty()) { + if (!zmq_address.empty()) { stopBridge(); - const std::string path = (std::filesystem::path(QCoreApplication::applicationDirPath().toStdString()) / - "../../cereal/messaging/bridge").lexically_normal().string(); - const std::string addr = zmq_address.toStdString(); + const std::string path = (executableDir() / "../../cereal/messaging/bridge").lexically_normal().string(); const char *can_filter = "/\"can/\""; // Self-pipe: write end is CLOEXEC so it closes on successful exec. If exec // fails, the child writes errno and the parent aborts stream start. int err_pipe[2] = {-1, -1}; if (::pipe(err_pipe) != 0) { - QMessageBox::warning(nullptr, tr("Error"), - tr("Failed to start bridge: %1").arg(QString::fromLocal8Bit(strerror(errno)))); + error(std::string("Failed to start bridge: ") + strerror(errno)); return; } @@ -70,7 +63,7 @@ void DeviceStream::start() { if (pid == 0) { ::close(err_pipe[0]); ::fcntl(err_pipe[1], F_SETFD, FD_CLOEXEC); - execl(path.c_str(), path.c_str(), addr.c_str(), can_filter, static_cast(nullptr)); + execl(path.c_str(), path.c_str(), zmq_address.c_str(), can_filter, static_cast(nullptr)); const int err = errno; (void)!::write(err_pipe[1], &err, sizeof(err)); _exit(127); @@ -79,8 +72,7 @@ void DeviceStream::start() { ::close(err_pipe[1]); if (pid < 0) { ::close(err_pipe[0]); - QMessageBox::warning(nullptr, tr("Error"), - tr("Failed to start bridge: %1").arg(QString::fromLocal8Bit(strerror(errno)))); + error(std::string("Failed to start bridge: ") + strerror(errno)); return; } @@ -91,8 +83,7 @@ void DeviceStream::start() { // Child failed to exec; reap and surface the error. int status = 0; ::waitpid(pid, &status, 0); - QMessageBox::warning(nullptr, tr("Error"), - tr("Failed to start bridge: %1").arg(QString::fromLocal8Bit(strerror(exec_errno)))); + error(std::string("Failed to start bridge: ") + strerror(exec_errno)); return; } @@ -103,7 +94,7 @@ void DeviceStream::start() { } void DeviceStream::streamThread() { - zmq_address.isEmpty() ? unsetenv("ZMQ") : setenv("ZMQ", "1", 1); + zmq_address.empty() ? unsetenv("ZMQ") : setenv("ZMQ", "1", 1); std::unique_ptr context(Context::create()); std::unique_ptr sock(SubSocket::create(context.get(), "can", "127.0.0.1", false, true, services.at("can").queue_size)); @@ -118,31 +109,3 @@ void DeviceStream::streamThread() { handleEvent(kj::ArrayPtr((capnp::word*)msg->getData(), msg->getSize() / sizeof(capnp::word))); } } - -// OpenDeviceWidget - -OpenDeviceWidget::OpenDeviceWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) { - QRadioButton *msgq = new QRadioButton(tr("MSGQ")); - QRadioButton *zmq = new QRadioButton(tr("ZMQ")); - ip_address = new QLineEdit(this); - ip_address->setPlaceholderText(tr("Enter device Ip Address")); - ip_address->setValidator(new IpAddressValidator(this)); - - group = new QButtonGroup(this); - group->addButton(msgq, 0); - group->addButton(zmq, 1); - - QFormLayout *form_layout = new QFormLayout(this); - form_layout->addRow(msgq); - form_layout->addRow(zmq, ip_address); - QObject::connect(group, qOverload(&QButtonGroup::buttonToggled), [=](QAbstractButton *button, bool checked) { - ip_address->setEnabled(button == zmq && checked); - }); - zmq->setChecked(true); -} - -AbstractStream *OpenDeviceWidget::open() { - QString ip = ip_address->text().isEmpty() ? "127.0.0.1" : ip_address->text(); - bool msgq = group->checkedId() == 0; - return new DeviceStream(qApp, msgq ? "" : ip); -} diff --git a/openpilot/tools/cabana/streams/devicestream.h b/openpilot/tools/cabana/streams/devicestream.h index 0e6951c92c..3770d952aa 100644 --- a/openpilot/tools/cabana/streams/devicestream.h +++ b/openpilot/tools/cabana/streams/devicestream.h @@ -2,15 +2,15 @@ #include "tools/cabana/streams/livestream.h" +#include #include class DeviceStream : public LiveStream { - Q_OBJECT public: - DeviceStream(QObject *parent, QString address = {}); + DeviceStream(std::string address = {}); ~DeviceStream(); inline std::string routeName() const override { - return "Live Streaming From " + (zmq_address.isEmpty() ? std::string("127.0.0.1") : zmq_address.toStdString()); + return "Live Streaming From " + (zmq_address.empty() ? std::string("127.0.0.1") : zmq_address); } protected: @@ -18,17 +18,5 @@ protected: void streamThread() override; void stopBridge(); pid_t bridge_pid = -1; - const QString zmq_address; -}; - -class OpenDeviceWidget : public AbstractOpenStreamWidget { - Q_OBJECT - -public: - OpenDeviceWidget(QWidget *parent = nullptr); - AbstractStream *open() override; - -private: - QLineEdit *ip_address; - QButtonGroup *group; + const std::string zmq_address; }; diff --git a/openpilot/tools/cabana/streams/livestream.cc b/openpilot/tools/cabana/streams/livestream.cc index 019a67e8f2..ec71a6794d 100644 --- a/openpilot/tools/cabana/streams/livestream.cc +++ b/openpilot/tools/cabana/streams/livestream.cc @@ -9,6 +9,7 @@ #include "common/timing.h" #include "common/util.h" +#include "tools/cabana/settings.h" struct LiveStream::Logger { Logger() : start_ts(seconds_since_epoch()), segment_num(-1) {} @@ -21,12 +22,9 @@ struct LiveStream::Logger { localtime_r(&start_time, &local_time); std::ostringstream date; date << std::put_time(&local_time, "%Y-%m-%d--%H-%M-%S"); - QString dir = QString("%1/%2--%3") - .arg(QString::fromStdString(settings.log_path)) - .arg(QString::fromStdString(date.str())) - .arg(n); - util::create_directories(dir.toStdString(), 0755); - fs.reset(new std::ofstream((dir + "/rlog").toStdString(), std::ios::binary | std::ios::out)); + std::string dir = settings.log_path + "/" + date.str() + "--" + std::to_string(n); + util::create_directories(dir, 0755); + fs.reset(new std::ofstream(dir + "/rlog", std::ios::binary | std::ios::out)); } auto bytes = data.asBytes(); @@ -38,7 +36,7 @@ struct LiveStream::Logger { uint64_t start_ts; }; -LiveStream::LiveStream(QObject *parent) : AbstractStream(parent) { +LiveStream::LiveStream() { if (settings.log_livestream) { logger = std::make_unique(); } @@ -65,9 +63,9 @@ void LiveStream::stop() { void LiveStream::updateThread() { while (!exit_) { std::this_thread::sleep_for(std::chrono::milliseconds(1000 / fps_)); - // coalesce: skip the emit if the main thread hasn't processed the previous one yet. + // coalesce: skip the request if the main thread hasn't processed the previous one yet. if (!update_pending_.exchange(true)) { - emit privateUpdateLastMsgsSignal(); + requestUpdateLastMessages(); } } } @@ -89,7 +87,7 @@ void LiveStream::handleEvent(kj::ArrayPtr data) { } } -// called on the main thread by the queued privateUpdateLastMsgsSignal connection +// called on the main thread via requestUpdateLastMessages() void LiveStream::updateLastMessages() { update_pending_ = false; fps_ = settings.fps; @@ -142,10 +140,10 @@ void LiveStream::seekTo(double sec) { first_update_ts = nanos_since_boot(); current_event_ts = first_event_ts = std::min(sec * 1e9 + begin_event_ts, lastest_event_ts); post_last_event = (first_event_ts == lastest_event_ts); - emit seekedTo((current_event_ts - begin_event_ts) / 1e9); + seekedTo((current_event_ts - begin_event_ts) / 1e9); } void LiveStream::pause(bool pause) { paused_ = pause; - emit(pause ? paused() : resume()); + pause ? paused() : resume(); } diff --git a/openpilot/tools/cabana/streams/livestream.h b/openpilot/tools/cabana/streams/livestream.h index 5d65b1743f..22587335b0 100644 --- a/openpilot/tools/cabana/streams/livestream.h +++ b/openpilot/tools/cabana/streams/livestream.h @@ -9,10 +9,8 @@ #include "tools/cabana/streams/abstractstream.h" class LiveStream : public AbstractStream { - Q_OBJECT - public: - LiveStream(QObject *parent); + LiveStream(); virtual ~LiveStream(); void start() override; void stop(); diff --git a/openpilot/tools/cabana/streams/pandastream.cc b/openpilot/tools/cabana/streams/pandastream.cc index 7ccb18a756..0e72443c7a 100644 --- a/openpilot/tools/cabana/streams/pandastream.cc +++ b/openpilot/tools/cabana/streams/pandastream.cc @@ -4,13 +4,7 @@ #include #include -#include -#include -#include -#include -#include - -PandaStream::PandaStream(QObject *parent, PandaStreamConfig config_) : config(config_), LiveStream(parent) { +PandaStream::PandaStream(PandaStreamConfig config_) : config(config_) { if (!connect()) { throw std::runtime_error("Failed to connect to panda"); } @@ -77,113 +71,3 @@ void PandaStream::streamThread() { panda->send_heartbeat(false); } } - -// OpenPandaWidget - -OpenPandaWidget::OpenPandaWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) { - form_layout = new QFormLayout(this); - if (can && dynamic_cast(can) != nullptr) { - form_layout->addWidget(new QLabel(tr("Already connected to %1.").arg(QString::fromStdString(can->routeName())))); - form_layout->addWidget(new QLabel("Close the current connection via [File menu -> Close Stream] before connecting to another Panda.")); - QTimer::singleShot(0, [this]() { emit enableOpenButton(false); }); - return; - } - - QHBoxLayout *serial_layout = new QHBoxLayout(); - serial_layout->addWidget(serial_edit = new QComboBox()); - - QPushButton *refresh = new QPushButton(tr("Refresh")); - refresh->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred); - serial_layout->addWidget(refresh); - form_layout->addRow(tr("Serial"), serial_layout); - - QObject::connect(refresh, &QPushButton::clicked, this, &OpenPandaWidget::refreshSerials); - QObject::connect(serial_edit, &QComboBox::currentTextChanged, this, &OpenPandaWidget::buildConfigForm); - - // Populate serials - refreshSerials(); - buildConfigForm(); -} - -void OpenPandaWidget::refreshSerials() { - serial_edit->clear(); - for (auto serial : Panda::list()) { - serial_edit->addItem(QString::fromStdString(serial)); - } -} - -void OpenPandaWidget::buildConfigForm() { - for (int i = form_layout->rowCount() - 1; i > 0; --i) { - form_layout->removeRow(i); - } - - QString serial = serial_edit->currentText(); - bool has_fd = false; - bool has_panda = !serial.isEmpty(); - if (has_panda) { - try { - Panda panda(serial.toStdString()); - has_fd = (panda.hw_type == cereal::PandaState::PandaType::RED_PANDA) || (panda.hw_type == cereal::PandaState::PandaType::RED_PANDA_V2); - } catch (const std::exception& e) { - fprintf(stderr, "failed to open panda %s\n", serial.toUtf8().constData()); - has_panda = false; - } - } - - if (has_panda) { - config.serial = serial.toStdString(); - config.bus_config.resize(3); - for (int i = 0; i < config.bus_config.size(); i++) { - QHBoxLayout *bus_layout = new QHBoxLayout; - - // CAN Speed - bus_layout->addWidget(new QLabel(tr("CAN Speed (kbps):"))); - QComboBox *can_speed = new QComboBox; - for (int j = 0; j < std::size(speeds); j++) { - can_speed->addItem(QString::number(speeds[j])); - - if (data_speeds[j] == config.bus_config[i].can_speed_kbps) { - can_speed->setCurrentIndex(j); - } - } - QObject::connect(can_speed, qOverload(&QComboBox::currentIndexChanged), [=](int index) {config.bus_config[i].can_speed_kbps = speeds[index];}); - bus_layout->addWidget(can_speed); - - // CAN-FD Speed - if (has_fd) { - QCheckBox *enable_fd = new QCheckBox("CAN-FD"); - bus_layout->addWidget(enable_fd); - bus_layout->addWidget(new QLabel(tr("Data Speed (kbps):"))); - QComboBox *data_speed = new QComboBox; - for (int j = 0; j < std::size(data_speeds); j++) { - data_speed->addItem(QString::number(data_speeds[j])); - - if (data_speeds[j] == config.bus_config[i].data_speed_kbps) { - data_speed->setCurrentIndex(j); - } - } - - data_speed->setEnabled(false); - bus_layout->addWidget(data_speed); - - QObject::connect(data_speed, qOverload(&QComboBox::currentIndexChanged), [=](int index) {config.bus_config[i].data_speed_kbps = data_speeds[index];}); - QObject::connect(enable_fd, &QCheckBox::stateChanged, data_speed, &QComboBox::setEnabled); - QObject::connect(enable_fd, &QCheckBox::stateChanged, [=](int state) {config.bus_config[i].can_fd = (bool)state;}); - } - - form_layout->addRow(tr("Bus %1:").arg(i), bus_layout); - } - } else { - config.serial = ""; - form_layout->addWidget(new QLabel(tr("No panda found"))); - } -} - -AbstractStream *OpenPandaWidget::open() { - try { - return new PandaStream(qApp, config); - } catch (std::exception &e) { - QMessageBox::warning(nullptr, tr("Warning"), tr("Failed to connect to panda: '%1'").arg(e.what())); - return nullptr; - } -} diff --git a/openpilot/tools/cabana/streams/pandastream.h b/openpilot/tools/cabana/streams/pandastream.h index f8847f65e5..760b5c7b15 100644 --- a/openpilot/tools/cabana/streams/pandastream.h +++ b/openpilot/tools/cabana/streams/pandastream.h @@ -3,15 +3,9 @@ #include #include -#include -#include - #include "tools/cabana/streams/livestream.h" #include "tools/cabana/panda.h" -const uint32_t speeds[] = {10U, 20U, 50U, 100U, 125U, 250U, 500U, 1000U}; -const uint32_t data_speeds[] = {10U, 20U, 50U, 100U, 125U, 250U, 500U, 1000U, 2000U, 5000U}; - struct BusConfig { int can_speed_kbps = 500; int data_speed_kbps = 2000; @@ -24,9 +18,8 @@ struct PandaStreamConfig { }; class PandaStream : public LiveStream { - Q_OBJECT public: - PandaStream(QObject *parent, PandaStreamConfig config_ = {}); + PandaStream(PandaStreamConfig config_ = {}); ~PandaStream() { stop(); } inline std::string routeName() const override { return "Panda: " + config.serial; @@ -39,19 +32,3 @@ protected: std::unique_ptr panda; PandaStreamConfig config = {}; }; - -class OpenPandaWidget : public AbstractOpenStreamWidget { - Q_OBJECT - -public: - OpenPandaWidget(QWidget *parent = nullptr); - AbstractStream *open() override; - -private: - void refreshSerials(); - void buildConfigForm(); - - QComboBox *serial_edit; - QFormLayout *form_layout; - PandaStreamConfig config = {}; -}; diff --git a/openpilot/tools/cabana/streams/replaystream.cc b/openpilot/tools/cabana/streams/replaystream.cc index 6d54369aef..944cb10fd9 100644 --- a/openpilot/tools/cabana/streams/replaystream.cc +++ b/openpilot/tools/cabana/streams/replaystream.cc @@ -1,28 +1,26 @@ #include "tools/cabana/streams/replaystream.h" -#include - -#include -#include -#include -#include -#include +#include #include "common/timing.h" #include "common/util.h" -#include "tools/cabana/streams/routes.h" +#include "tools/cabana/settings.h" -ReplayStream::ReplayStream(QObject *parent) : AbstractStream(parent) { +ReplayStream::ReplayStream() { unsetenv("ZMQ"); setenv("COMMA_CACHE", "/tmp/comma_download_cache", 1); op_prefix = std::make_unique(); - QObject::connect(&settings, &Settings::changed, this, [this]() { + settings_connection_ = settings.changed.connect([this]() { if (replay) replay->setSegmentCacheLimit(settings.max_cached_minutes); }); } +ReplayStream::~ReplayStream() { + cancelWaits(); +} + void ReplayStream::mergeSegments() { auto event_data = replay->getEventData(); for (const auto &[n, seg] : event_data->segments) { @@ -51,38 +49,36 @@ bool ReplayStream::loadRoute(const std::string &route, const std::string &data_d replay->setSegmentCacheLimit(settings.max_cached_minutes); replay->installEventFilter([this](const Event *event) { return eventFilter(event); }); - // Forward replay callbacks to corresponding Qt signals. - replay->onSeeking = [this](double sec) { emit seeking(sec); }; + // replay callbacks arrive on replay threads + replay->onSeeking = [this](double sec) { postToMainThread([this, sec]() { seeking(sec); }); }; replay->onSeekedTo = [this](double sec) { - emit seekedTo(sec); + postToMainThread([this, sec]() { seekedTo(sec); }); waitForSeekFinshed(); }; - replay->onQLogLoaded = [this](std::shared_ptr qlog) { emit qLogLoaded(qlog); }; - replay->onSegmentsMerged = [this]() { QMetaObject::invokeMethod(this, &ReplayStream::mergeSegments, Qt::BlockingQueuedConnection); }; + replay->onQLogLoaded = [this](std::shared_ptr qlog) { postToMainThread([this, qlog]() { qLogLoaded(qlog); }); }; + replay->onSegmentsMerged = [this]() { postToMainThreadAndWait([this]() { mergeSegments(); }); }; bool success = replay->load(); if (!success) { + std::string message; if (replay->lastRouteError() == RouteLoadError::Unauthorized) { auto auth_content = util::read_file(util::getenv("HOME") + "/.comma/auth.json"); - QString message; if (auth_content.empty()) { message = "Authentication Required. Please run the following command to authenticate:\n\n" "python3 openpilot/tools/lib/auth.py\n\n" "This will grant access to routes from your comma account."; } else { - message = tr("Access Denied. You do not have permission to access route:\n\n%1\n\n" - "This is likely a private route.").arg(QString::fromStdString(route)); + message = "Access Denied. You do not have permission to access route:\n\n" + route + "\n\n" + "This is likely a private route."; } - QMessageBox::warning(nullptr, tr("Access Denied"), message); } else if (replay->lastRouteError() == RouteLoadError::NetworkError) { - QMessageBox::warning(nullptr, tr("Network Error"), - tr("Unable to load the route:\n\n %1.\n\nPlease check your network connection and try again.").arg(QString::fromStdString(route))); + message = "Unable to load the route:\n\n " + route + ".\n\nPlease check your network connection and try again."; } else if (replay->lastRouteError() == RouteLoadError::FileNotFound) { - QMessageBox::warning(nullptr, tr("Route Not Found"), - tr("The specified route could not be found:\n\n %1.\n\nPlease check the route name and try again.").arg(QString::fromStdString(route))); + message = "The specified route could not be found:\n\n " + route + ".\n\nPlease check the route name and try again."; } else { - QMessageBox::warning(nullptr, tr("Route Load Failed"), tr("Failed to load route: '%1'").arg(QString::fromStdString(route))); + message = "Failed to load route: '" + route + "'"; } + error(message); } return success; } @@ -102,7 +98,7 @@ bool ReplayStream::eventFilter(const Event *event) { double ts = millis_since_boot(); if ((ts - prev_update_ts) > (1000.0 / settings.fps)) { - emit privateUpdateLastMsgsSignal(); + requestUpdateLastMessages(); prev_update_ts = ts; } return true; @@ -110,66 +106,5 @@ bool ReplayStream::eventFilter(const Event *event) { void ReplayStream::pause(bool pause) { replay->pause(pause); - emit(pause ? paused() : resume()); -} - - -// OpenReplayWidget - -OpenReplayWidget::OpenReplayWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) { - QGridLayout *grid_layout = new QGridLayout(this); - grid_layout->addWidget(new QLabel(tr("Route")), 0, 0); - grid_layout->addWidget(route_edit = new QLineEdit(this), 0, 1); - route_edit->setPlaceholderText(tr("Enter route name or browse for local/remote route")); - auto browse_remote_btn = new QPushButton(tr("Remote route..."), this); - grid_layout->addWidget(browse_remote_btn, 0, 2); - auto browse_local_btn = new QPushButton(tr("Local route..."), this); - grid_layout->addWidget(browse_local_btn, 0, 3); - - QHBoxLayout *camera_layout = new QHBoxLayout(); - for (auto c : {tr("Road camera"), tr("Driver camera"), tr("Wide road camera")}) - camera_layout->addWidget(cameras.emplace_back(new QCheckBox(c, this))); - cameras[0]->setChecked(true); - camera_layout->addStretch(1); - grid_layout->addItem(camera_layout, 1, 1); - - setMinimumWidth(550); - QObject::connect(browse_local_btn, &QPushButton::clicked, [=]() { - QString dir = QFileDialog::getExistingDirectory(this, tr("Open Local Route"), QString::fromStdString(settings.last_route_dir)); - if (!dir.isEmpty()) { - route_edit->setText(dir); - settings.last_route_dir = std::filesystem::absolute(dir.toStdString()).parent_path().string(); - } - }); - QObject::connect(browse_remote_btn, &QPushButton::clicked, [this]() { - RoutesDialog route_dlg(this); - if (route_dlg.exec()) { - route_edit->setText(route_dlg.route()); - } - }); -} - -AbstractStream *OpenReplayWidget::open() { - QString route = route_edit->text(); - QString data_dir; - if (int idx = route.lastIndexOf('/'); idx != -1 && util::file_exists(route.toStdString())) { - data_dir = route.mid(0, idx + 1); - route = route.mid(idx + 1); - } - - bool is_valid_format = Route::parseRoute(route.toStdString()).str.size() > 0; - if (!is_valid_format) { - QMessageBox::warning(nullptr, tr("Warning"), tr("Invalid route format: '%1'").arg(route)); - } else { - auto replay_stream = std::make_unique(qApp); - uint32_t flags = REPLAY_FLAG_NONE; - if (cameras[1]->isChecked()) flags |= REPLAY_FLAG_CABIN_CAMERA; - if (cameras[2]->isChecked()) flags |= REPLAY_FLAG_WIDE_ROAD; - if (flags == REPLAY_FLAG_NONE && !cameras[0]->isChecked()) flags = REPLAY_FLAG_NO_VIPC; - - if (replay_stream->loadRoute(route.toStdString(), data_dir.toStdString(), flags)) { - return replay_stream.release(); - } - } - return nullptr; + pause ? paused() : resume(); } diff --git a/openpilot/tools/cabana/streams/replaystream.h b/openpilot/tools/cabana/streams/replaystream.h index eecd345715..aad0ac6361 100644 --- a/openpilot/tools/cabana/streams/replaystream.h +++ b/openpilot/tools/cabana/streams/replaystream.h @@ -1,22 +1,17 @@ #pragma once -#include #include #include #include -#include #include "common/prefix.h" #include "tools/cabana/streams/abstractstream.h" #include "tools/replay/replay.h" -Q_DECLARE_METATYPE(std::shared_ptr); - class ReplayStream : public AbstractStream { - Q_OBJECT - public: - ReplayStream(QObject *parent); + ReplayStream(); + ~ReplayStream(); void start() override { replay->start(); } bool loadRoute(const std::string &route, const std::string &data_dir, uint32_t replay_flags = REPLAY_FLAG_NONE, bool auto_source = false); bool eventFilter(const Event *event); @@ -36,24 +31,13 @@ public: inline bool isPaused() const override { return replay->isPaused(); } void pause(bool pause) override; -signals: - void qLogLoaded(std::shared_ptr qlog); + // invoked on the main thread + Observable> qLogLoaded; private: void mergeSegments(); std::unique_ptr replay = nullptr; + Connection settings_connection_; std::set processed_segments; std::unique_ptr op_prefix; }; - -class OpenReplayWidget : public AbstractOpenStreamWidget { - Q_OBJECT - -public: - OpenReplayWidget(QWidget *parent = nullptr); - AbstractStream *open() override; - -private: - QLineEdit *route_edit; - std::vector cameras; -}; diff --git a/openpilot/tools/cabana/streams/routes.cc b/openpilot/tools/cabana/streams/routes.cc deleted file mode 100644 index b6f98da533..0000000000 --- a/openpilot/tools/cabana/streams/routes.cc +++ /dev/null @@ -1,196 +0,0 @@ -#include "tools/cabana/streams/routes.h" - -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include - -#include "json11/json11.hpp" -#include "tools/replay/py_downloader.h" - -namespace { - -// Parse a PyDownloader JSON response into (success, error_code). -std::pair checkApiResponse(const std::string &result) { - if (result.empty()) return {false, 500}; - std::string err; - auto doc = json11::Json::parse(result, err); - if (!err.empty()) return {false, 500}; - if (doc.is_object() && doc["error"].is_string()) { - return {false, doc["error"].string_value() == "unauthorized" ? 401 : 500}; - } - return {true, 0}; -} - -int64_t nowUnixMs() { - return std::chrono::duration_cast( - std::chrono::system_clock::now().time_since_epoch()) - .count(); -} - -// Parse ISO-8601 (with optional fractional seconds / Z) to unix ms. Returns 0 on failure. -int64_t parseIsoToUnixMs(const std::string &s) { - std::string bytes = s; - if (!bytes.empty() && (bytes.back() == 'Z' || bytes.back() == 'z')) bytes.pop_back(); - int millis = 0; - auto dot = bytes.find('.'); - if (dot != std::string::npos) { - std::string frac = bytes.substr(dot + 1); - bytes = bytes.substr(0, dot); - while (frac.size() < 3) frac.push_back('0'); - millis = std::atoi(frac.substr(0, 3).c_str()); - } - std::tm tm{}; - const char *ret = strptime(bytes.c_str(), "%Y-%m-%dT%H:%M:%S", &tm); - if (!ret) ret = strptime(bytes.c_str(), "%Y-%m-%d %H:%M:%S", &tm); - if (!ret) return 0; - tm.tm_isdst = -1; - time_t secs = timegm(&tm); - if (secs == static_cast(-1)) return 0; - return static_cast(secs) * 1000 + millis; -} - -QString formatUnixMs(int64_t ms) { - time_t secs = static_cast(ms / 1000); - std::tm tm{}; - localtime_r(&secs, &tm); - char buf[64]; - std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm); - return QString::fromUtf8(buf); -} - -} // namespace - -// The RouteListWidget class extends QListWidget to display a custom message when empty -class RouteListWidget : public QListWidget { -public: - RouteListWidget(QWidget *parent = nullptr) : QListWidget(parent) {} - void setEmptyText(const QString &text) { - empty_text_ = text; - viewport()->update(); - } - void paintEvent(QPaintEvent *event) override { - QListWidget::paintEvent(event); - if (count() == 0) { - QPainter painter(viewport()); - painter.drawText(viewport()->rect(), Qt::AlignCenter, empty_text_); - } - } - QString empty_text_ = tr("No items"); -}; - -RoutesDialog::RoutesDialog(QWidget *parent) : QDialog(parent) { - setWindowTitle(tr("Remote routes")); - - QFormLayout *layout = new QFormLayout(this); - layout->addRow(tr("Device"), device_list_ = new QComboBox(this)); - layout->addRow(period_selector_ = new QComboBox(this)); - layout->addRow(route_list_ = new RouteListWidget(this)); - auto button_box = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); - layout->addRow(button_box); - - device_list_->addItem(tr("Loading...")); - period_selector_->addItem(tr("Last week"), 7); - period_selector_->addItem(tr("Last 2 weeks"), 14); - period_selector_->addItem(tr("Last month"), 30); - period_selector_->addItem(tr("Last 6 months"), 180); - period_selector_->addItem(tr("Preserved"), -1); - - connect(device_list_, QOverload::of(&QComboBox::currentIndexChanged), this, &RoutesDialog::fetchRoutes); - connect(period_selector_, QOverload::of(&QComboBox::currentIndexChanged), this, &RoutesDialog::fetchRoutes); - connect(route_list_, &QListWidget::itemDoubleClicked, this, &QDialog::accept); - connect(button_box, &QDialogButtonBox::accepted, this, &QDialog::accept); - connect(button_box, &QDialogButtonBox::rejected, this, &QDialog::reject); - - // Fetch devices - std::thread([this, alive = std::weak_ptr(alive_)]() { - std::string result = PyDownloader::getDevices(); - QMetaObject::invokeMethod(qApp, [this, alive, r = QString::fromStdString(result), response = checkApiResponse(result)]() { - if (!alive.expired()) parseDeviceList(r, response.first, response.second); - }, Qt::QueuedConnection); - }).detach(); -} - -void RoutesDialog::parseDeviceList(const QString &json, bool success, int error_code) { - if (success) { - device_list_->clear(); - std::string err; - auto doc = json11::Json::parse(json.toStdString(), err); - if (err.empty() && doc.is_array()) { - for (const auto &device : doc.array_items()) { - QString dongle_id = QString::fromStdString(device["dongle_id"].string_value()); - device_list_->addItem(dongle_id, dongle_id); - } - } - } else { - QMessageBox::warning(this, tr("Error"), error_code == 401 ? tr("Unauthorized. Authenticate with openpilot/tools/lib/auth.py") : tr("Network error")); - reject(); - } -} - -void RoutesDialog::fetchRoutes() { - if (device_list_->currentIndex() == -1 || device_list_->currentData().isNull()) - return; - - route_list_->clear(); - route_list_->setEmptyText(tr("Loading...")); - - std::string did = device_list_->currentText().toStdString(); - int period = period_selector_->currentData().toInt(); - - bool preserved = (period == -1); - int64_t start_ms = 0, end_ms = 0; - if (!preserved) { - end_ms = nowUnixMs(); - start_ms = end_ms - static_cast(period) * 24LL * 60LL * 60LL * 1000LL; - } - - int request_id = ++fetch_id_; - std::thread([this, alive = std::weak_ptr(alive_), did, start_ms, end_ms, preserved, request_id]() { - std::string result = PyDownloader::getDeviceRoutes(did, start_ms, end_ms, preserved); - QMetaObject::invokeMethod(qApp, [this, alive, r = QString::fromStdString(result), response = checkApiResponse(result), request_id]() { - if (!alive.expired() && fetch_id_ == request_id) parseRouteList(r, response.first, response.second); - }, Qt::QueuedConnection); - }).detach(); -} - -void RoutesDialog::parseRouteList(const QString &json, bool success, int error_code) { - if (success) { - std::string err; - auto doc = json11::Json::parse(json.toStdString(), err); - if (err.empty() && doc.is_array()) { - for (const auto &route : doc.array_items()) { - int64_t from_ms = 0, to_ms = 0; - if (period_selector_->currentData().toInt() == -1) { - from_ms = parseIsoToUnixMs(route["start_time"].string_value()); - to_ms = parseIsoToUnixMs(route["end_time"].string_value()); - } else { - from_ms = static_cast(route["start_time_utc_millis"].number_value()); - to_ms = static_cast(route["end_time_utc_millis"].number_value()); - } - const int mins = static_cast((to_ms - from_ms) / 60000); - auto item = new QListWidgetItem(QString("%1 %2min").arg(formatUnixMs(from_ms)).arg(mins)); - item->setData(Qt::UserRole, QString::fromStdString(route["fullname"].string_value())); - route_list_->addItem(item); - } - } - if (route_list_->count() > 0) route_list_->setCurrentRow(0); - } else { - QMessageBox::warning(this, tr("Error"), tr("Failed to fetch routes. Check your network connection.")); - reject(); - } - route_list_->setEmptyText(tr("No items")); -} - -QString RoutesDialog::route() { - auto current_item = route_list_->currentItem(); - return current_item ? current_item->data(Qt::UserRole).toString() : ""; -} diff --git a/openpilot/tools/cabana/streams/socketcanstream.cc b/openpilot/tools/cabana/streams/socketcanstream.cc index b616e7f242..f16ed36d9d 100644 --- a/openpilot/tools/cabana/streams/socketcanstream.cc +++ b/openpilot/tools/cabana/streams/socketcanstream.cc @@ -8,15 +8,8 @@ #include #include -#include -#include -#include -#include -#include -#include - -SocketCanStream::SocketCanStream(QObject *parent, SocketCanStreamConfig config_) : config(config_), LiveStream(parent) { +SocketCanStream::SocketCanStream(SocketCanStreamConfig config_) : config(config_) { if (!available()) { throw std::runtime_error("SocketCAN not available"); } @@ -98,51 +91,3 @@ void SocketCanStream::streamThread() { handleEvent(capnp::messageToFlatArray(msg)); } } - -OpenSocketCanWidget::OpenSocketCanWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) { - QVBoxLayout *main_layout = new QVBoxLayout(this); - main_layout->addStretch(1); - - QFormLayout *form_layout = new QFormLayout(); - - QHBoxLayout *device_layout = new QHBoxLayout(); - device_edit = new QComboBox(); - device_edit->setFixedWidth(300); - device_layout->addWidget(device_edit); - - QPushButton *refresh = new QPushButton(tr("Refresh")); - refresh->setFixedWidth(100); - device_layout->addWidget(refresh); - form_layout->addRow(tr("Device"), device_layout); - main_layout->addLayout(form_layout); - - main_layout->addStretch(1); - - QObject::connect(refresh, &QPushButton::clicked, this, &OpenSocketCanWidget::refreshDevices); - QObject::connect(device_edit, &QComboBox::currentTextChanged, this, [=]{ config.device = device_edit->currentText().toStdString(); }); - - // Populate devices - refreshDevices(); -} - -void OpenSocketCanWidget::refreshDevices() { - device_edit->clear(); - // Scan /sys/class/net/ for CAN interfaces (type 280 = ARPHRD_CAN) - std::error_code ec; - for (const auto &entry : std::filesystem::directory_iterator("/sys/class/net", ec)) { - std::ifstream type_file(entry.path() / "type"); - int type = 0; - if (type_file >> type && type == 280) { - device_edit->addItem(QString::fromStdString(entry.path().filename().string())); - } - } -} - -AbstractStream *OpenSocketCanWidget::open() { - try { - return new SocketCanStream(qApp, config); - } catch (std::exception &e) { - QMessageBox::warning(nullptr, tr("Warning"), tr("Failed to connect to SocketCAN device: '%1'").arg(e.what())); - return nullptr; - } -} diff --git a/openpilot/tools/cabana/streams/socketcanstream.h b/openpilot/tools/cabana/streams/socketcanstream.h index 3c5cd184f7..c44a498221 100644 --- a/openpilot/tools/cabana/streams/socketcanstream.h +++ b/openpilot/tools/cabana/streams/socketcanstream.h @@ -1,7 +1,5 @@ #pragma once -#include - #include "tools/cabana/streams/livestream.h" struct SocketCanStreamConfig { @@ -9,9 +7,8 @@ struct SocketCanStreamConfig { }; class SocketCanStream : public LiveStream { - Q_OBJECT public: - SocketCanStream(QObject *parent, SocketCanStreamConfig config_ = {}); + SocketCanStream(SocketCanStreamConfig config_ = {}); ~SocketCanStream(); static bool available(); @@ -26,17 +23,3 @@ protected: SocketCanStreamConfig config = {}; int sock_fd = -1; }; - -class OpenSocketCanWidget : public AbstractOpenStreamWidget { - Q_OBJECT - -public: - OpenSocketCanWidget(QWidget *parent = nullptr); - AbstractStream *open() override; - -private: - void refreshDevices(); - - QComboBox *device_edit; - SocketCanStreamConfig config = {}; -}; diff --git a/openpilot/tools/cabana/streamselector.cc b/openpilot/tools/cabana/streamselector.cc index 7e8adc568d..31532737f3 100644 --- a/openpilot/tools/cabana/streamselector.cc +++ b/openpilot/tools/cabana/streamselector.cc @@ -1,18 +1,279 @@ #include "tools/cabana/streamselector.h" #include +#include #include +#include #include +#include #include +#include +#include #include "tools/cabana/streams/devicestream.h" -#include "tools/cabana/streams/pandastream.h" #include "tools/cabana/streams/replaystream.h" +#include "tools/cabana/routesdialog.h" +#include "tools/cabana/utils/qtutil.h" + +// OpenReplayWidget + +OpenReplayWidget::OpenReplayWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) { + QGridLayout *grid_layout = new QGridLayout(this); + grid_layout->addWidget(new QLabel(tr("Route")), 0, 0); + grid_layout->addWidget(route_edit = new QLineEdit(this), 0, 1); + route_edit->setPlaceholderText(tr("Enter route name or browse for local/remote route")); + auto browse_remote_btn = new QPushButton(tr("Remote route..."), this); + grid_layout->addWidget(browse_remote_btn, 0, 2); + auto browse_local_btn = new QPushButton(tr("Local route..."), this); + grid_layout->addWidget(browse_local_btn, 0, 3); + + QHBoxLayout *camera_layout = new QHBoxLayout(); + for (auto c : {tr("Road camera"), tr("Driver camera"), tr("Wide road camera")}) + camera_layout->addWidget(cameras.emplace_back(new QCheckBox(c, this))); + cameras[0]->setChecked(true); + camera_layout->addStretch(1); + grid_layout->addItem(camera_layout, 1, 1); + + setMinimumWidth(550); + QObject::connect(browse_local_btn, &QPushButton::clicked, [=]() { + QString dir = QFileDialog::getExistingDirectory(this, tr("Open Local Route"), QString::fromStdString(settings.last_route_dir)); + if (!dir.isEmpty()) { + route_edit->setText(dir); + settings.last_route_dir = std::filesystem::absolute(dir.toStdString()).parent_path().string(); + } + }); + QObject::connect(browse_remote_btn, &QPushButton::clicked, [this]() { + RoutesDialog route_dlg(this); + if (route_dlg.exec()) { + route_edit->setText(QString::fromStdString(route_dlg.route())); + } + }); +} + +AbstractStream *OpenReplayWidget::open() { + QString route = route_edit->text(); + QString data_dir; + if (int idx = route.lastIndexOf('/'); idx != -1 && util::file_exists(route.toStdString())) { + data_dir = route.mid(0, idx + 1); + route = route.mid(idx + 1); + } + + bool is_valid_format = Route::parseRoute(route.toStdString()).str.size() > 0; + if (!is_valid_format) { + QMessageBox::warning(nullptr, tr("Warning"), tr("Invalid route format: '%1'").arg(route)); + } else { + auto replay_stream = std::make_unique(); + Connection err = replay_stream->error.connect([](const std::string &msg) { + QMessageBox::warning(nullptr, tr("Error"), QString::fromStdString(msg)); + }); + uint32_t flags = REPLAY_FLAG_NONE; + if (cameras[1]->isChecked()) flags |= REPLAY_FLAG_CABIN_CAMERA; + if (cameras[2]->isChecked()) flags |= REPLAY_FLAG_WIDE_ROAD; + if (flags == REPLAY_FLAG_NONE && !cameras[0]->isChecked()) flags = REPLAY_FLAG_NO_VIPC; + + if (replay_stream->loadRoute(route.toStdString(), data_dir.toStdString(), flags)) { + return replay_stream.release(); + } + } + return nullptr; +} + +// OpenPandaWidget + +static const uint32_t speeds[] = {10U, 20U, 50U, 100U, 125U, 250U, 500U, 1000U}; +static const uint32_t data_speeds[] = {10U, 20U, 50U, 100U, 125U, 250U, 500U, 1000U, 2000U, 5000U}; + +OpenPandaWidget::OpenPandaWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) { + form_layout = new QFormLayout(this); + if (can && dynamic_cast(can) != nullptr) { + form_layout->addWidget(new QLabel(tr("Already connected to %1.").arg(QString::fromStdString(can->routeName())))); + form_layout->addWidget(new QLabel("Close the current connection via [File menu -> Close Stream] before connecting to another Panda.")); + QTimer::singleShot(0, [this]() { emit enableOpenButton(false); }); + return; + } + + QHBoxLayout *serial_layout = new QHBoxLayout(); + serial_layout->addWidget(serial_edit = new QComboBox()); + + QPushButton *refresh = new QPushButton(tr("Refresh")); + refresh->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred); + serial_layout->addWidget(refresh); + form_layout->addRow(tr("Serial"), serial_layout); + + QObject::connect(refresh, &QPushButton::clicked, this, &OpenPandaWidget::refreshSerials); + QObject::connect(serial_edit, &QComboBox::currentTextChanged, this, &OpenPandaWidget::buildConfigForm); + + // Populate serials + refreshSerials(); + buildConfigForm(); +} + +void OpenPandaWidget::refreshSerials() { + serial_edit->clear(); + for (auto serial : Panda::list()) { + serial_edit->addItem(QString::fromStdString(serial)); + } +} + +void OpenPandaWidget::buildConfigForm() { + for (int i = form_layout->rowCount() - 1; i > 0; --i) { + form_layout->removeRow(i); + } + + QString serial = serial_edit->currentText(); + bool has_fd = false; + bool has_panda = !serial.isEmpty(); + if (has_panda) { + try { + Panda panda(serial.toStdString()); + has_fd = (panda.hw_type == cereal::PandaState::PandaType::RED_PANDA) || (panda.hw_type == cereal::PandaState::PandaType::RED_PANDA_V2); + } catch (const std::exception& e) { + fprintf(stderr, "failed to open panda %s\n", serial.toUtf8().constData()); + has_panda = false; + } + } + + if (has_panda) { + config.serial = serial.toStdString(); + config.bus_config.resize(3); + for (int i = 0; i < config.bus_config.size(); i++) { + QHBoxLayout *bus_layout = new QHBoxLayout; + + // CAN Speed + bus_layout->addWidget(new QLabel(tr("CAN Speed (kbps):"))); + QComboBox *can_speed = new QComboBox; + for (int j = 0; j < std::size(speeds); j++) { + can_speed->addItem(QString::number(speeds[j])); + + if (data_speeds[j] == config.bus_config[i].can_speed_kbps) { + can_speed->setCurrentIndex(j); + } + } + QObject::connect(can_speed, qOverload(&QComboBox::currentIndexChanged), [=](int index) {config.bus_config[i].can_speed_kbps = speeds[index];}); + bus_layout->addWidget(can_speed); + + // CAN-FD Speed + if (has_fd) { + QCheckBox *enable_fd = new QCheckBox("CAN-FD"); + bus_layout->addWidget(enable_fd); + bus_layout->addWidget(new QLabel(tr("Data Speed (kbps):"))); + QComboBox *data_speed = new QComboBox; + for (int j = 0; j < std::size(data_speeds); j++) { + data_speed->addItem(QString::number(data_speeds[j])); + + if (data_speeds[j] == config.bus_config[i].data_speed_kbps) { + data_speed->setCurrentIndex(j); + } + } + + data_speed->setEnabled(false); + bus_layout->addWidget(data_speed); + + QObject::connect(data_speed, qOverload(&QComboBox::currentIndexChanged), [=](int index) {config.bus_config[i].data_speed_kbps = data_speeds[index];}); + QObject::connect(enable_fd, &QCheckBox::stateChanged, data_speed, &QComboBox::setEnabled); + QObject::connect(enable_fd, &QCheckBox::stateChanged, [=](int state) {config.bus_config[i].can_fd = (bool)state;}); + } + + form_layout->addRow(tr("Bus %1:").arg(i), bus_layout); + } + } else { + config.serial = ""; + form_layout->addWidget(new QLabel(tr("No panda found"))); + } +} + +AbstractStream *OpenPandaWidget::open() { + try { + return new PandaStream(config); + } catch (std::exception &e) { + QMessageBox::warning(nullptr, tr("Warning"), tr("Failed to connect to panda: '%1'").arg(e.what())); + return nullptr; + } +} + +// OpenDeviceWidget + +OpenDeviceWidget::OpenDeviceWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) { + QRadioButton *msgq = new QRadioButton(tr("MSGQ")); + QRadioButton *zmq = new QRadioButton(tr("ZMQ")); + ip_address = new QLineEdit(this); + ip_address->setPlaceholderText(tr("Enter device Ip Address")); + ip_address->setValidator(new IpAddressValidator(this)); + + group = new QButtonGroup(this); + group->addButton(msgq, 0); + group->addButton(zmq, 1); + + QFormLayout *form_layout = new QFormLayout(this); + form_layout->addRow(msgq); + form_layout->addRow(zmq, ip_address); + QObject::connect(group, qOverload(&QButtonGroup::buttonToggled), [=](QAbstractButton *button, bool checked) { + ip_address->setEnabled(button == zmq && checked); + }); + zmq->setChecked(true); +} + +AbstractStream *OpenDeviceWidget::open() { + std::string ip = ip_address->text().isEmpty() ? "127.0.0.1" : ip_address->text().toStdString(); + bool msgq = group->checkedId() == 0; + return new DeviceStream(msgq ? "" : ip); +} + #ifdef __linux__ -#include "tools/cabana/streams/socketcanstream.h" +// OpenSocketCanWidget + +OpenSocketCanWidget::OpenSocketCanWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) { + QVBoxLayout *main_layout = new QVBoxLayout(this); + main_layout->addStretch(1); + + QFormLayout *form_layout = new QFormLayout(); + + QHBoxLayout *device_layout = new QHBoxLayout(); + device_edit = new QComboBox(); + device_edit->setFixedWidth(300); + device_layout->addWidget(device_edit); + + QPushButton *refresh = new QPushButton(tr("Refresh")); + refresh->setFixedWidth(100); + device_layout->addWidget(refresh); + form_layout->addRow(tr("Device"), device_layout); + main_layout->addLayout(form_layout); + + main_layout->addStretch(1); + + QObject::connect(refresh, &QPushButton::clicked, this, &OpenSocketCanWidget::refreshDevices); + QObject::connect(device_edit, &QComboBox::currentTextChanged, this, [=]{ config.device = device_edit->currentText().toStdString(); }); + + // Populate devices + refreshDevices(); +} + +void OpenSocketCanWidget::refreshDevices() { + device_edit->clear(); + // Scan /sys/class/net/ for CAN interfaces (type 280 = ARPHRD_CAN) + std::error_code ec; + for (const auto &entry : std::filesystem::directory_iterator("/sys/class/net", ec)) { + std::ifstream type_file(entry.path() / "type"); + int type = 0; + if (type_file >> type && type == 280) { + device_edit->addItem(QString::fromStdString(entry.path().filename().string())); + } + } +} + +AbstractStream *OpenSocketCanWidget::open() { + try { + return new SocketCanStream(config); + } catch (std::exception &e) { + QMessageBox::warning(nullptr, tr("Warning"), tr("Failed to connect to SocketCAN device: '%1'").arg(e.what())); + return nullptr; + } +} #endif +// StreamSelector + StreamSelector::StreamSelector(QWidget *parent) : QDialog(parent) { setWindowTitle(tr("Open stream")); QVBoxLayout *layout = new QVBoxLayout(this); diff --git a/openpilot/tools/cabana/streamselector.h b/openpilot/tools/cabana/streamselector.h index 0919195e4e..1210806a3b 100644 --- a/openpilot/tools/cabana/streamselector.h +++ b/openpilot/tools/cabana/streamselector.h @@ -1,11 +1,86 @@ #pragma once +#include + +#include +#include +#include #include #include +#include #include #include #include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/streams/pandastream.h" +#ifdef __linux__ +#include "tools/cabana/streams/socketcanstream.h" +#endif + +class AbstractOpenStreamWidget : public QWidget { + Q_OBJECT +public: + AbstractOpenStreamWidget(QWidget *parent = nullptr) : QWidget(parent) {} + virtual AbstractStream *open() = 0; + +signals: + void enableOpenButton(bool); +}; + +class OpenReplayWidget : public AbstractOpenStreamWidget { + Q_OBJECT + +public: + OpenReplayWidget(QWidget *parent = nullptr); + AbstractStream *open() override; + +private: + QLineEdit *route_edit; + std::vector cameras; +}; + +class OpenPandaWidget : public AbstractOpenStreamWidget { + Q_OBJECT + +public: + OpenPandaWidget(QWidget *parent = nullptr); + AbstractStream *open() override; + +private: + void refreshSerials(); + void buildConfigForm(); + + QComboBox *serial_edit; + QFormLayout *form_layout; + PandaStreamConfig config = {}; +}; + +class OpenDeviceWidget : public AbstractOpenStreamWidget { + Q_OBJECT + +public: + OpenDeviceWidget(QWidget *parent = nullptr); + AbstractStream *open() override; + +private: + QLineEdit *ip_address; + QButtonGroup *group; +}; + +#ifdef __linux__ +// no Q_OBJECT: moc does not define __linux__ and would otherwise skip this class +class OpenSocketCanWidget : public AbstractOpenStreamWidget { +public: + OpenSocketCanWidget(QWidget *parent = nullptr); + AbstractStream *open() override; + +private: + void refreshDevices(); + + QComboBox *device_edit; + SocketCanStreamConfig config = {}; +}; +#endif class StreamSelector : public QDialog { Q_OBJECT diff --git a/openpilot/tools/cabana/tests/test_cabana.cc b/openpilot/tools/cabana/tests/test_cabana.cc index 53be1b0afa..5c5fce9864 100644 --- a/openpilot/tools/cabana/tests/test_cabana.cc +++ b/openpilot/tools/cabana/tests/test_cabana.cc @@ -1,10 +1,14 @@ +#include +#include #include #include #include "common/tests/native_test.h" #include "tools/cabana/dbc/dbcfile.h" #include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/routes.h" +#include "tools/cabana/utils/strings.h" const std::string TEST_RLOG_URL = "https://commadataci.blob.core.windows.net/openpilotci/0c94aa1e1296d7c6/2021-05-05--19-48-37/0/rlog.bz2"; @@ -164,11 +168,10 @@ void test_dbc_manager() { int files_changed = 0; int signals_added = 0; int masks_updated = 0; - manager.setCallbacks({ - .signal_added = [&](MessageId, const cabana::Signal *) { ++signals_added; }, - .file_changed = [&]() { ++files_changed; }, - .mask_updated = [&]() { ++masks_updated; }, - }); + Connections connections; + connections.push_back(manager.signalAdded.connect([&](MessageId, const cabana::Signal *) { ++signals_added; })); + connections.push_back(manager.fileChanged.connect([&]() { ++files_changed; })); + connections.push_back(manager.maskUpdated.connect([&]() { ++masks_updated; })); std::string error; REQUIRE(manager.open(SOURCE_ALL, "test", "BO_ 160 message: 8 XXX\n", &error)); @@ -186,7 +189,120 @@ void test_dbc_manager() { REQUIRE(manager.msg({.source = 0, .address = 160})->sig("speed") != nullptr); } +void test_format_seconds() { + REQUIRE(utils::formatSeconds(0) == "00:00"); + REQUIRE(utils::formatSeconds(59.4) == "00:59"); + REQUIRE(utils::formatSeconds(-1) == "00:00"); + REQUIRE(utils::formatSeconds(61.234, true) == "01:01.234"); + REQUIRE(utils::formatSeconds(3599.9) == "59:59"); + REQUIRE(utils::formatSeconds(3601) == "01:00:01"); + REQUIRE(utils::formatSeconds(3601.5, true) == "01:00:01.500"); + + const char *tz = getenv("TZ"); + const bool had_tz = tz != nullptr; + const std::string saved_tz = had_tz ? tz : ""; + setenv("TZ", "UTC", 1); + tzset(); + REQUIRE(utils::formatSeconds(0, false, true) == "1970-01-01 00:00:00"); + REQUIRE(utils::formatSeconds(1700000000.123, true, true) == "2023-11-14 22:13:20.123"); + if (had_tz) { + setenv("TZ", saved_tz.c_str(), 1); + } else { + unsetenv("TZ"); + } + tzset(); +} + +void test_to_hex() { + REQUIRE(utils::toHex({}) == ""); + REQUIRE(utils::toHex({0x00, 0x0f, 0xab, 0xff}) == "000FABFF"); + REQUIRE(utils::toHex({0x01, 0x02, 0x03}, ' ') == "01 02 03"); + + REQUIRE(utils::toHexString(0) == "0x00"); + REQUIRE(utils::toHexString(0xf) == "0x0F"); + REQUIRE(utils::toHexString(0x1ab) == "0x1AB"); + REQUIRE(utils::toHexString(0x1fffffff) == "0x1FFFFFFF"); +} + +void test_signal_tooltip() { + cabana::Signal sig{}; + sig.name = "speed"; + sig.start_bit = 3; + sig.size = 12; + sig.msb = 14; + sig.lsb = 3; + sig.is_little_endian = true; + sig.is_signed = false; + REQUIRE(utils::signalToolTip(&sig) == R"( + speed
+ Start Bit: 3 Size: 12
+ MSB: 14 LSB: 3
+ Little Endian: Y Signed: N
+ )"); +} + +void test_route_timestamps() { + REQUIRE(routes::parseIsoToUnixMs("2024-01-02T03:04:05Z") == 1704164645000); + REQUIRE(routes::parseIsoToUnixMs("2024-01-02T03:04:05") == 1704164645000); + REQUIRE(routes::parseIsoToUnixMs("2024-01-02 03:04:05") == 1704164645000); + REQUIRE(routes::parseIsoToUnixMs("2024-01-02T03:04:05.123Z") == 1704164645123); + REQUIRE(routes::parseIsoToUnixMs("2024-01-02T03:04:05.4Z") == 1704164645400); + REQUIRE(routes::parseIsoToUnixMs("2024-01-02T03:04:05.123456Z") == 1704164645123); + REQUIRE(routes::parseIsoToUnixMs("") == 0); + REQUIRE(routes::parseIsoToUnixMs("not a timestamp") == 0); + + // formatUnixMs is local time + const char *tz = getenv("TZ"); + const std::string prev_tz = tz ? tz : ""; + setenv("TZ", "UTC", 1); + tzset(); + REQUIRE(routes::formatUnixMs(1704164645123) == "2024-01-02 03:04:05"); + if (tz) { + setenv("TZ", prev_tz.c_str(), 1); + } else { + unsetenv("TZ"); + } + tzset(); +} + +void test_route_api_response() { + REQUIRE(routes::checkApiResponse("") == std::make_pair(false, 500)); + REQUIRE(routes::checkApiResponse("not json") == std::make_pair(false, 500)); + REQUIRE(routes::checkApiResponse(R"({"error": "unauthorized"})") == std::make_pair(false, 401)); + REQUIRE(routes::checkApiResponse(R"({"error": "server error"})") == std::make_pair(false, 500)); + REQUIRE(routes::checkApiResponse("[]") == std::make_pair(true, 0)); + REQUIRE(routes::checkApiResponse(R"({"dongle_id": "aaaa"})") == std::make_pair(true, 0)); +} + +void test_route_json() { + auto devices = routes::parseDevices(R"([{"dongle_id": "aaaa"}, {"dongle_id": "bbbb"}])"); + REQUIRE(devices.size() == 2); + REQUIRE(devices[0].dongle_id == "aaaa"); + REQUIRE(devices[1].dongle_id == "bbbb"); + REQUIRE(routes::parseDevices("not json").empty()); + REQUIRE(routes::parseDevices(R"({"error": "unauthorized"})").empty()); + + auto list = routes::parseRoutes( + R"([{"fullname": "aaaa|2024-01-02--03-04-05", "start_time_utc_millis": 1704164645000, "end_time_utc_millis": 1704165245000}])", false); + REQUIRE(list.size() == 1); + REQUIRE(list[0].name == "aaaa|2024-01-02--03-04-05"); + REQUIRE(list[0].start_ms == 1704164645000); + REQUIRE(list[0].end_ms == 1704165245000); + + // preserved routes report ISO-8601 timestamps + auto preserved = routes::parseRoutes( + R"([{"fullname": "aaaa|2024-01-02--03-04-05", "start_time": "2024-01-02T03:04:05Z", "end_time": "2024-01-02T03:14:05Z"}])", true); + REQUIRE(preserved.size() == 1); + REQUIRE(preserved[0].start_ms == 1704164645000); + REQUIRE(preserved[0].end_ms == 1704165245000); + + REQUIRE(routes::parseRoutes("not json", false).empty()); +} + void test_cabana_core() { + test_format_seconds(); + test_to_hex(); + test_signal_tooltip(); test_generate_dbc(); test_comment_order(); test_preserve_original_header(); @@ -194,6 +310,9 @@ void test_cabana_core() { test_parse_dbc(); test_parse_opendbc(); test_dbc_manager(); + test_route_timestamps(); + test_route_api_response(); + test_route_json(); } int main() { diff --git a/openpilot/tools/cabana/tools/findsignal.cc b/openpilot/tools/cabana/tools/findsignal.cc index 4511af7c01..2e533741cc 100644 --- a/openpilot/tools/cabana/tools/findsignal.cc +++ b/openpilot/tools/cabana/tools/findsignal.cc @@ -10,6 +10,8 @@ #include #include +#include "tools/cabana/utils/qtutil.h" + // FindSignalModel QVariant FindSignalModel::headerData(int section, Qt::Orientation orientation, int role) const { diff --git a/openpilot/tools/cabana/tools/findsignal.h b/openpilot/tools/cabana/tools/findsignal.h index 239a08c9c4..b7cae73b49 100644 --- a/openpilot/tools/cabana/tools/findsignal.h +++ b/openpilot/tools/cabana/tools/findsignal.h @@ -7,8 +7,13 @@ #include #include +#include +#include +#include #include +#include #include +#include #include #include "tools/cabana/commands.h" diff --git a/openpilot/tools/cabana/tools/routeinfo.cc b/openpilot/tools/cabana/tools/routeinfo.cc index dc272e3d12..1037d4a206 100644 --- a/openpilot/tools/cabana/tools/routeinfo.cc +++ b/openpilot/tools/cabana/tools/routeinfo.cc @@ -6,7 +6,7 @@ #include "tools/cabana/streams/replaystream.h" RouteInfoDlg::RouteInfoDlg(QWidget *parent) : QDialog(parent) { - auto *replay = qobject_cast(can)->getReplay(); + auto *replay = dynamic_cast(can)->getReplay(); setWindowTitle(tr("Route: %1").arg(QString::fromStdString(replay->route().name()))); auto *table = new QTableWidget(replay->route().segments().size(), 7, this); diff --git a/openpilot/tools/cabana/utils/qtutil.cc b/openpilot/tools/cabana/utils/qtutil.cc new file mode 100644 index 0000000000..040c90c624 --- /dev/null +++ b/openpilot/tools/cabana/utils/qtutil.cc @@ -0,0 +1,236 @@ +#include "tools/cabana/utils/qtutil.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +// MessageBytesDelegate + +MessageBytesDelegate::MessageBytesDelegate(QObject *parent, bool multiple_lines) + : font_metrics(QApplication::font()), multiple_lines(multiple_lines), QStyledItemDelegate(parent) { + fixed_font = QFontDatabase::systemFont(QFontDatabase::FixedFont); + byte_size = QFontMetrics(fixed_font).size(Qt::TextSingleLine, "00 ") + QSize(0, 2); + for (int i = 0; i < 256; ++i) { + hex_text_table[i].setText(QStringLiteral("%1").arg(i, 2, 16, QLatin1Char('0')).toUpper()); + hex_text_table[i].prepare({}, fixed_font); + } + h_margin = QApplication::style()->pixelMetric(QStyle::PM_FocusFrameHMargin) + 1; + v_margin = QApplication::style()->pixelMetric(QStyle::PM_FocusFrameVMargin) + 1; +} + +QSize MessageBytesDelegate::sizeForBytes(int n) const { + int rows = multiple_lines ? std::max(1, n / 8) : 1; + return {(n / rows) * byte_size.width() + h_margin * 2, rows * byte_size.height() + v_margin * 2}; +} + +QSize MessageBytesDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const { + auto data = index.data(BytesRole); + return sizeForBytes(data.isValid() ? static_cast *>(data.value())->size() : 0); +} + +void MessageBytesDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const { + if (option.state & QStyle::State_Selected) { + painter->fillRect(option.rect, option.palette.brush(QPalette::Normal, QPalette::Highlight)); + } + + QRect item_rect = option.rect.adjusted(h_margin, v_margin, -h_margin, -v_margin); + QColor highlighted_color = option.palette.color(QPalette::HighlightedText); + auto text_color = index.data(Qt::ForegroundRole).value(); + bool inactive = text_color.isValid(); + if (!inactive) { + text_color = option.palette.color(QPalette::Text); + } + auto data = index.data(BytesRole); + if (!data.isValid()) { + painter->setFont(option.font); + painter->setPen(option.state & QStyle::State_Selected ? highlighted_color : text_color); + QString text = font_metrics.elidedText(index.data(Qt::DisplayRole).toString(), Qt::ElideRight, item_rect.width()); + painter->drawText(item_rect, Qt::AlignLeft | Qt::AlignVCenter, text); + return; + } + + // Paint hex column + const auto &bytes = *static_cast *>(data.value()); + const auto &colors = *static_cast *>(index.data(ColorsRole).value()); + + painter->setFont(fixed_font); + const QPen text_pen(option.state & QStyle::State_Selected ? highlighted_color : text_color); + const QPoint pt = item_rect.topLeft(); + for (int i = 0; i < bytes.size(); ++i) { + int row = !multiple_lines ? 0 : i / 8; + int column = !multiple_lines ? i : i % 8; + QRect r({pt.x() + column * byte_size.width(), pt.y() + row * byte_size.height()}, byte_size); + + if (!inactive && i < colors.size() && colors[i].alpha() > 0) { + if (option.state & QStyle::State_Selected) { + painter->setPen(option.palette.color(QPalette::Text)); + painter->fillRect(r, option.palette.color(QPalette::Window)); + } + painter->fillRect(r, toQColor(colors[i])); + } else { + painter->setPen(text_pen); + } + utils::drawStaticText(painter, r, hex_text_table[bytes[i]]); + } +} + +// TabBar + +int TabBar::addTab(const QString &text) { + int index = QTabBar::addTab(text); + QToolButton *btn = new ToolButton("x", tr("Close Tab")); + int width = style()->pixelMetric(QStyle::PM_TabCloseIndicatorWidth, nullptr, btn); + int height = style()->pixelMetric(QStyle::PM_TabCloseIndicatorHeight, nullptr, btn); + btn->setFixedSize({width, height}); + setTabButton(index, QTabBar::RightSide, btn); + QObject::connect(btn, &QToolButton::clicked, this, &TabBar::closeTabClicked); + return index; +} + +void TabBar::closeTabClicked() { + QObject *object = sender(); + for (int i = 0; i < count(); ++i) { + if (tabButton(i, QTabBar::RightSide) == object) { + emit tabCloseRequested(i); + break; + } + } +} + +// validators + +static QValidator::State toQtState(ValidState s) { + switch (s) { + case ValidState::Acceptable: return QValidator::Acceptable; + case ValidState::Intermediate: return QValidator::Intermediate; + default: return QValidator::Invalid; + } +} + +QValidator::State NameValidator::validate(QString &input, int &pos) const { + std::string s = input.toStdString(); + auto state = validateName(s); + input = QString::fromStdString(s); + return toQtState(state); +} + +QValidator::State NodeValidator::validate(QString &input, int &pos) const { + return toQtState(validateNodes(input.toStdString())); +} + +QValidator::State NonWhitespaceValidator::validate(QString &input, int &pos) const { + return toQtState(validateNonWhitespace(input.toStdString())); +} + +QValidator::State IpAddressValidator::validate(QString &input, int &pos) const { + return toQtState(validateIpAddress(input.toStdString())); +} + +QValidator::State DoubleValidator::validate(QString &input, int &pos) const { + return toQtState(validateDouble(input.toLatin1().toStdString())); +} + +namespace utils { + +bool isDarkTheme() { + QColor windowColor = QApplication::palette().color(QPalette::Window); + return windowColor.lightness() < 128; +} + +QPixmap icon(const QString &id) { + bool dark_theme = isDarkTheme(); + + QPixmap pm; + QString key = "bootstrap_" % id % (dark_theme ? "1" : "0"); + if (!QPixmapCache::find(key, &pm)) { + pm = bootstrapPixmap(id); + if (dark_theme) { + QPainter p(&pm); + p.setCompositionMode(QPainter::CompositionMode_SourceIn); + p.fillRect(pm.rect(), QColor("#bbbbbb")); + } + QPixmapCache::insert(key, pm); + } + return pm; +} + +void setTheme(int theme) { + auto style = QApplication::style(); + if (!style) return; + + static int prev_theme = 0; + if (theme != prev_theme) { + prev_theme = theme; + QPalette new_palette; + if (theme == DARK_THEME) { + new_palette.setColor(QPalette::Window, toQColor(DarkTheme::window)); + new_palette.setColor(QPalette::WindowText, toQColor(DarkTheme::window_text)); + new_palette.setColor(QPalette::Base, toQColor(DarkTheme::base)); + new_palette.setColor(QPalette::AlternateBase, toQColor(DarkTheme::base)); + new_palette.setColor(QPalette::ToolTipBase, toQColor(DarkTheme::base)); + new_palette.setColor(QPalette::ToolTipText, toQColor(DarkTheme::tooltip_text)); + new_palette.setColor(QPalette::Text, toQColor(DarkTheme::text)); + new_palette.setColor(QPalette::Button, toQColor(DarkTheme::button)); + new_palette.setColor(QPalette::ButtonText, toQColor(DarkTheme::window_text)); + new_palette.setColor(QPalette::Highlight, toQColor(DarkTheme::highlight)); + new_palette.setColor(QPalette::HighlightedText, toQColor(DarkTheme::window_text)); + new_palette.setColor(QPalette::BrightText, toQColor(DarkTheme::bright_text)); + new_palette.setColor(QPalette::Disabled, QPalette::ButtonText, toQColor(DarkTheme::disabled_text)); + new_palette.setColor(QPalette::Disabled, QPalette::WindowText, toQColor(DarkTheme::disabled_text)); + new_palette.setColor(QPalette::Disabled, QPalette::Text, toQColor(DarkTheme::disabled_text)); + new_palette.setColor(QPalette::Light, toQColor(DarkTheme::light)); + new_palette.setColor(QPalette::Dark, toQColor(DarkTheme::dark)); + } else { + new_palette = style->standardPalette(); + } + qApp->setPalette(new_palette); + style->polish(qApp); + for (auto w : QApplication::allWidgets()) { + w->setPalette(new_palette); + } + } +} + +} // namespace utils + +void sigTermHandler(int s) { + std::signal(s, SIG_DFL); + qApp->quit(); +} + +void initApp(int argc, char *argv[], bool disable_hidpi) { + // setup signal handlers to exit gracefully + std::signal(SIGINT, sigTermHandler); + std::signal(SIGTERM, sigTermHandler); + +#ifdef __APPLE__ + // Get the devicePixelRatio, and scale accordingly to maintain 1:1 rendering + QApplication tmp(argc, argv); + if (disable_hidpi) { + qputenv("QT_SCALE_FACTOR", QString::number(1.0 / tmp.devicePixelRatio()).toLocal8Bit()); + } +#endif + + qputenv("QT_DBL_CLICK_DIST", "150"); + // ensure the current dir matches the exectuable's directory + std::error_code ec; + std::filesystem::current_path(executableDir(), ec); +} + +QPixmap bootstrapPixmap(const QString &id) { + QPixmap pixmap; + const std::string svg = utils::bootstrapSvg(id.toStdString()); + if (!svg.empty()) { + pixmap.loadFromData((const uchar *)svg.data(), svg.size(), "svg"); + } + return pixmap; +} diff --git a/openpilot/tools/cabana/utils/qtutil.h b/openpilot/tools/cabana/utils/qtutil.h new file mode 100644 index 0000000000..db0417ec9a --- /dev/null +++ b/openpilot/tools/cabana/utils/qtutil.h @@ -0,0 +1,139 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "tools/cabana/core/observable.h" +#include "tools/cabana/dbc/dbc.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/utils/strings.h" +#include "tools/cabana/utils/util.h" + +// needed by QVariant::fromValue() in the Qt views; goes away with QVariant +Q_DECLARE_METATYPE(MessageId) +Q_DECLARE_METATYPE(ValueDescription) + +inline QColor toQColor(const CabanaColor &color) { + return QColor(color.r, color.g, color.b, color.a); +} + +class LogSlider : public QSlider { + Q_OBJECT + +public: + LogSlider(double factor, Qt::Orientation orientation, QWidget *parent = nullptr) : scale(factor), QSlider(orientation, parent) {} + + void setRange(double min, double max) { + scale.setRange(min, max); + QSlider::setRange(min, max); + setValue(QSlider::value()); + } + int value() const { return scale.value(QSlider::value(), minimum(), maximum()); } + void setValue(int v) { QSlider::setValue(scale.position(v, minimum(), maximum())); } + +private: + LogScale scale; +}; + +enum { + ColorsRole = Qt::UserRole + 1, + BytesRole = Qt::UserRole + 2 +}; + +class MessageBytesDelegate : public QStyledItemDelegate { + Q_OBJECT +public: + MessageBytesDelegate(QObject *parent, bool multiple_lines = false); + void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; + QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; + bool multipleLines() const { return multiple_lines; } + void setMultipleLines(bool v) { multiple_lines = v; } + QSize sizeForBytes(int n) const; + +private: + std::array hex_text_table; + QFontMetrics font_metrics; + QFont fixed_font; + QSize byte_size = {}; + bool multiple_lines = false; + int h_margin, v_margin; +}; + +// QValidator wrappers around the std::string validators in util.h +#define CABANA_VALIDATOR(Name) \ + class Name : public QValidator { \ + Q_OBJECT \ + public: \ + Name(QObject *parent = nullptr) : QValidator(parent) {} \ + QValidator::State validate(QString &input, int &pos) const override; \ + }; +CABANA_VALIDATOR(NameValidator) +CABANA_VALIDATOR(NodeValidator) +CABANA_VALIDATOR(NonWhitespaceValidator) +CABANA_VALIDATOR(IpAddressValidator) +CABANA_VALIDATOR(DoubleValidator) +#undef CABANA_VALIDATOR + +namespace utils { + +QPixmap icon(const QString &id); +bool isDarkTheme(); +void setTheme(int theme); +inline void drawStaticText(QPainter *p, const QRect &r, const QStaticText &text) { + auto size = (r.size() - text.size()) / 2; + p->drawStaticText(r.left() + size.width(), r.top() + size.height(), text); +} +inline auto qbytes(const std::vector &dat) { + return decltype(QString().toUtf8())((const char *)dat.data(), (int)dat.size()); +} + +} + +class ToolButton : public QToolButton { + Q_OBJECT +public: + ToolButton(const QString &icon, const QString &tooltip = {}, QWidget *parent = nullptr) : QToolButton(parent) { + setIcon(icon); + setToolTip(tooltip); + setAutoRaise(true); + const int metric = QApplication::style()->pixelMetric(QStyle::PM_SmallIconSize); + setIconSize({metric, metric}); + theme = settings.theme; + settings_connection_ = settings.changed.connect([this]() { updateIcon(); }); + } + void setIcon(const QString &icon) { + icon_str = icon; + QToolButton::setIcon(utils::icon(icon_str)); + } + +private: + void updateIcon() { if (std::exchange(theme, settings.theme) != theme) setIcon(icon_str); } + Connection settings_connection_; + QString icon_str; + int theme; +}; + +class TabBar : public QTabBar { + Q_OBJECT + +public: + TabBar(QWidget *parent) : QTabBar(parent) {} + int addTab(const QString &text); + +private: + void closeTabClicked(); +}; + +void initApp(int argc, char *argv[], bool disable_hidpi = true); +QPixmap bootstrapPixmap(const QString &id); diff --git a/openpilot/tools/cabana/utils/strings.cc b/openpilot/tools/cabana/utils/strings.cc new file mode 100644 index 0000000000..5590b6078e --- /dev/null +++ b/openpilot/tools/cabana/utils/strings.cc @@ -0,0 +1,57 @@ +#include "tools/cabana/utils/strings.h" + +#include +#include +#include +#include + +#include "tools/cabana/dbc/dbc.h" + +namespace utils { + +std::string formatSeconds(double sec, bool include_milliseconds, bool absolute_time) { + char out[80] = {}; + if (absolute_time) { + const auto ms_total = static_cast(std::llround(sec * 1000.0)); + const std::time_t secs = static_cast(ms_total / 1000); + int millis = static_cast(ms_total % 1000); + if (millis < 0) millis = -millis; + std::tm tm{}; + localtime_r(&secs, &tm); + char buf[64] = {}; + std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm); + if (!include_milliseconds) return buf; + snprintf(out, sizeof(out), "%s.%03d", buf, millis); + return out; + } + + // Relative duration (not wall-clock). + const bool show_hours = sec > 60 * 60; + int total_ms = static_cast(std::llround(std::max(0.0, sec) * 1000.0)); + const int hours = total_ms / (3600 * 1000); + const int minutes = (total_ms / (60 * 1000)) % 60; + const int seconds = (total_ms / 1000) % 60; + const int millis = total_ms % 1000; + if (show_hours && include_milliseconds) { + snprintf(out, sizeof(out), "%02d:%02d:%02d.%03d", hours, minutes, seconds, millis); + } else if (show_hours) { + snprintf(out, sizeof(out), "%02d:%02d:%02d", hours, minutes, seconds); + } else if (include_milliseconds) { + snprintf(out, sizeof(out), "%02d:%02d.%03d", minutes, seconds, millis); + } else { + snprintf(out, sizeof(out), "%02d:%02d", minutes, seconds); + } + return out; +} + +std::string signalToolTip(const cabana::Signal *sig) { + std::ostringstream s; + s << "\n " << sig->name << "
\n" + << " Start Bit: " << sig->start_bit << " Size: " << sig->size << "
\n" + << " MSB: " << sig->msb << " LSB: " << sig->lsb << "
\n" + << " Little Endian: " << (sig->is_little_endian ? "Y" : "N") + << " Signed: " << (sig->is_signed ? "Y" : "N") << "
\n "; + return s.str(); +} + +} // namespace utils diff --git a/openpilot/tools/cabana/utils/strings.h b/openpilot/tools/cabana/utils/strings.h new file mode 100644 index 0000000000..f6581c3164 --- /dev/null +++ b/openpilot/tools/cabana/utils/strings.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include +#include + +namespace cabana { class Signal; } + +namespace utils { + +std::string formatSeconds(double sec, bool include_milliseconds = false, bool absolute_time = false); +std::string signalToolTip(const cabana::Signal *sig); + +inline std::string toHex(const std::vector &dat, char separator = '\0') { + static const char digits[] = "0123456789ABCDEF"; + std::string hex; + hex.reserve(dat.size() * (separator ? 3 : 2)); + for (size_t i = 0; i < dat.size(); ++i) { + if (separator && i) hex += separator; + hex += digits[dat[i] >> 4]; + hex += digits[dat[i] & 0xf]; + } + return hex; +} + +inline std::string toHexString(int value) { + char buf[16] = {}; + snprintf(buf, sizeof(buf), "0x%02X", value); + return buf; +} + +} // namespace utils diff --git a/openpilot/tools/cabana/utils/util.cc b/openpilot/tools/cabana/utils/util.cc index 87a0f89427..7bdf225975 100644 --- a/openpilot/tools/cabana/utils/util.cc +++ b/openpilot/tools/cabana/utils/util.cc @@ -1,46 +1,69 @@ #include "tools/cabana/utils/util.h" #include +#include #include -#include #include #include #include #include -#include +#include #include #include -#include +#include #include +#include +#include #include #include #include +#ifdef __APPLE__ +#include +#endif -#include -#include -#include -#include -#include #include "common/util.h" -// SegmentTree +static const std::thread::id main_thread_id = std::this_thread::get_id(); +static std::mutex main_thread_queue_mutex; +static std::vector> main_thread_queue; -void SegmentTree::build(const std::vector &arr) { - size = arr.size(); - tree.resize(4 * size); // size of the tree is 4 times the size of the array - if (size > 0) { - build_tree(arr, 1, 0, size - 1); +bool utils::isMainThread() { return std::this_thread::get_id() == main_thread_id; } + +void utils::runOnMainThread(std::function fn) { + if (isMainThread()) { + fn(); + } else { + std::lock_guard lk(main_thread_queue_mutex); + main_thread_queue.push_back(std::move(fn)); } } -void SegmentTree::build_tree(const std::vector &arr, int n, int left, int right) { +void utils::drainMainThreadQueue() { + std::vector> fns; + { + std::lock_guard lk(main_thread_queue_mutex); + fns.swap(main_thread_queue); + } + for (auto &fn : fns) fn(); +} + +// SegmentTree + +void SegmentTree::build(int n, const std::function &y) { + size = n; + tree.resize(4 * size); // size of the tree is 4 times the size of the array + if (size > 0) { + build_tree(y, 1, 0, size - 1); + } +} + +void SegmentTree::build_tree(const std::function &y, int n, int left, int right) { if (left == right) { - const double y = arr[left].y(); - tree[n] = {y, y}; + tree[n] = {y(left), y(left)}; } else { const int mid = (left + right) >> 1; - build_tree(arr, 2 * n, left, mid); - build_tree(arr, 2 * n + 1, mid + 1, right); + build_tree(y, 2 * n, left, mid); + build_tree(y, 2 * n + 1, mid + 1, right); tree[n] = {std::min(tree[2 * n].first, tree[2 * n + 1].first), std::max(tree[2 * n].second, tree[2 * n + 1].second)}; } } @@ -56,119 +79,22 @@ std::pair SegmentTree::get_minmax(int n, int left, int right, in return {std::min(l.first, r.first), std::max(l.second, r.second)}; } -// MessageBytesDelegate - -MessageBytesDelegate::MessageBytesDelegate(QObject *parent, bool multiple_lines) - : font_metrics(QApplication::font()), multiple_lines(multiple_lines), QStyledItemDelegate(parent) { - fixed_font = QFontDatabase::systemFont(QFontDatabase::FixedFont); - byte_size = QFontMetrics(fixed_font).size(Qt::TextSingleLine, "00 ") + QSize(0, 2); - for (int i = 0; i < 256; ++i) { - hex_text_table[i].setText(QStringLiteral("%1").arg(i, 2, 16, QLatin1Char('0')).toUpper()); - hex_text_table[i].prepare({}, fixed_font); - } - h_margin = QApplication::style()->pixelMetric(QStyle::PM_FocusFrameHMargin) + 1; - v_margin = QApplication::style()->pixelMetric(QStyle::PM_FocusFrameVMargin) + 1; -} - -QSize MessageBytesDelegate::sizeForBytes(int n) const { - int rows = multiple_lines ? std::max(1, n / 8) : 1; - return {(n / rows) * byte_size.width() + h_margin * 2, rows * byte_size.height() + v_margin * 2}; -} - -QSize MessageBytesDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const { - auto data = index.data(BytesRole); - return sizeForBytes(data.isValid() ? static_cast *>(data.value())->size() : 0); -} - -void MessageBytesDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const { - if (option.state & QStyle::State_Selected) { - painter->fillRect(option.rect, option.palette.brush(QPalette::Normal, QPalette::Highlight)); - } - - QRect item_rect = option.rect.adjusted(h_margin, v_margin, -h_margin, -v_margin); - QColor highlighted_color = option.palette.color(QPalette::HighlightedText); - auto text_color = index.data(Qt::ForegroundRole).value(); - bool inactive = text_color.isValid(); - if (!inactive) { - text_color = option.palette.color(QPalette::Text); - } - auto data = index.data(BytesRole); - if (!data.isValid()) { - painter->setFont(option.font); - painter->setPen(option.state & QStyle::State_Selected ? highlighted_color : text_color); - QString text = font_metrics.elidedText(index.data(Qt::DisplayRole).toString(), Qt::ElideRight, item_rect.width()); - painter->drawText(item_rect, Qt::AlignLeft | Qt::AlignVCenter, text); - return; - } - - // Paint hex column - const auto &bytes = *static_cast *>(data.value()); - const auto &colors = *static_cast *>(index.data(ColorsRole).value()); - - painter->setFont(fixed_font); - const QPen text_pen(option.state & QStyle::State_Selected ? highlighted_color : text_color); - const QPoint pt = item_rect.topLeft(); - for (int i = 0; i < bytes.size(); ++i) { - int row = !multiple_lines ? 0 : i / 8; - int column = !multiple_lines ? i : i % 8; - QRect r({pt.x() + column * byte_size.width(), pt.y() + row * byte_size.height()}, byte_size); - - if (!inactive && i < colors.size() && colors[i].alpha() > 0) { - if (option.state & QStyle::State_Selected) { - painter->setPen(option.palette.color(QPalette::Text)); - painter->fillRect(r, option.palette.color(QPalette::Window)); - } - painter->fillRect(r, toQColor(colors[i])); - } else { - painter->setPen(text_pen); - } - utils::drawStaticText(painter, r, hex_text_table[bytes[i]]); - } -} - -// TabBar - -int TabBar::addTab(const QString &text) { - int index = QTabBar::addTab(text); - QToolButton *btn = new ToolButton("x", tr("Close Tab")); - int width = style()->pixelMetric(QStyle::PM_TabCloseIndicatorWidth, nullptr, btn); - int height = style()->pixelMetric(QStyle::PM_TabCloseIndicatorHeight, nullptr, btn); - btn->setFixedSize({width, height}); - setTabButton(index, QTabBar::RightSide, btn); - QObject::connect(btn, &QToolButton::clicked, this, &TabBar::closeTabClicked); - return index; -} - -void TabBar::closeTabClicked() { - QObject *object = sender(); - for (int i = 0; i < count(); ++i) { - if (tabButton(i, QTabBar::RightSide) == object) { - emit tabCloseRequested(i); - break; - } - } -} - // UnixSignalHandler -UnixSignalHandler::UnixSignalHandler() { +UnixSignalHandler::UnixSignalHandler(std::function on_signal) { if (::socketpair(AF_UNIX, SOCK_STREAM, 0, sig_fd)) { - qFatal("Couldn't create TERM socketpair"); + fprintf(stderr, "Couldn't create TERM socketpair\n"); + abort(); } - waiter = std::thread([this]() { + waiter = std::thread([this, on_signal = std::move(on_signal)]() { int tmp = 0; while (::read(sig_fd[1], &tmp, sizeof(tmp)) < 0) { if (errno != EINTR) return; } if (shutting_down.load()) return; - // Marshal exit onto the GUI thread (qApp methods are not thread-safe). - QMetaObject::invokeMethod(qApp, []() { - printf("\nexiting...\n"); - qApp->closeAllWindows(); - qApp->exit(); - }, Qt::QueuedConnection); + on_signal(); }); std::signal(SIGINT, signalHandler); @@ -188,118 +114,131 @@ void UnixSignalHandler::signalHandler(int s) { (void)!::write(sig_fd[0], &s, sizeof(s)); } -// NameValidator +// validators -NameValidator::NameValidator(QObject *parent) : QValidator(parent) {} - -QValidator::State NameValidator::validate(QString &input, int &pos) const { - Q_UNUSED(pos); - input.replace(' ', '_'); - if (input.isEmpty()) return QValidator::Intermediate; - for (const QChar &c : input) { - if (!c.isLetterOrNumber() && c != '_') return QValidator::Invalid; +ValidState validateName(std::string &input) { + std::replace(input.begin(), input.end(), ' ', '_'); + if (input.empty()) return ValidState::Intermediate; + for (const unsigned char c : input) { + if (!std::isalnum(c) && c != '_') return ValidState::Invalid; } - return QValidator::Acceptable; + return ValidState::Acceptable; } -// NodeValidator - -NodeValidator::NodeValidator(QObject *parent) : QValidator(parent) {} - -QValidator::State NodeValidator::validate(QString &input, int &pos) const { - Q_UNUSED(pos); - if (input.isEmpty()) return QValidator::Intermediate; +ValidState validateNodes(const std::string &input) { + if (input.empty()) return ValidState::Intermediate; // Match ^\w+(,\w+)*$ ; a trailing comma is Intermediate (user still typing). bool need_word = true; - for (const QChar &c : input) { - if (c.isLetterOrNumber() || c == '_') { + for (const unsigned char c : input) { + if (std::isalnum(c) || c == '_') { need_word = false; } else if (c == ',' && !need_word) { need_word = true; } else { - return QValidator::Invalid; + return ValidState::Invalid; } } - return need_word ? QValidator::Intermediate : QValidator::Acceptable; + return need_word ? ValidState::Intermediate : ValidState::Acceptable; } -// NonWhitespaceValidator - -NonWhitespaceValidator::NonWhitespaceValidator(QObject *parent) : QValidator(parent) {} - -QValidator::State NonWhitespaceValidator::validate(QString &input, int &pos) const { - Q_UNUSED(pos); - if (input.isEmpty()) return QValidator::Intermediate; - for (const QChar &c : input) { - if (c.isSpace()) return QValidator::Invalid; +ValidState validateNonWhitespace(const std::string &input) { + if (input.empty()) return ValidState::Intermediate; + for (const unsigned char c : input) { + if (std::isspace(c)) return ValidState::Invalid; } - return QValidator::Acceptable; + return ValidState::Acceptable; } -// IpAddressValidator - -IpAddressValidator::IpAddressValidator(QObject *parent) : QValidator(parent) {} - -QValidator::State IpAddressValidator::validate(QString &input, int &pos) const { - Q_UNUSED(pos); - if (input.isEmpty()) return QValidator::Intermediate; +ValidState validateIpAddress(const std::string &input) { + if (input.empty()) return ValidState::Intermediate; int dots = 0; int value = 0; bool has_digit = false; - for (const QChar &c : input) { - if (c.isDigit()) { - value = has_digit ? value * 10 + c.digitValue() : c.digitValue(); - if (value > 255) return QValidator::Invalid; + for (const unsigned char c : input) { + if (std::isdigit(c)) { + value = has_digit ? value * 10 + (c - '0') : (c - '0'); + if (value > 255) return ValidState::Invalid; has_digit = true; } else if (c == '.') { - if (!has_digit || dots >= 3) return QValidator::Invalid; + if (!has_digit || dots >= 3) return ValidState::Invalid; ++dots; has_digit = false; value = 0; } else { - return QValidator::Invalid; + return ValidState::Invalid; } } - return (dots == 3 && has_digit) ? QValidator::Acceptable : QValidator::Intermediate; + return (dots == 3 && has_digit) ? ValidState::Acceptable : ValidState::Intermediate; } -DoubleValidator::DoubleValidator(QObject *parent) : QValidator(parent) {} +ValidState validateDouble(const std::string &input) { + if (input.empty()) return ValidState::Intermediate; -QValidator::State DoubleValidator::validate(QString &input, int &pos) const { - Q_UNUSED(pos); - if (input.isEmpty()) return QValidator::Intermediate; - - // Match QString::toDouble(): C locale, no hex floats / inf / nan. - const std::string bytes = input.toLatin1().toStdString(); - // strtod accepts 0x… hex floats and p-exponents; QString::toDouble does not. - if (bytes.find_first_of("xXpP") != std::string::npos) { - return QValidator::Invalid; + // C locale, no hex floats / p-exponents / inf / nan (strtod accepts them, the DBC parser does not) + if (input.find_first_of("xXpP") != std::string::npos) { + return ValidState::Invalid; } - const char *start = bytes.c_str(); + const char *start = input.c_str(); char *end = nullptr; const double value = std::strtod(start, &end); if (end == start) { // Still typing a sign, decimal point, or exponent prefix. if (input == "-" || input == "+" || input == "." || input == "-." || input == "+.") { - return QValidator::Intermediate; + return ValidState::Intermediate; } - return QValidator::Invalid; + return ValidState::Invalid; } if (*end == '\0') { - // Reject inf/nan (strtod accepts them; QDoubleValidator / toDouble path should not). - return std::isfinite(value) ? QValidator::Acceptable : QValidator::Invalid; + return std::isfinite(value) ? ValidState::Acceptable : ValidState::Invalid; } // Partial exponent / trailing sign while typing (e.g. "1e", "1e-", "1."). for (const char *p = end; *p; ++p) { const char c = *p; if (!(c == 'e' || c == 'E' || c == '+' || c == '-' || c == '.' || (c >= '0' && c <= '9'))) { - return QValidator::Invalid; + return ValidState::Invalid; } } - return QValidator::Intermediate; + return ValidState::Intermediate; +} + +// embedded at build time from the bootstrap_icons package (see SConscript) +extern const unsigned char bootstrap_icons_svg[]; +extern const size_t bootstrap_icons_svg_len; + +static std::unordered_map load_bootstrap_icons() { + std::unordered_map icons; + + const std::string content(reinterpret_cast(bootstrap_icons_svg), bootstrap_icons_svg_len); + const std::string sym_open = " with + svg_str.replace(0, 7, " ""); // "" (9) -> "" (6) + icons[id] = std::move(svg_str); + } + } + pos = end; + } + return icons; } namespace utils { @@ -356,187 +295,30 @@ bool setClipboardText(const std::string &text) { return false; } -bool isDarkTheme() { - QColor windowColor = QApplication::palette().color(QPalette::Window); - return windowColor.lightness() < 128; -} - -QPixmap icon(const QString &id) { - bool dark_theme = isDarkTheme(); - - QPixmap pm; - QString key = "bootstrap_" % id % (dark_theme ? "1" : "0"); - if (!QPixmapCache::find(key, &pm)) { - pm = bootstrapPixmap(id); - if (dark_theme) { - QPainter p(&pm); - p.setCompositionMode(QPainter::CompositionMode_SourceIn); - p.fillRect(pm.rect(), QColor("#bbbbbb")); - } - QPixmapCache::insert(key, pm); - } - return pm; -} - -void setTheme(int theme) { - auto style = QApplication::style(); - if (!style) return; - - static int prev_theme = 0; - if (theme != prev_theme) { - prev_theme = theme; - QPalette new_palette; - if (theme == DARK_THEME) { - // "Darcula" like dark theme - new_palette.setColor(QPalette::Window, QColor("#353535")); - new_palette.setColor(QPalette::WindowText, QColor("#bbbbbb")); - new_palette.setColor(QPalette::Base, QColor("#3c3f41")); - new_palette.setColor(QPalette::AlternateBase, QColor("#3c3f41")); - new_palette.setColor(QPalette::ToolTipBase, QColor("#3c3f41")); - new_palette.setColor(QPalette::ToolTipText, QColor("#bbb")); - new_palette.setColor(QPalette::Text, QColor("#bbbbbb")); - new_palette.setColor(QPalette::Button, QColor("#3c3f41")); - new_palette.setColor(QPalette::ButtonText, QColor("#bbbbbb")); - new_palette.setColor(QPalette::Highlight, QColor("#2f65ca")); - new_palette.setColor(QPalette::HighlightedText, QColor("#bbbbbb")); - new_palette.setColor(QPalette::BrightText, QColor("#f0f0f0")); - new_palette.setColor(QPalette::Disabled, QPalette::ButtonText, QColor("#777777")); - new_palette.setColor(QPalette::Disabled, QPalette::WindowText, QColor("#777777")); - new_palette.setColor(QPalette::Disabled, QPalette::Text, QColor("#777777")); - new_palette.setColor(QPalette::Light, QColor("#777777")); - new_palette.setColor(QPalette::Dark, QColor("#353535")); - } else { - new_palette = style->standardPalette(); - } - qApp->setPalette(new_palette); - style->polish(qApp); - for (auto w : QApplication::allWidgets()) { - w->setPalette(new_palette); - } - } -} - -QString formatSeconds(double sec, bool include_milliseconds, bool absolute_time) { - if (absolute_time) { - const auto ms_total = static_cast(std::llround(sec * 1000.0)); - const std::time_t secs = static_cast(ms_total / 1000); - int millis = static_cast(ms_total % 1000); - if (millis < 0) millis = -millis; - std::tm tm{}; - localtime_r(&secs, &tm); - char buf[64]; - std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm); - if (include_milliseconds) { - return QString::asprintf("%s.%03d", buf, millis); - } - return QString::fromUtf8(buf); - } - - // Relative duration (not wall-clock). - const bool show_hours = sec > 60 * 60; - int total_ms = static_cast(std::llround(std::max(0.0, sec) * 1000.0)); - const int hours = total_ms / (3600 * 1000); - const int minutes = (total_ms / (60 * 1000)) % 60; - const int seconds = (total_ms / 1000) % 60; - const int millis = total_ms % 1000; - if (show_hours) { - return include_milliseconds ? QString::asprintf("%02d:%02d:%02d.%03d", hours, minutes, seconds, millis) - : QString::asprintf("%02d:%02d:%02d", hours, minutes, seconds); - } - return include_milliseconds ? QString::asprintf("%02d:%02d.%03d", minutes, seconds, millis) - : QString::asprintf("%02d:%02d", minutes, seconds); +std::string bootstrapSvg(const std::string &id) { + static auto icons = load_bootstrap_icons(); + auto it = icons.find(id); + return it != icons.end() ? it->second : std::string(); } } // namespace utils int num_decimals(double num) { - const QString string = QString::number(num); - auto dot_pos = string.indexOf('.'); - return dot_pos == -1 ? 0 : string.size() - dot_pos - 1; + char buf[32]; + snprintf(buf, sizeof(buf), "%g", num); + const char *dot = strpbrk(buf, ".,"); // Qt sets LC_ALL from the environment so the decimal mark may be a comma + return dot ? (int)strlen(dot + 1) : 0; } -QString signalToolTip(const cabana::Signal *sig) { - return QObject::tr(R"( - %1
- Start Bit: %2 Size: %3
- MSB: %4 LSB: %5
- Little Endian: %6 Signed: %7
- )").arg(QString::fromStdString(sig->name)).arg(sig->start_bit).arg(sig->size).arg(sig->msb).arg(sig->lsb) - .arg(sig->is_little_endian ? "Y" : "N").arg(sig->is_signed ? "Y" : "N"); -} - -void sigTermHandler(int s) { - std::signal(s, SIG_DFL); - qApp->quit(); -} - -void initApp(int argc, char *argv[], bool disable_hidpi) { - // setup signal handlers to exit gracefully - std::signal(SIGINT, sigTermHandler); - std::signal(SIGTERM, sigTermHandler); - - std::filesystem::path app_dir; +std::filesystem::path executableDir() { #ifdef __APPLE__ - // Get the devicePixelRatio, and scale accordingly to maintain 1:1 rendering - QApplication tmp(argc, argv); - app_dir = QCoreApplication::applicationDirPath().toStdString(); - if (disable_hidpi) { - qputenv("QT_SCALE_FACTOR", QString::number(1.0 / tmp.devicePixelRatio()).toLocal8Bit()); - } -#else - app_dir = std::filesystem::path(util::readlink("/proc/self/exe")).parent_path(); -#endif - - qputenv("QT_DBL_CLICK_DIST", "150"); - // ensure the current dir matches the exectuable's directory + char buf[PATH_MAX]; + uint32_t size = sizeof(buf); + if (_NSGetExecutablePath(buf, &size) != 0) return {}; std::error_code ec; - std::filesystem::current_path(app_dir, ec); -} - -// embedded at build time from the bootstrap_icons package (see SConscript) -extern const unsigned char bootstrap_icons_svg[]; -extern const size_t bootstrap_icons_svg_len; - -static std::unordered_map load_bootstrap_icons() { - std::unordered_map icons; - - const std::string content(reinterpret_cast(bootstrap_icons_svg), bootstrap_icons_svg_len); - const std::string sym_open = " with - svg_str.replace(0, 7, " ""); // "" (9) -> "" (6) - icons[id] = std::move(svg_str); - } - } - pos = end; - } - return icons; -} - -QPixmap bootstrapPixmap(const QString &id) { - static auto icons = load_bootstrap_icons(); - - QPixmap pixmap; - auto it = icons.find(id.toStdString()); - if (it != icons.end()) { - pixmap.loadFromData((const uchar *)it->second.data(), it->second.size(), "svg"); - } - return pixmap; + auto path = std::filesystem::canonical(buf, ec); + return (ec ? std::filesystem::path(buf) : path).parent_path(); +#else + return std::filesystem::path(util::readlink("/proc/self/exe")).parent_path(); +#endif } diff --git a/openpilot/tools/cabana/utils/util.h b/openpilot/tools/cabana/utils/util.h index 5a3c62d118..324c966b5a 100644 --- a/openpilot/tools/cabana/utils/util.h +++ b/openpilot/tools/cabana/utils/util.h @@ -1,209 +1,102 @@ #pragma once -#include +#include #include #include #include +#include #include #include #include #include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/dbc/dbc.h" -#include "tools/cabana/settings.h" - -inline QColor toQColor(const CabanaColor &color) { - return QColor(color.r, color.g, color.b, color.a); -} - -class LogSlider : public QSlider { - Q_OBJECT +#include "tools/cabana/core/color.h" +class SegmentTree { public: - LogSlider(double factor, Qt::Orientation orientation, QWidget *parent = nullptr) : factor(factor), QSlider(orientation, parent) {} + SegmentTree() = default; + void build(int n, const std::function &y); + inline std::pair minmax(int left, int right) const { return get_minmax(1, 0, size - 1, left, right); } +private: + std::pair get_minmax(int n, int left, int right, int range_left, int range_right) const; + void build_tree(const std::function &y, int n, int left, int right); + std::vector> tree; + int size = 0; +}; + +// maps a linear slider position onto a log10 scale +class LogScale { +public: + LogScale(double factor) : factor(factor) {} void setRange(double min, double max) { log_min = factor * std::log10(min); log_max = factor * std::log10(max); - QSlider::setRange(min, max); - setValue(QSlider::value()); } - int value() const { - double v = log_min + (log_max - log_min) * ((QSlider::value() - minimum()) / double(maximum() - minimum())); + int value(int pos, int pos_min, int pos_max) const { + double v = log_min + (log_max - log_min) * ((pos - pos_min) / double(pos_max - pos_min)); return std::lround(std::pow(10, v / factor)); } - void setValue(int v) { + int position(int v, int pos_min, int pos_max) const { double log_v = std::clamp(factor * std::log10(v), log_min, log_max); - v = minimum() + (maximum() - minimum()) * ((log_v - log_min) / (log_max - log_min)); - QSlider::setValue(v); + return pos_min + (pos_max - pos_min) * ((log_v - log_min) / (log_max - log_min)); } private: double factor, log_min = 0, log_max = 1; }; -enum { - ColorsRole = Qt::UserRole + 1, - BytesRole = Qt::UserRole + 2 -}; +enum class ValidState { Invalid, Intermediate, Acceptable }; -class SegmentTree { -public: - SegmentTree() = default; - void build(const std::vector &arr); - inline std::pair minmax(int left, int right) const { return get_minmax(1, 0, size - 1, left, right); } +// single identifier: one or more [A-Za-z0-9_], spaces rewritten to '_' +ValidState validateName(std::string &input); +// comma-separated identifiers: \w+(,\w+)* +ValidState validateNodes(const std::string &input); +// one or more non-whitespace characters (\S+) +ValidState validateNonWhitespace(const std::string &input); +// dotted IPv4 address (0-255 per octet) +ValidState validateIpAddress(const std::string &input); +// C-locale floating-point +ValidState validateDouble(const std::string &input); -private: - std::pair get_minmax(int n, int left, int right, int range_left, int range_right) const; - void build_tree(const std::vector &arr, int n, int left, int right); - std::vector> tree; - int size = 0; -}; - -class MessageBytesDelegate : public QStyledItemDelegate { - Q_OBJECT -public: - MessageBytesDelegate(QObject *parent, bool multiple_lines = false); - void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; - QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; - bool multipleLines() const { return multiple_lines; } - void setMultipleLines(bool v) { multiple_lines = v; } - QSize sizeForBytes(int n) const; - -private: - std::array hex_text_table; - QFontMetrics font_metrics; - QFont fixed_font; - QSize byte_size = {}; - bool multiple_lines = false; - int h_margin, v_margin; -}; - -// Accepts a single identifier: one or more [A-Za-z0-9_], spaces rewritten to '_'. -class NameValidator : public QValidator { - Q_OBJECT -public: - NameValidator(QObject *parent=nullptr); - QValidator::State validate(QString &input, int &pos) const override; -}; - -// Accepts comma-separated identifiers: \w+(,\w+)* -class NodeValidator : public QValidator { - Q_OBJECT -public: - NodeValidator(QObject *parent=nullptr); - QValidator::State validate(QString &input, int &pos) const override; -}; - -// Accepts one or more non-whitespace characters (\S+). -class NonWhitespaceValidator : public QValidator { - Q_OBJECT -public: - NonWhitespaceValidator(QObject *parent=nullptr); - QValidator::State validate(QString &input, int &pos) const override; -}; - -// Accepts a dotted IPv4 address (0-255 per octet). -class IpAddressValidator : public QValidator { - Q_OBJECT -public: - IpAddressValidator(QObject *parent=nullptr); - QValidator::State validate(QString &input, int &pos) const override; -}; - -// C-locale floating-point validator (matches QString::toDouble). -class DoubleValidator : public QValidator { - Q_OBJECT -public: - DoubleValidator(QObject *parent = nullptr); - QValidator::State validate(QString &input, int &pos) const override; +// "Darcula" like dark theme +struct DarkTheme { + static constexpr CabanaColor window{0x35, 0x35, 0x35}; + static constexpr CabanaColor window_text{0xbb, 0xbb, 0xbb}; + static constexpr CabanaColor base{0x3c, 0x3f, 0x41}; + static constexpr CabanaColor tooltip_text{0xbb, 0xbb, 0xbb}; + static constexpr CabanaColor text{0xbb, 0xbb, 0xbb}; + static constexpr CabanaColor button{0x3c, 0x3f, 0x41}; + static constexpr CabanaColor highlight{0x2f, 0x65, 0xca}; + static constexpr CabanaColor bright_text{0xf0, 0xf0, 0xf0}; + static constexpr CabanaColor disabled_text{0x77, 0x77, 0x77}; + static constexpr CabanaColor light{0x77, 0x77, 0x77}; + static constexpr CabanaColor dark{0x35, 0x35, 0x35}; }; namespace utils { -QPixmap icon(const QString &id); +bool isMainThread(); +// inline on the main thread, queued until drainMainThreadQueue() otherwise +void runOnMainThread(std::function fn); +void drainMainThreadQueue(); std::string homePath(); std::filesystem::path configPath(); bool getClipboardText(std::string *text); // false if no clipboard tool is available bool setClipboardText(const std::string &text); -bool isDarkTheme(); -void setTheme(int theme); -QString formatSeconds(double sec, bool include_milliseconds = false, bool absolute_time = false); -inline void drawStaticText(QPainter *p, const QRect &r, const QStaticText &text) { - auto size = (r.size() - text.size()) / 2; - p->drawStaticText(r.left() + size.width(), r.top() + size.height(), text); -} -inline QString toHex(const std::vector &dat, char separator = '\0') { - static const char digits[] = "0123456789ABCDEF"; - QString hex; - hex.reserve(dat.size() * (separator ? 3 : 2)); - for (size_t i = 0; i < dat.size(); ++i) { - if (separator && i) hex += QLatin1Char(separator); - hex += QLatin1Char(digits[dat[i] >> 4]); - hex += QLatin1Char(digits[dat[i] & 0xf]); - } - return hex; -} +std::string bootstrapSvg(const std::string &id); // empty if unknown // boundary conversions for the remaining Qt byte-array based state APIs template std::vector toBytes(const T &dat) { return {dat.begin(), dat.end()}; } -inline auto qbytes(const std::vector &dat) { - return decltype(QString().toUtf8())((const char *)dat.data(), (int)dat.size()); -} } -class ToolButton : public QToolButton { - Q_OBJECT -public: - ToolButton(const QString &icon, const QString &tooltip = {}, QWidget *parent = nullptr) : QToolButton(parent) { - setIcon(icon); - setToolTip(tooltip); - setAutoRaise(true); - const int metric = QApplication::style()->pixelMetric(QStyle::PM_SmallIconSize); - setIconSize({metric, metric}); - theme = settings.theme; - connect(&settings, &Settings::changed, this, &ToolButton::updateIcon); - } - void setIcon(const QString &icon) { - icon_str = icon; - QToolButton::setIcon(utils::icon(icon_str)); - } - -private: - void updateIcon() { if (std::exchange(theme, settings.theme) != theme) setIcon(icon_str); } - QString icon_str; - int theme; -}; - -class TabBar : public QTabBar { - Q_OBJECT - -public: - TabBar(QWidget *parent) : QTabBar(parent) {} - int addTab(const QString &text); - -private: - void closeTabClicked(); -}; - -// Watches SIGINT/SIGTERM via a self-pipe and a dedicated waiter thread -// (no Qt notifiers/timers). Exit is marshaled onto the GUI thread. +// Watches SIGINT/SIGTERM via a self-pipe and a dedicated waiter thread. +// on_signal runs on the waiter thread; the caller marshals to the GUI thread. class UnixSignalHandler { public: - UnixSignalHandler(); + UnixSignalHandler(std::function on_signal); ~UnixSignalHandler(); static void signalHandler(int s); @@ -214,7 +107,4 @@ private: }; int num_decimals(double num); -QString signalToolTip(const cabana::Signal *sig); -inline QString toHexString(int value) { return QString("0x%1").arg(QString::number(value, 16).toUpper(), 2, '0'); } -void initApp(int argc, char *argv[], bool disable_hidpi = true); -QPixmap bootstrapPixmap(const QString &id); +std::filesystem::path executableDir(); diff --git a/openpilot/tools/cabana/videowidget.cc b/openpilot/tools/cabana/videowidget.cc index bd61573658..f7b1b41628 100644 --- a/openpilot/tools/cabana/videowidget.cc +++ b/openpilot/tools/cabana/videowidget.cc @@ -12,6 +12,7 @@ #include #include "tools/cabana/tools/routeinfo.h" +#include "tools/cabana/utils/qtutil.h" const int MIN_VIDEO_HEIGHT = 100; const int THUMBNAIL_MARGIN = 3; @@ -27,7 +28,7 @@ static const QColor timeline_colors[] = { }; static Replay *getReplay() { - auto stream = qobject_cast(can); + auto stream = dynamic_cast(can); return stream ? stream->getReplay() : nullptr; } @@ -42,11 +43,11 @@ VideoWidget::VideoWidget(QWidget *parent) : QFrame(parent) { createPlaybackController(); setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Maximum); - QObject::connect(can, &AbstractStream::paused, this, &VideoWidget::updatePlayBtnState); - QObject::connect(can, &AbstractStream::resume, this, &VideoWidget::updatePlayBtnState); - QObject::connect(can, &AbstractStream::msgsReceived, this, &VideoWidget::updateState); - QObject::connect(can, &AbstractStream::seeking, this, &VideoWidget::updateState); - QObject::connect(can, &AbstractStream::timeRangeChanged, this, &VideoWidget::timeRangeChanged); + connections_.push_back(can->paused.connect([this]() { updatePlayBtnState(); })); + connections_.push_back(can->resume.connect([this]() { updatePlayBtnState(); })); + connections_.push_back(can->msgsReceived.connect([this](const std::set *, bool) { updateState(); })); + connections_.push_back(can->seeking.connect([this](double) { updateState(); })); + connections_.push_back(can->timeRangeChanged.connect([this](const auto &) { timeRangeChanged(); })); updatePlayBtnState(); setWhatsThis(tr(R"( @@ -157,14 +158,14 @@ QWidget *VideoWidget::createCameraWidget() { slider->setTimeRange(can->minSeconds(), can->maxSeconds()); QObject::connect(slider, &QSlider::sliderReleased, [this]() { can->seekTo(slider->currentSecond()); }); - QObject::connect(can, &AbstractStream::paused, cam_widget, qOverload<>(&StreamCameraView::update)); - QObject::connect(can, &AbstractStream::eventsMerged, this, [this]() { slider->update(); }); - QObject::connect(cam_widget, &CameraWidget::clicked, []() { can->pause(!can->isPaused()); }); - QObject::connect(cam_widget, &CameraWidget::vipcAvailableStreamsUpdated, this, &VideoWidget::vipcAvailableStreamsUpdated); + connections_.push_back(can->paused.connect([this]() { cam_widget->update(); })); + connections_.push_back(can->eventsMerged.connect([this](const MessageEventsMap &) { slider->update(); })); + connections_.push_back(cam_widget->clicked.connect([]() { can->pause(!can->isPaused()); })); + connections_.push_back(cam_widget->availableStreamsUpdated.connect([this](std::set streams) { vipcAvailableStreamsUpdated(streams); })); QObject::connect(camera_tab, &QTabBar::currentChanged, [this](int index) { if (index != -1) cam_widget->setStreamType((VisionStreamType)camera_tab->tabData(index).toInt()); }); - QObject::connect(static_cast(can), &ReplayStream::qLogLoaded, cam_widget, &StreamCameraView::parseQLog, Qt::QueuedConnection); + connections_.push_back(static_cast(can)->qLogLoaded.connect([this](std::shared_ptr qlog) { cam_widget->parseQLog(qlog); })); slider->installEventFilter(this); return w; } @@ -204,7 +205,7 @@ void VideoWidget::timeRangeChanged() { QString VideoWidget::formatTime(double sec, bool include_milliseconds) { if (settings.absolute_time) sec += std::chrono::duration(can->beginDateTime().time_since_epoch()).count(); - return utils::formatSeconds(sec, include_milliseconds, settings.absolute_time); + return QString::fromStdString(utils::formatSeconds(sec, include_milliseconds, settings.absolute_time)); } void VideoWidget::updateState() { diff --git a/openpilot/tools/cabana/videowidget.h b/openpilot/tools/cabana/videowidget.h index 09f7a9931b..8416eece59 100644 --- a/openpilot/tools/cabana/videowidget.h +++ b/openpilot/tools/cabana/videowidget.h @@ -12,7 +12,7 @@ #include #include "tools/cabana/cameraview.h" -#include "tools/cabana/utils/util.h" +#include "tools/cabana/utils/qtutil.h" #include "tools/replay/logreader.h" #include "tools/cabana/streams/replaystream.h" @@ -64,6 +64,7 @@ protected: void timeRangeChanged(); void updateState(); void updatePlayBtnState(); + Connections connections_; QWidget *createCameraWidget(); void createPlaybackController(); void createSpeedDropdown(QToolBar *toolbar); diff --git a/openpilot/tools/replay/py_downloader.cc b/openpilot/tools/replay/py_downloader.cc index a265dfd6a3..db2b2be127 100644 --- a/openpilot/tools/replay/py_downloader.cc +++ b/openpilot/tools/replay/py_downloader.cc @@ -2,8 +2,11 @@ #include #include +#include +#include #include #include +#include #include #include @@ -14,8 +17,15 @@ namespace { static std::mutex handler_mutex; static DownloadProgressHandler progress_handler = nullptr; -// Run a Python command and capture stdout. Stderr is left attached to the parent. -// Returns stdout content. If abort is signaled, kills the child process. +void reportProgress(const char *line) { + uint64_t cur = 0, total = 0; + if (sscanf(line, "PROGRESS:%llu:%llu", (unsigned long long *)&cur, (unsigned long long *)&total) != 2) return; + std::lock_guard lk(handler_mutex); + if (progress_handler && total > 0) progress_handler(cur, total, true); +} + +// Run a Python command and capture stdout. Stderr is scanned for PROGRESS lines and otherwise passed +// through to the parent's stderr. Returns stdout content. If abort is signaled, kills the child process. std::string runPython(const std::vector &args, std::atomic *abort = nullptr) { // Build argv for execvp std::vector argv; @@ -27,16 +37,22 @@ std::string runPython(const std::vector &args, std::atomic *a } argv.push_back(nullptr); - int stdout_pipe[2]; + int stdout_pipe[2], stderr_pipe[2]; if (pipe(stdout_pipe) != 0) { rWarning("py_downloader: pipe() failed"); return {}; } + if (pipe(stderr_pipe) != 0) { + rWarning("py_downloader: pipe() failed"); + close(stdout_pipe[0]); close(stdout_pipe[1]); + return {}; + } pid_t pid = fork(); if (pid < 0) { rWarning("py_downloader: fork() failed"); close(stdout_pipe[0]); close(stdout_pipe[1]); + close(stderr_pipe[0]); close(stderr_pipe[1]); return {}; } @@ -57,6 +73,9 @@ std::string runPython(const std::vector &args, std::atomic *a close(stdout_pipe[0]); dup2(stdout_pipe[1], STDOUT_FILENO); close(stdout_pipe[1]); + close(stderr_pipe[0]); + dup2(stderr_pipe[1], STDERR_FILENO); + close(stderr_pipe[1]); execvp("python3", const_cast(argv.data())); _exit(127); @@ -64,6 +83,27 @@ std::string runPython(const std::vector &args, std::atomic *a // Parent process close(stdout_pipe[1]); + close(stderr_pipe[1]); + + // stderr carries the progress lines, so a thread reads it while the loop below waits on stdout + std::thread stderr_thread([fd = stderr_pipe[0]]() { + FILE *f = fdopen(fd, "r"); + if (!f) { + close(fd); + return; + } + char *line = nullptr; + size_t cap = 0; + while (getline(&line, &cap, f) > 0) { + if (strncmp(line, "PROGRESS:", 9) == 0) { + reportProgress(line); + } else { + fputs(line, stderr); + } + } + free(line); + fclose(f); + }); std::string stdout_data; char buf[4096]; @@ -102,6 +142,7 @@ std::string runPython(const std::vector &args, std::atomic *a stdout_data.append(buf, n); } close(stdout_pipe[0]); + stderr_thread.join(); int status; waitpid(pid, &status, 0); diff --git a/panda b/panda index ea5a83a956..74a0adced4 160000 --- a/panda +++ b/panda @@ -1 +1 @@ -Subproject commit ea5a83a956d61c7540c1a13a8d76f08c24675d1b +Subproject commit 74a0adced421e8b7acd728d0f9988ce225423f13 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) 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 . diff --git a/release/ci/upload_default_model.py b/release/ci/upload_default_model.py index eac48e3be4..97263ae6f5 100644 --- a/release/ci/upload_default_model.py +++ b/release/ci/upload_default_model.py @@ -38,7 +38,8 @@ def main(): api = HfApi() onnx_sha256 = hash_file(args.onnx_path) short_ref = args.onnx_ref[:8] - folder_name = f"model-{args.model_name}-{short_ref}-{args.run_number}" + safe_name = args.model_name.replace(" ", "-") + folder_name = f"model-{safe_name}-{short_ref}-{args.run_number}" print(f"ONNX hash: {onnx_sha256}") print(f"ONNX ref: {args.onnx_ref} (short: {short_ref})") diff --git a/tinygrad_repo b/tinygrad_repo index 66ee3cfb4f..e837e367aa 160000 --- a/tinygrad_repo +++ b/tinygrad_repo @@ -1 +1 @@ -Subproject commit 66ee3cfb4f3a3908a6a20ddfbec7774ba7c09b4e +Subproject commit e837e367aac9e1a66e689f4f32ce20ca9367df13 diff --git a/uv.lock b/uv.lock index b12a3283a1..30be99f497 100644 --- a/uv.lock +++ b/uv.lock @@ -234,15 +234,15 @@ wheels = [ [[package]] name = "comma-deps-raylib" -version = "6.0.0.1.post98" +version = "6.0.0.1.post101" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/21/59509b2758e3612c63336df5534ea3b36e472e06c18a50f0b96c32e8e3e0/comma_deps_raylib-6.0.0.1.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:838a6115f508ee4ca0e4c84975c0892d67c10f94b11dd2bc0e023ad65138d010", size = 2004199, upload-time = "2026-07-23T17:03:17.648Z" }, - { url = "https://files.pythonhosted.org/packages/c2/f5/84e135ac611a6dfe8b95ff989097c200770907907afba78455d4c863981e/comma_deps_raylib-6.0.0.1.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:9347fe18799c209dd1af746a44a94433bae69d4644de10e11dcc307aecd87fb1", size = 7203023, upload-time = "2026-07-23T17:03:21.525Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1b/ae0959dac622f7169de230ec98c6555870ae0ad4f61e7b8d32922fa960ed/comma_deps_raylib-6.0.0.1.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:f9fd393fbaf1f16d7785be0b2252ef3b14a953c93d89296ed9a67bc7063de882", size = 5055448, upload-time = "2026-07-23T17:03:25.737Z" }, + { url = "https://files.pythonhosted.org/packages/ff/90/e289acd1725d71c792c33399422f1052c4b0c38aeb4222a866d30c4a2cad/comma_deps_raylib-6.0.0.1.post101-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fa69d5093a92d7d2bfd2714a1afccca63b94d4c55fae88e61b80e8841de6a6cd", size = 1885392, upload-time = "2026-08-27T18:24:37.25Z" }, + { url = "https://files.pythonhosted.org/packages/a1/17/12977631f6d86d1daa4f67a310cfdf2783ba288f42d444d6a89b2297a0a4/comma_deps_raylib-6.0.0.1.post101-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:c93ff9b45df414620b3011280da77151764c027e2f95d384fe255615d78872cc", size = 21707644, upload-time = "2026-08-27T18:24:41.282Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ca/ef33aff790b37dfc925f94fbb3a86da19f67929c15b3725ddb18afb91e96/comma_deps_raylib-6.0.0.1.post101-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:8f2a1ffe5f60cac06170b144d6ce84925c91657e7ada3ae35bc5df6ccbe0b461", size = 20722616, upload-time = "2026-08-27T18:24:45.803Z" }, ] [[package]]